A browser based webmail client for smol mail, a minimalist end-to-end encrypted mail protocol
Find a file
2026-09-26 17:45:20 +03:00
test test: assert the reference client's reply address in e2e 2026-09-26 16:45:41 +03:00
web fix: contact rows, narrow sender labels and stale reader pane 2026-09-26 17:45:20 +03:00
.gitignore feat: browser client for smol mail 2026-09-26 15:59:31 +03:00
AGENTS.md feat: browser client for smol mail 2026-09-26 15:59:31 +03:00
bridge.py feat: browser client for smol mail 2026-09-26 15:59:31 +03:00
devbox.json feat: browser client for smol mail 2026-09-26 15:59:31 +03:00
devbox.lock feat: browser client for smol mail 2026-09-26 15:59:31 +03:00
README.md feat: carry a signed Reply-To address for first contact 2026-09-26 16:36:12 +03:00

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. 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. 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@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. 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).