chore: untrack AGENTS.md, add license and AI declaration

This commit is contained in:
randogoth 2026-10-09 13:38:10 +03:00
parent 4a89ca85e0
commit 403f65c3b6
5 changed files with 225 additions and 145 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
result
result-*
.claude/
AGENTS.md

View file

@ -1,51 +0,0 @@
# General Working Rules
## Coding
- **Simple and idiomatic.** Write readable, conventional code. Prefer composition, immutability, explicit dependencies, and a single source of truth.
- **Small and focused.** Keep functions and modules single-purpose. Separate concerns and avoid unnecessary nesting, abstraction, and global mutable state.
- **Reuse existing solutions.** Follow project conventions and prefer existing tools and dependencies. Introduce new ones only when justified.
- **Minimal changes.** Implement only what is requested. Prefer deletion over rewriting, and avoid unrelated refactoring, formatting, or speculative features.
## Comments
- **Self-contained.** Explain essential context inline without requiring external documents or conversations. References to nearby source code are acceptable.
- **Concise.** Usually one sentence. Put longer explanations in brief module headers or documentation.
- **Why, not what.** Comment only on non-obvious decisions, constraints, invariants, and surprising behavior. Do not restate obvious code.
- **No unnecessary history.** Include implementation history only when essential to understanding current behavior. Remove obsolete comments.
## Documentation
- Keep documentation concise, accurate, and synchronized with the implementation.
- Distinguish implemented behavior from proposals and historical plans. Remove outdated information and shorten resolved findings.
- Update specifications alongside changes to interfaces, formats, and contracts. Record important decisions where they are enforced.
- **Natural reflow.** Write paragraphs as continuous lines without hard wrapping. Use blank lines between paragraphs and explicit line breaks only where structurally necessary.
## Working Style
- **Inspect first.** Examine existing code, configuration, architecture, and conventions rather than assuming how things work.
- **Respect scope.** Follow the latest explicit instructions. Do not make unrelated changes or resume superseded tasks.
- **Work silently.** No progress narration or request recaps. Interrupt only for genuine blockers or discoveries that materially change the approach.
- **Resolve uncertainty.** Investigate missing information before asking. Never invent requirements or assume unverified behavior.
- **Respect user decisions.** Identify significant design problems and propose concrete alternatives with trade-offs, but follow the user's choice.
- **Respect authorization.** Observe approval requirements for changes, tests, commits, deployments, and destructive operations.
- **Commit messages.** Use concise, single-line, tag-prefixed messages (e.g., fix:, feat:, refactor:, docs:). Describe what changed using the imperative mood.
- **No credit notes.** Omit attribution, co-author trailers, AI-generated notices, and other credit statements from commits and generated documentation.
## Verification
- Test affected behavior using existing project tools. Add regression tests where appropriate.
- Preserve safeguards for security, external input, data integrity, and concurrency.
- Measure consequential assumptions before making architectural decisions.
- Perform repository-wide cleanup or extensive refactoring only when explicitly requested.
- Format and validate affected code without altering unrelated files.
- Report what passed, failed, or could not be verified. Never claim untested behavior works.
## Completion
Summarize changes, verification results, important decisions, and unresolved issues in a few sentences. Avoid lengthy reports unless requested.
## Project Preferences
- use `git` colocated `jj` for version management
- use `devbox` to manage dependencies and scripting

11
AI-DECLARATION.md Normal file
View file

@ -0,0 +1,11 @@
---
version: "0.1.2"
level: copilot
---
This format is based on [AI-DECLARATION.md](https://ai-declaration.md/en/0.1.2).
## Notes
- I directed the work step by step, reviewed and committed the results, and verified the UI live; build, packaging and cross-platform scripts.
- Claude Code was used throughout, prompted and reviewed by me at each step. Its main task was UI and string refinements.

201
LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/LICENSE-2.0
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

106
README.md
View file

@ -1,16 +1,14 @@
# gsmol
An HTML5 browser client for [Smol Mail](../smolmail), the minimalist end-to-end encrypted mail protocol: one 32-byte master secret, five operations, Noise_NX transport, sealed and signed messages, and accept tokens that sort correspondence from cold mail. Everything the reference CLI client does, in a browser tab.
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Built with Devbox](https://www.jetify.com/img/devbox/shield_galaxy.svg)](https://github.com/jetify-com/devbox/) [![AI-DECLARATION: copilot](https://img.shields.io/badge/䷼%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) ![Nix Flake](https://img.shields.io/badge/Nix-Flake-5277C3?logo=nixos&logoColor=white)
## Why there is a bridge
The protocol is raw TCP with a Noise_NX handshake, and browsers cannot open TCP sockets. So the client is split in two:
[Smol Mail](../smolmail) in a browser tab. All the crypto, keys and mail handling run client-side in JavaScript; a small local bridge just relays bytes to a smolmaild server over WebSocket.
```
browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild (TCP :1961)
```
`bridge.py` serves the web app and relays WebSocket bytes to the server untouched. It holds no keys, never parses a byte of traffic, and relays only to allowlisted ports (`--allow-port`, default 1961), so it is not an open proxy. It also relays only for upgrades whose `Origin` is its own page — WebSockets are exempt from CORS, so without that check any page the user happened to visit could drive the relay. `--allow-origin` admits others, and an upgrade carrying no `Origin` at all, which a browser never sends, is left alone for non-browser clients. The Noise session, sealing, signing, pinning and trust-on-first-use all run in the browser's JavaScript — the trust boundary is the machine running the browser, as with the CLI client's `identity.key` file.
The bridge holds no keys and never parses a byte of traffic. The trust boundary is the machine running the browser.
## Run
@ -18,102 +16,22 @@ browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild
devbox run serve
```
Then open http://127.0.0.1:8096. Onboarding walks through one concern per screen: generate or restore an identity (the seed is shown once — write it down), pin a server's public key (from the operator, through a trusted channel), then claim an address on it. Then fetch.
Open http://127.0.0.1:8096. Onboarding walks you through creating or restoring an identity (the seed is shown once — write it down), pinning your server's public key, and claiming an address. Then fetch.
For a server on a non-default port, start the bridge with `uv run bridge.py --allow-port <port>`.
Non-default server port: `uv run bridge.py --allow-port <port>`.
## Deploy
`flake.nix` packages the bridge for a server, separately from `devbox`'s local dev loop:
```
nix build .#default # -> ./result/bin/gsmol-bridge
nix run . # runs it directly, default flags
```
The package wraps `bridge.py` with `python3` and `aiohttp` from nixpkgs, not `uv` — no network access at build or run time, and the script's `#!/usr/bin/env -S uv run --script` shebang stays relevant only to `devbox run serve`.
For a NixOS host, `nixosModules.default` provides `services.gsmol-bridge`:
```nix
{
inputs.gsmol.url = "git+https://code.randogoth.com/randogoth/gsmol.git";
outputs = { self, nixpkgs, gsmol, ... }: {
nixosConfigurations.mail = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
gsmol.nixosModules.default
{
services.gsmol-bridge = {
enable = true;
allowPorts = [ 1961 ]; # smolmaild's port(s)
nginx.enable = true; # TLS via ACME, in front of the bridge
nginx.domain = "mail.example.org";
};
}
];
};
};
}
```
The service runs under `DynamicUser`, restarts on failure, and — like the standalone script — listens on `127.0.0.1` by default. `nginx.enable` (or `caddy.enable`, wiring into `services.caddy.virtualHosts` — automatic HTTPS, no `enableACME`/`forceSSL` equivalent to set) puts a TLS-terminating reverse proxy in front rather than exposing the bridge directly: it has no TLS of its own, and a browser refuses a `ws://` connection from an `https://` page, so a real deployment needs one of the two regardless of `--allow-origin`. Enable only one; a deployment that replaces `services.caddy.configFile` with a hand-written Caddyfile instead of using `virtualHosts` needs the domain added there directly, proxying to `host`:`port`. `openFirewall = true` is for exposing the bridge's port directly instead of either, which is not the recommended path (see "Notes and limits").
`services.gsmol-bridge.presetServer = { host, publicKey }` bakes a server's key into the pages this deployment serves, so a first-time visitor is pre-pinned instead of asked to copy a base32 key into settings by hand (§4). The value reaches the browser over this deployment's own TLS, so it only stands in for "from the operator, through a trusted channel" when the deployment operator and the smolmaild operator are the same trusted party — set it only for a server you operate or otherwise vouch for. It seeds the first run only: the browser stores it as an ordinary pin from then on, same as one pinned through onboarding or a correspondent's server pinned later from compose's recovery card — and onboarding's server screen starts already on "Continue" instead of asking the visitor to fetch or paste anything.
## What it implements
- **Identity** (§2): one 32-byte master secret; create or restore. Every rotation index's signing seed and the accept key are derived from it with HKDF, so the X25519 agreement keys and every superseded identity are re-derivable rather than archived. The master lives in localStorage, mail stays sealed in IndexedDB — the browser profile is the trust boundary.
- **Addressing** (§3): short `user@host` and self-certifying `smol://` addresses, base32 fingerprints.
- **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching only ever reach a server onboarding already pinned; `REGISTER` carries a proof-of-possession signature bound to the server's own key, so a binding cannot be replayed elsewhere. An opt-in **require a pinned server** setting (settings → Advanced) refuses to learn a new correspondent's key from an unpinned server at all; compose shows the blocked host inline instead, with a fetch-and-pin-then-send recovery in place of losing the draft.
- **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch pages forward by cursor and verifies id and signature before acknowledging — anything unreadable stays on the server. Outgoing mail carries a signed `Reply-To` frontmatter field with the sender's full `smol://` address (an "anonymous" checkbox omits it), so a first-time recipient can name the sender and answer; on receipt such a claim binds only when its key matches the message's signer, and never over an address already pinned to a different key.
- **Accept tokens** (§5.8): mail from an accepted correspondent lands in the inbox's main tier; everything else lands in a separate "requests" tier. Accepting or blocking a contact pushes the changed token set to the server immediately; a learned token is attached automatically to outgoing mail once a correspondent's own token has been received.
- **Leave mail on server**: a toggle under settings → Server → fetching that has fetch page forward by cursor without acknowledging, so mail stays server-side until deleted from the reader — off by default, which acknowledges (and so deletes) fetched mail immediately, as before.
- **Rotation** (§7): rotate to the next index's derived key with a certificate both the old and new keys sign, binding the change to this username; contacts accept it from the chain; every superseded key stays derivable from the master, since mail sealed to it is readable with nothing else; key changes without a chain are surfaced, never applied silently.
- **Settings**: four tabs — Identity (address, key, fingerprint, the recovery secret), Server (connection, timeout, fetching), Backup (export/import, sealed to the identity), Advanced (the pinning setting above, rotation, signing out).
## Layout
```
bridge.py web server + WebSocket relay (aiohttp, one file, no keys)
web/js/crypto.js SHA-2, HKDF, ChaCha20-Poly1305, X25519, Ed25519 — dependency-free
web/js/noise.js Noise_NX_25519_ChaChaPoly_SHA256 initiator
web/js/proto.js addresses, seal/unseal, frontmatter, rotation, op framing
web/js/store.js localStorage + IndexedDB, sealed at rest
web/js/app.js the UI
web/js/i18n.js string lookup (t(), applyStaticLocale()) — see "Strings"
web/js/config.js deploy-time server preset, empty by default (see "Deploy")
web/locales/en-US.json generated string dictionary (see "Strings")
locales/en-US/main.ftl vendored Fluent source (see "Strings")
scripts/ build-time tooling, currently just the locale converter
test/ vectors and end-to-end tests (below)
```
The crypto is pure JavaScript rather than WebCrypto so the exact same modules run in Node for testing — WebCrypto has no ChaCha20-Poly1305 and no Ed25519→X25519 conversion, so half of it needed writing anyway.
## Strings
Every user-facing string lives in `locales/en-US/main.ftl`, a vendored copy of the same Fluent resource [carrier](../carrier) (the GTK4 client) uses — one shared source across both clients rather than two copies drifting apart. The file only ever uses Fluent's flat `id = value` shape (no selectors, terms, or attributes), so `scripts/build-locale.mjs` is a small, dependency-free converter rather than a real Fluent parser; it turns the `.ftl` into `web/locales/en-US.json`, the flat dictionary the browser actually fetches. `devbox run locale` regenerates it; the output is committed, not built at serve time.
`web/js/i18n.js` loads that dictionary once at boot. `t(id, args)` looks up a string and substitutes `{ $name }` placeholders; `applyStaticLocale()` does the same for `index.html`'s `data-l10n-id`/`-placeholder`/`-title`/`-aria-label` attributes. To change wording: edit `main.ftl` in carrier (the shared source, not this copy), copy the file over this repo's vendored copy, then `devbox run locale`.
## Verification
## Test
```
devbox run test
```
1. `test/gen_vectors.py` regenerates `test/vectors.json` from the reference stack (hashlib/hmac, PyNaCl, noiseprotocol): hashes, AEAD, key agreement, signatures, the §2 conversions, master-derived rotation seeds and accept tokens, a fixed-ephemeral envelope, and a deterministic Noise_NX transcript with internal states.
2. `test/vectors.test.mjs` checks the JS implementation byte for byte against those vectors, plus protocol cases: frontmatter (case-insensitive keys), addresses, and rotation chains bound to a username (accepted, broken, missing link, oversize, wrong username).
3. `test/e2e.py` runs a live `smolmaild.py` and drives the reference client (Alice) against this client's JS stack (Bob, headless via `test/client.mjs`): registration, unsolicited mail landing in the requests tier, accepting a correspondent and the accept-token round trip that follows, fetch with acknowledgment, and a rotation whose chain the reference client walks. Bob's reply travels through the bridge's WebSocket relay — the exact byte path the browser takes.
Byte-for-byte checks against the reference vectors, protocol cases, and a live end-to-end exchange with the reference client through the bridge — the exact byte path the browser takes. Only clicking through the UI is left to a manual pass.
What is not verified here: clicking through the actual browser UI. `devbox run serve` and a manual pass remain the last step; the logic under every button is what the tests exercise.
## Deploy
## Notes and limits
```
nix build .#default # -> ./result/bin/gsmol-bridge
```
- The master sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. The bridge serves a strict Content-Security-Policy (`default-src 'self'`, no inline script or style, no foreign origin) to raise that bar, but it is a mitigation and not a fix. No server push or notifications; click fetch.
- Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The UI surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence.
- The requests tier is a quota boundary the server enforces, not a filter: it does not refuse anyone's mail outright, which stays the reader's decision after decryption (§5.8).
- The bridge binds to 127.0.0.1 by default and has no TLS of its own. Serving it beyond localhost directly is possible but unadvised: the relay is plaintext-agnostic (the traffic is already end-to-end encrypted, but the bridge would relay anyone's bytes to allowlisted ports). The NixOS module's `nginx.enable` puts a TLS-terminating reverse proxy in front instead, which is the deployment path "Deploy" above documents.
- The relay is a proxy and is bounded as one, per the smolmail WebSocket annex: it relays only to allowlisted ports (`--allow-port`, checked per request) and refuses a WebSocket upgrade with a foreign `Origin`, so a page the user happens to visit cannot drive it. The annex's third bound is not implemented: `/tcp/{host}/{port}` dials any host the path names, including the loopback, private and link-local addresses of the machine the bridge runs on, which turns a public deployment into a door into the operator's own network. When implemented, the check belongs on the address the name resolved to, not on the name.
The bridge binds to 127.0.0.1 and has no TLS of its own — for anything beyond localhost, put `nginx` or `caddy` in front. A NixOS module is included (`nixosModules.default` → `services.gsmol-bridge`) with the same options.