gsmol/README.md

7.7 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>.

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