gsmol/README.md
randogoth 6f82a84b69 fix: use stdenv.hostPlatform.system, not the removed pkgs.system
nixpkgs renamed it, and the shim warns on every evaluation that imports this
module, so the noise lands on consumers rebuilding their systems rather than
here.
2026-10-06 09:59:12 +03:00

10 KiB

gsmol

An HTML5 browser client for Smol Mail, 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. First use: create an identity, back up the master, 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>.

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:

{
  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, replaceable or removable in settings like any other.

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 require a pinned server; REGISTER carries a proof-of-possession signature bound to the server's own key, so a binding cannot be replayed elsewhere.
  • 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 settings toggle 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.

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/config.js   deploy-time server preset, empty by default (see "Deploy")
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, 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.