# gsmol An HTML5 browser client for [Smol Mail](../smolmail), the minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, Noise_NX transport, sealed and signed messages. 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. The bridge exists only because the protocol is TCP, and it is the smaller dependency it looks like: `devbox run test` already needs Python and `uv` for the reference vectors and the end-to-end run. What would retire it altogether is a WebSocket transport binding in `smolmaild` itself — Noise messages already carry a `u16` length prefix, so a binary WebSocket stream is a drop-in byte stream — but that is an addition to SPEC.md §4 and to the server, not something this client can decide. ## Run ``` devbox run serve ``` Then open http://127.0.0.1:8096. First use: create an identity, back up the seed, pin your server's public key (from the operator, through a trusted channel), register an address, and fetch. For a server on a non-default port, start the bridge with `uv run bridge.py --allow-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` 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 the proxy regardless of `--allow-origin`. `openFirewall = true` is for exposing the bridge's port directly instead, which is not the recommended path (see "Notes and limits"). ## What it implements - **Identity** (§2): one 32-byte seed; create or restore; the X25519 agreement keys are derived from the Ed25519 keypair. The seed 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 require a pinned server. - **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch 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. - **Rotation** (§7): rotate with a signed certificate; contacts accept the change from the chain; superseded seeds are kept, since mail sealed to them is readable with nothing else; key changes without a chain are surfaced, never applied silently. ## 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 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. ## 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, 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, addresses, rotation chains (accepted, broken, missing link, oversize). 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, both send directions, 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 seed 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 in v1 (SPEC.md §13); 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 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.