5.9 KiB
gsmol
An HTML5 browser client for Smol Mail, 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 <port>.
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@hostand self-certifyingsmol://addresses, base32 fingerprints. - Trust (§4, §8): server keys pinned on first trust, mismatch aborts the handshake;
RESOLVEover 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-Tofrontmatter field with the sender's fullsmol://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
test/gen_vectors.pyregeneratestest/vectors.jsonfrom 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.test/vectors.test.mjschecks the JS implementation byte for byte against those vectors, plus protocol cases: frontmatter, addresses, rotation chains (accepted, broken, missing link, oversize).test/e2e.pyruns a livesmolmaild.pyand drives the reference client (Alice) against this client's JS stack (Bob, headless viatest/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.keyfor 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. Serving it beyond localhost 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).