gsmol/README.md

119 lines
12 KiB
Markdown

# 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.
## 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:
```
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.
## Run
```
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.
For a server on a non-default port, start the bridge with `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
```
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.
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.
## Notes and limits
- 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.