commit 9798d38040642f2be836ef2ac221f258fbc15a96 Author: randogoth Date: Sat Sep 26 15:59:28 2026 +0300 feat: browser client for smol mail diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..055b698 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.devbox/ +.venv/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eab6e03 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# General Working Rules + +## Coding + +- **Simple and idiomatic.** Write readable, conventional code. Prefer composition, immutability, explicit dependencies, and a single source of truth. +- **Small and focused.** Keep functions and modules single-purpose. Separate concerns and avoid unnecessary nesting, abstraction, and global mutable state. +- **Reuse existing solutions.** Follow project conventions and prefer existing tools and dependencies. Introduce new ones only when justified. +- **Minimal changes.** Implement only what is requested. Prefer deletion over rewriting, and avoid unrelated refactoring, formatting, or speculative features. + +## Comments + +- **Self-contained.** Explain essential context inline without requiring external documents or conversations. References to nearby source code are acceptable. +- **Concise.** Usually one sentence. Put longer explanations in brief module headers or documentation. +- **Why, not what.** Comment only on non-obvious decisions, constraints, invariants, and surprising behavior. Do not restate obvious code. +- **No unnecessary history.** Include implementation history only when essential to understanding current behavior. Remove obsolete comments. + +## Documentation + +- Keep documentation concise, accurate, and synchronized with the implementation. +- Distinguish implemented behavior from proposals and historical plans. Remove outdated information and shorten resolved findings. +- Update specifications alongside changes to interfaces, formats, and contracts. Record important decisions where they are enforced. +- **Natural reflow.** Write paragraphs as continuous lines without hard wrapping. Use blank lines between paragraphs and explicit line breaks only where structurally necessary. + +## Working Style + +- **Inspect first.** Examine existing code, configuration, architecture, and conventions rather than assuming how things work. +- **Respect scope.** Follow the latest explicit instructions. Do not make unrelated changes or resume superseded tasks. +- **Work silently.** No progress narration or request recaps. Interrupt only for genuine blockers or discoveries that materially change the approach. +- **Resolve uncertainty.** Investigate missing information before asking. Never invent requirements or assume unverified behavior. +- **Respect user decisions.** Identify significant design problems and propose concrete alternatives with trade-offs, but follow the user's choice. +- **Respect authorization.** Observe approval requirements for changes, tests, commits, deployments, and destructive operations. +- **Commit messages.** Use concise, single-line, tag-prefixed messages (e.g., fix:, feat:, refactor:, docs:). Describe what changed using the imperative mood. +- **No credit notes.** Omit attribution, co-author trailers, AI-generated notices, and other credit statements from commits and generated documentation. + +## Verification + +- Test affected behavior using existing project tools. Add regression tests where appropriate. +- Preserve safeguards for security, external input, data integrity, and concurrency. +- Measure consequential assumptions before making architectural decisions. +- Perform repository-wide cleanup or extensive refactoring only when explicitly requested. +- Format and validate affected code without altering unrelated files. +- Report what passed, failed, or could not be verified. Never claim untested behavior works. + +## Completion + +Summarize changes, verification results, important decisions, and unresolved issues in a few sentences. Avoid lengthy reports unless requested. + +## Project Preferences + +- use `git` colocated `jj` for version management +- use `devbox` to manage dependencies and scripting \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..de8925b --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# 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. 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 `. + +## 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. +- **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). diff --git a/bridge.py b/bridge.py new file mode 100755 index 0000000..04cd1e4 --- /dev/null +++ b/bridge.py @@ -0,0 +1,98 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["aiohttp>=3.9"] +# /// +"""gsmol bridge: serve the web client and relay WebSocket bytes to smolmaild TCP. + +Browsers cannot open raw TCP sockets, so the Noise_NX session runs in the +browser and this bridge carries its bytes. The bridge holds no keys and never +parses a byte of the traffic; it is a pipe with a port allowlist, so it is not +an open proxy. + + uv run bridge.py # serve web/ and relay port 1961 + uv run bridge.py --allow-port 11961 # relay to an extra port +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +from pathlib import Path + +import aiohttp +from aiohttp import web + +log = logging.getLogger("gsmol") + + +async def relay(request: web.Request) -> web.WebSocketResponse: + """A WebSocket whose binary frames are piped to host:port untouched.""" + port = int(request.match_info["port"]) + if port not in request.app["relay_ports"]: + raise web.HTTPForbidden(text=f"port {port} is not allowed") + host = request.match_info["host"] + + ws = web.WebSocketResponse(autoping=True) + await ws.prepare(request) + try: + reader, writer = await asyncio.open_connection(host, port) + except OSError as exc: + await ws.close(code=1011, message=str(exc).encode()) + return ws + log.info("relaying to %s:%d", host, port) + + async def pump_up() -> None: + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.BINARY: + writer.write(msg.data) + await writer.drain() + elif msg.type == aiohttp.WSMsgType.ERROR: + break + finally: + writer.close() + + async def pump_down() -> None: + try: + while chunk := await reader.read(65536): + await ws.send_bytes(chunk) + except (ConnectionError, asyncio.IncompleteReadError): + pass + finally: + await ws.close() + + await asyncio.gather(pump_up(), pump_down()) + log.info("relay to %s:%d closed", host, port) + return ws + + +async def index(request: web.Request) -> web.FileResponse: + return web.FileResponse(Path(request.app["web_dir"]) / "index.html") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8096) + parser.add_argument("--dir", default=str(Path(__file__).parent / "web")) + parser.add_argument("--allow-port", type=int, action="append", default=[1961], + metavar="PORT", dest="allow_ports", + help="relayed TCP port; repeatable") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + app = web.Application() + app["web_dir"], app["relay_ports"] = args.dir, set(args.allow_ports) + app.router.add_get("/", index) + app.router.add_get("/tcp/{host}/{port}", relay) + app.router.add_static("/", args.dir, show_index=False) + log.info("serving %s on http://%s:%d — allowed TCP ports: %s", + args.dir, args.host, args.port, ", ".join(map(str, sorted(app["relay_ports"])))) + web.run_app(app, host=args.host, port=args.port, print=None) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..f8c9c1f --- /dev/null +++ b/devbox.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json", + "packages": [ + "nodejs@latest", + "python3@latest", + "uv@latest" + ], + "shell": { + "init_hook": [ + "echo 'Welcome to devbox!' > /dev/null" + ], + "scripts": { + "serve": [ + "uv run bridge.py" + ], + "test": [ + "uv run test/gen_vectors.py", + "node test/vectors.test.mjs", + "uv run test/e2e.py" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..9a2964e --- /dev/null +++ b/devbox.lock @@ -0,0 +1,143 @@ +{ + "lockfile_version": "1", + "packages": { + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410?lastModified=1787814960&narHash=sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ%3D" + }, + "nodejs@latest": { + "last_modified": "2026-08-27T23:47:58Z", + "plugin_version": "0.0.2", + "resolved": "github:NixOS/nixpkgs/7d1a7c21a4c00ea653fc7ed5c083d261340f185f#nodejs_26", + "source": "devbox-search", + "version": "26.8.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/szf4xdpd4znb3hzprlyivq6nd507hyas-nodejs-26.8.1", + "default": true + } + ], + "store_path": "/nix/store/szf4xdpd4znb3hzprlyivq6nd507hyas-nodejs-26.8.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/s3azam32y3bmwd6b2zynqqg9yv99lnam-nodejs-26.8.1", + "default": true + } + ], + "store_path": "/nix/store/s3azam32y3bmwd6b2zynqqg9yv99lnam-nodejs-26.8.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ymlp5q9l64224b9jqpqwp298bgbjv7p9-nodejs-26.8.1", + "default": true + } + ], + "store_path": "/nix/store/ymlp5q9l64224b9jqpqwp298bgbjv7p9-nodejs-26.8.1" + } + } + }, + "python3@latest": { + "last_modified": "2025-01-06T03:40:18Z", + "plugin_version": "0.0.4", + "resolved": "github:NixOS/nixpkgs/3df3c47c19dc90fec35359e89ffb52b34d2b0e94#python3", + "source": "devbox-search", + "version": "3.12.8", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/8zc3wcplydp8gsxms24scpzdca438dk5-python3-3.12.8", + "default": true + } + ], + "store_path": "/nix/store/8zc3wcplydp8gsxms24scpzdca438dk5-python3-3.12.8" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/66pn6ysmvx675061xaq2vz93s9vdc5p4-python3-3.12.8", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/brcg3a34fi45ffky63g5pqd22sksvq13-python3-3.12.8-debug" + } + ], + "store_path": "/nix/store/66pn6ysmvx675061xaq2vz93s9vdc5p4-python3-3.12.8" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/fpmkmdzgd1q7kqadc7czcjdhjj7bsc0i-python3-3.12.8", + "default": true + } + ], + "store_path": "/nix/store/fpmkmdzgd1q7kqadc7czcjdhjj7bsc0i-python3-3.12.8" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/c9m6yd8fg1flz2j5r4bif1ib5j20a0cy-python3-3.12.8", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/cicfrcjr8pky8qd0gxw0x84ynyviy6b5-python3-3.12.8-debug" + } + ], + "store_path": "/nix/store/c9m6yd8fg1flz2j5r4bif1ib5j20a0cy-python3-3.12.8" + } + } + }, + "uv@latest": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#uv", + "source": "devbox-search", + "version": "0.12.5", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/71zq15rr7i4avy58y89hfgf2lhp3b7la-uv-0.12.5", + "default": true + } + ], + "store_path": "/nix/store/71zq15rr7i4avy58y89hfgf2lhp3b7la-uv-0.12.5" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/s2pqw31dkyrcym72fcdn0h9myl0yy9ix-uv-0.12.5", + "default": true + } + ], + "store_path": "/nix/store/s2pqw31dkyrcym72fcdn0h9myl0yy9ix-uv-0.12.5" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/v6av5m4035ds4558ax9sh0ywwnkjnd7b-uv-0.12.5", + "default": true + } + ], + "store_path": "/nix/store/v6av5m4035ds4558ax9sh0ywwnkjnd7b-uv-0.12.5" + } + } + } + } +} diff --git a/test/client.mjs b/test/client.mjs new file mode 100644 index 0000000..890b9b0 --- /dev/null +++ b/test/client.mjs @@ -0,0 +1,335 @@ +// Headless driver for the end-to-end test: the same web/js modules the browser +// uses, over a TCP socket (or, with GSMOL_TRANSPORT=ws, through the bridge's +// WebSocket relay — the exact byte path the browser takes). +// +// node test/client.mjs --state bob.json [args] +// +// Commands mirror the reference smolmail.py: keygen, trust, register, resolve, +// import, send, fetch, list, read, rotate. State lives in a JSON file; mail is +// stored sealed, like the browser store does. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import { hex, timingSafeEqual, unhex, utf8Bytes } from "../web/js/crypto.js"; +import * as proto from "../web/js/proto.js"; + +const { b32decode, b32encode, fingerprint } = proto; + +const args = process.argv.slice(2); +const stateIndex = args.indexOf("--state"); +const statePath = stateIndex >= 0 ? args[stateIndex + 1] : "client-state.json"; +const command = args.find((a, i) => i !== stateIndex && i !== stateIndex + 1 && !a.startsWith("--")); +const rest = args.slice(args.indexOf(command) + 1); + +const load = () => existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) : {}; +const save = (state) => writeFileSync(statePath, JSON.stringify(state)); + +function parseOption(name) { + const i = rest.indexOf(name); + return i >= 0 ? rest[i + 1] : null; +} + +// --- transports ------------------------------------------------------------- + +function connectTcp(host, port) { + return new Promise((resolve, reject) => { + const socket = net.connect({ host, port }); + socket.on("error", reject); + socket.on("connect", () => { + const stream = { + send: (bytes) => socket.write(bytes), + close: () => socket.end(), + onData: null, + onClose: null, + }; + socket.on("data", (chunk) => stream.onData?.(new Uint8Array(chunk))); + socket.on("close", () => stream.onClose?.()); + resolve(stream); + }); + }); +} + +function connectWs(host, port) { + const bridge = process.env.GSMOL_BRIDGE || "ws://127.0.0.1:8096"; + return new Promise((resolve, reject) => { + const socket = new WebSocket(`${bridge}/tcp/${host}/${port}`); + socket.binaryType = "arraybuffer"; + const stream = { + send: (bytes) => socket.send(bytes), + close: () => socket.close(), + onData: null, + onClose: null, + }; + socket.onerror = () => reject(new Error(`cannot reach the bridge at ${bridge}`)); + socket.onopen = () => resolve(stream); + socket.onmessage = (event) => stream.onData?.(new Uint8Array(event.data)); + socket.onclose = () => stream.onClose?.(); + }); +} + +const connectStream = process.env.GSMOL_TRANSPORT === "ws" ? connectWs : connectTcp; + +// --- session ------------------------------------------------------------------ + +async function open(host, port, { requirePin }) { + const state = load(); + const pinned = state.servers?.[host] ? b32decode(state.servers[host]) : null; + if (requirePin && !pinned) + throw new proto.SmolError(`no pinned key for ${host}`); + const stream = await connectStream(host, port); + const opened = await proto.openSession(stream, host, pinned); + if (!pinned) + console.error(`warning: ${host} is not pinned; its key is ${b32encode(opened.serverStatic)}`); + return opened; +} + +function identities(state) { + const current = proto.identityFromSeed(unhex(state.seed)); + const retired = (state.retired || []).map(s => proto.identityFromSeed(unhex(s))); + return [current, ...retired]; +} + +// --- commands ------------------------------------------------------------------- + +const commands = { + keygen() { + const state = load(); + state.seed = hex(cryptoRandom(32)); + save(state); + const me = proto.identityFromSeed(unhex(state.seed)); + console.log(`public key: ${b32encode(me.publicKey)}`); + console.log(`fingerprint: ${fingerprint(me.publicKey)}`); + }, + + trust() { + const host = rest[0], key = b32decode(rest[1]); + if (key.length !== 32) throw new proto.SmolError(`server key is ${key.length} bytes, expected 32`); + const state = load(); + state.servers = { ...(state.servers || {}), [host]: b32encode(key) }; + save(state); + console.log(`pinned ${host} ${b32encode(key)}`); + }, + + async register() { + const state = load(); + const me = proto.identityFromSeed(unhex(state.seed)); + const addr = proto.parseAddress(rest[0]); + const opened = await open(addr.host, addr.port, { requirePin: true }); + try { + await proto.registerOp(opened.session, { + username: addr.user, identity: me, token: parseOption("--token") || "", + }); + } finally { + opened.session.stream.close(); + } + state.account = { user: addr.user, host: addr.host, port: addr.port }; + save(state); + console.log(`registered ${addr.short}`); + console.log(`share: ${addr.uri(me.publicKey)}`); + }, + + async resolve() { + const state = load(); + const addr = proto.parseAddress(rest[0]); + const opened = await open(addr.host, addr.port, { requirePin: false }); + let current, chain; + try { + ({ identity: current, chain } = await proto.resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + const known = state.contacts?.[addr.short]; + if (!known) { + state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(current), verified: false } }; + save(state); + console.log(`${addr.short} ${b32encode(current)}`); + console.log(` pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`); + return; + } + if (timingSafeEqual(b32decode(known.key), current)) return console.log(`${addr.short}: unchanged`); + if (proto.walkChain(b32decode(known.key), current, chain)) { + state.contacts[addr.short] = { key: b32encode(current), verified: known.verified }; + save(state); + console.log(`warning: ${addr.short} rotated its key; a signed chain confirms it`); + return; + } + throw new proto.SmolError(`${addr.short} presents a different key with no valid rotation chain`); + }, + + import() { + const state = load(); + const addr = proto.parseAddress(rest[0]); + if (!addr.identity) throw new proto.SmolError("import needs a smol:// address carrying a key"); + state.contacts = { + ...(state.contacts || {}), + [addr.short]: { key: b32encode(addr.identity), verified: true }, + }; + save(state); + console.log(`imported ${addr.short} ${b32encode(addr.identity)} (verified)`); + }, + + contacts() { + const state = load(); + for (const [address, c] of Object.entries(state.contacts || {})) + console.log(`${address} ${c.key} ${c.verified ? "verified" : "tofu"}`); + }, + + async send() { + const state = load(); + const me = proto.identityFromSeed(unhex(state.seed)); + const addr = proto.parseAddress(rest[0]); + let recipient; + if (addr.identity) { + recipient = addr.identity; + state.contacts = { + ...(state.contacts || {}), + [addr.short]: { key: b32encode(recipient), verified: true }, + }; + } else if (state.contacts?.[addr.short]) { + recipient = b32decode(state.contacts[addr.short].key); + } else { + const opened = await open(addr.host, addr.port, { requirePin: false }); + try { + ({ identity: recipient } = await proto.resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(recipient), verified: false } }; + console.error(`warning: ${addr.short} pinned (trust on first use)`); + } + save(state); + + const fields = [["Subject", parseOption("--subject")], ["In-Reply-To", parseOption("--reply-to")]] + .filter(([, v]) => v); + const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n")); + const envelope = proto.seal(me, recipient, body); + const opened = await open(addr.host, addr.port, { requirePin: false }); + try { + await proto.sendOp(opened.session, envelope); + } finally { + opened.session.stream.close(); + } + state.sent = [...(state.sent || []), { + id: hex(proto.messageId(envelope)), recipient: addr.short, + envelope: hex(proto.seal(me, me.publicKey, body)), sentAt: proto.nowSeconds(), + }]; + save(state); + console.log(`sent ${hex(proto.messageId(envelope)).slice(0, 8)} to ${addr.short}`); + }, + + async fetch() { + const state = load(); + const me = proto.identityFromSeed(unhex(state.seed)); + const account = proto.parseAddress(`${state.account.user}@${state.account.host}` + + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)); + const ids = identities(state); + const opened = await open(account.host, account.port, { requirePin: true }); + let total = 0, stored = 0, rejected = 0; + try { + await proto.authenticate(opened.session, opened.handshakeHash, account.user, me); + for (;;) { + const records = await proto.fetchOp(opened.session); + if (!records.length) break; + const acked = []; + for (const record of records) { + total++; + try { + if (!timingSafeEqual(proto.messageId(record.envelope), record.id)) + throw new proto.SmolError("id does not match the envelope"); + proto.unseal(ids, record.envelope); + } catch (err) { + rejected++; + console.error(`warning: ${hex(record.id)}: ${err.message}; left on server`); + continue; + } + if (!state.inbox) state.inbox = []; + if (!state.inbox.some(m => m.id === hex(record.id))) { + state.inbox.push({ + id: hex(record.id), envelope: hex(record.envelope), receivedAt: record.receivedAt, + }); + stored++; + } + acked.push(record.id); + } + if (!acked.length) break; + await proto.deleteOp(opened.session, acked); + } + } finally { + opened.session.stream.close(); + } + save(state); + console.log(`${total} message(s): ${stored} new, ${rejected} rejected`); + }, + + list() { + const state = load(); + for (const m of state.inbox || []) describe(state, m, "inbox"); + for (const m of state.sent || []) describe(state, m, "sent"); + }, + + read() { + const state = load(); + const wanted = rest[0].toLowerCase(); + const match = [...(state.inbox || []).map(m => [m, "inbox"]), ...(state.sent || []).map(m => [m, "sent"])] + .find(([m]) => m.id.startsWith(wanted)); + if (!match) throw new proto.SmolError(`no message matching ${wanted}`); + describe(state, match[0], match[1], true); + }, + + async rotate() { + const state = load(); + const old = proto.identityFromSeed(unhex(state.seed)); + const account = proto.parseAddress(`${state.account.user}@${state.account.host}` + + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)); + const fresh = proto.newIdentity(); + const cert = proto.makeCert(old, fresh.seed); + const opened = await open(account.host, account.port, { requirePin: true }); + try { + await proto.registerOp(opened.session, { username: account.user, identity: fresh, cert }); + } finally { + opened.session.stream.close(); + } + state.retired = [...(state.retired || []), state.seed]; + state.seed = hex(fresh.seed); + save(state); + console.log(`rotated ${account.short}`); + console.log(`new key: ${b32encode(fresh.publicKey)}`); + console.log(`fingerprint: ${fingerprint(fresh.publicKey)}`); + }, +}; + +function describe(state, row, box, verbose = false) { + const id = box === "inbox" ? row.id : row.id; + try { + const opened = proto.unseal(identities(state), unhex(row.envelope)); + const { fields, body } = proto.parseFrontmatter(new TextDecoder().decode(opened.body)); + const who = box === "sent" ? row.recipient + : Object.entries(state.contacts || {}).find(([, c]) => c.key === b32encode(opened.sender))?.[0] + ?? `<${b32encode(opened.sender).slice(0, 20)}…>`; + const stamp = new Date((opened.time) * 1000).toISOString().slice(0, 16).replace("T", " "); + if (!verbose) return console.log( + `${id.slice(0, 8)} ${stamp} ${who.padEnd(28).slice(0, 28)} ${fields.Subject || "(no subject)"}`); + console.log(`id: ${id}`); + console.log(`${box === "sent" ? "to" : "from"}: ${who}`); + console.log(`key: ${b32encode(opened.sender)}`); + console.log(`date: ${new Date(opened.time * 1000).toISOString()}`); + for (const [k, v] of Object.entries(fields)) console.log(`${k.toLowerCase()}: ${v}`); + console.log("signature verified\n"); + console.log(body); + } catch (err) { + if (verbose) throw err; + console.log(`${id.slice(0, 8)} `); + } +} + +function cryptoRandom(n) { + const out = new Uint8Array(n); + crypto.getRandomValues(out); + return out; +} + +if (!commands[command]) { + console.error(`unknown command: ${command}`); + process.exit(2); +} +await commands[command](); diff --git a/test/e2e.py b/test/e2e.py new file mode 100644 index 0000000..ca2e7ff --- /dev/null +++ b/test/e2e.py @@ -0,0 +1,131 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""End-to-end test: the browser JS stack (web/js, driven headless by +test/client.mjs) against the reference server and client in ../smolmail. + +Two transports are covered: the driver connects directly over TCP, and once +more through bridge.py's WebSocket relay — the exact byte path the browser +takes. Alice is always the reference smolmail.py client, so every exchange +crosses implementations. +""" + +from __future__ import annotations + +import os +import re +import signal +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +SMOL = Path(__file__).resolve().parent.parent.parent / "smolmail" +NODE = "node" + +failures: list[str] = [] + + +def check(name: str, condition: bool, detail: str = "") -> None: + print(("ok " if condition else "FAIL ") + name + (f" — {detail}" if detail and not condition else "")) + if not condition: + failures.append(name) + + +def wait_port(port: int, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.2) + raise TimeoutError(f"port {port} never opened") + + +def run(command: list[str], expect: int = 0, env: dict | None = None) -> str: + result = subprocess.run(command, capture_output=True, text=True, env=env) + if result.returncode != expect: + raise RuntimeError(f"command failed ({result.returncode}): {command}\n{result.stdout}{result.stderr}") + return result.stdout + result.stderr + + +def main() -> int: + work = Path(tempfile.mkdtemp(prefix="gsmol-e2e-")) + server_port, bridge_port = 11961, 18096 + smolmaild = ["uv", "run", str(SMOL / "smolmaild.py")] + smolmail = ["uv", "run", str(SMOL / "smolmail.py")] + alice = smolmail + ["--key", str(work / "alice.key"), "--db", str(work / "alice.db")] + bob = [NODE, "test/client.mjs", "--state", str(work / "bob.json")] + server_key = re.search(r"public key: (\S+)", run( + smolmaild + ["keygen", "--key", str(work / "server.key")])).group(1) + + server = subprocess.Popen( + smolmaild + ["serve", "--key", str(work / "server.key"), "--db", str(work / "mail.db"), + "--host", "127.0.0.1", "--port", str(server_port)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + bridge = subprocess.Popen( + ["uv", "run", "bridge.py", "--port", str(bridge_port), "--allow-port", str(server_port)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + wait_port(server_port) + wait_port(bridge_port) + host = f"127.0.0.1:{server_port}" + + # --- both sides register on the reference server + run(alice + ["keygen"]) + run(alice + ["trust", "127.0.0.1", server_key]) + run(alice + ["register", f"alice@{host}"]) + run(bob + ["keygen"]) + run(bob + ["trust", "127.0.0.1", server_key]) + run(bob + ["register", f"bob@{host}"]) + check("bob registers on the reference server", True) + + # --- reference client sends to the JS client + run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"]) + print(run(bob + ["fetch"]).strip()) + bob_list = run(bob + ["list"]) + check("bob lists alice's message", "greetings" in bob_list, bob_list) + bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_list).group(1)]) + alice_pub = re.search(r"public key: (\S+)", run(alice + ["whoami"])).group(1) + check("bob reads the message", "hello bob" in bob_read, bob_read) + check("bob sees alice's key", alice_pub in bob_read) + check("signature verifies", "signature verified" in bob_read) + + # --- JS client replies, through the bridge's WebSocket relay + ws_env = dict(os.environ, GSMOL_TRANSPORT="ws", GSMOL_BRIDGE=f"ws://127.0.0.1:{bridge_port}") + reply_id = re.search(r"sent ([0-9a-f]+)", run(bob + ["send", f"alice@{host}", + "--subject", "re: greetings", "--body", "hello alice"], env=ws_env)).group(1) + check("bob sends through the bridge", bool(reply_id)) + run(alice + ["fetch"]) + alice_list = run(alice + ["list"]) + check("alice lists bob's reply", "re: greetings" in alice_list, alice_list) + alice_read = run(alice + ["read", reply_id[:8]]) + check("alice reads bob's reply", "hello alice" in alice_read, alice_read) + check("reference client verifies the JS signature", "signature verified" in alice_read) + + # --- rotation: the JS client rotates, the reference client walks the chain + bob_rotated = run(bob + ["rotate"]) + new_key = re.search(r"new key: (\S+)", bob_rotated).group(1) + resolved = run(alice + ["resolve", f"bob@{host}"]) + check("chain confirms the rotation", "a signed chain confirms it" in resolved, resolved) + run(alice + ["send", f"bob@{host}", "--subject", "post-rotation", "--body", "still here"]) + print(run(bob + ["fetch"]).strip()) + check("bob reads mail addressed to the new key", "post-rotation" in run(bob + ["list"])) + + print(f"\n{len(failures)} failure(s)" + (f": {failures}" if failures else "")) + return 1 if failures else 0 + finally: + for process in (bridge, server): + process.send_signal(signal.SIGTERM) + process.wait(timeout=10) + print(f"workdir left at {work}" if failures else f"cleaning {work}") + + +if __name__ == "__main__": + os.chdir(Path(__file__).resolve().parent.parent) + sys.exit(main()) diff --git a/test/gen_vectors.py b/test/gen_vectors.py new file mode 100644 index 0000000..f9867b8 --- /dev/null +++ b/test/gen_vectors.py @@ -0,0 +1,185 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["noiseprotocol>=0.3.1", "pynacl>=1.5"] +# /// +"""Generate test/vectors.json for the JS implementation. + +The JS crypto in web/js must agree byte for byte with the reference stack +(hashlib/hmac, PyNaCl, noiseprotocol), so the ground truth is generated here +with fixed inputs and consumed by test/vectors.test.mjs. Everything is +derived from SHA-256 of fixed labels, so the file is reproducible. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import struct +from pathlib import Path + +import nacl.bindings as sodium +from noise.connection import Keypair, NoiseConnection + +LABEL_SEAL, LABEL_MSG, LABEL_ID = b"smolmail/1 seal", b"smolmail/1 msg", b"smolmail/1 id" +TIME = 1730000000 # fixed so the envelope vector is reproducible + + +def seed(label: str) -> bytes: + return hashlib.sha256(label.encode()).digest() + + +def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes: + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + out, block, counter = b"", b"", 1 + while len(out) < length: + block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest() + out, counter = out + block, counter + 1 + return out[:length] + + +def main() -> int: + out: dict = {} + + # --- hashes over empty, one-block and multi-block inputs + out["sha256"] = [ + {"in": m.hex(), "out": hashlib.sha256(m).hexdigest()} + for m in (b"", b"abc", bytes(range(64)), b"smolmail" * 40) + ] + out["sha512"] = [ + {"in": m.hex(), "out": hashlib.sha512(m).hexdigest()} + for m in (b"", b"abc", bytes(range(64))) + ] + + # --- HMAC/HKDF, including RFC 5869 test case 1 + out["hkdf"] = [] + for ikm, salt, info, length, name in ( + (bytes.fromhex("0b" * 22), bytes.fromhex("000102030405060708090a0b0c"), + bytes.fromhex("f0f1f2f3f4f5f6f7f8f9"), 42, "rfc5869-1"), + (b"agreement shared", b"epk" + b"recipient", b"smolmail/1 seal", 32, "seal-shaped"), + ): + out["hkdf"].append({"name": name, "ikm": ikm.hex(), "salt": salt.hex(), + "info": info.hex(), "len": length, + "out": hkdf_sha256(ikm, salt, info, length).hex()}) + + # --- ChaCha20-Poly1305 (RFC 8439 IETF AEAD) over varied shapes + key, nonce = seed("aead key"), seed("aead nonce")[:12] + aad = bytes.fromhex("50515253c0c1c2c3c4c5c6c7") + out["aead"] = [] + for plaintext, use_aad, name in ( + (b"", False, "empty"), (b"hello", True, "short"), + (b"Ladies and Gentlemen of the class of '99: if I could offer you " + b"only one tip for the future, sunscreen would be it.", True, "multiline"), + ): + sealed = sodium.crypto_aead_chacha20poly1305_ietf_encrypt( + plaintext, aad if use_aad else b"", nonce, key) + out["aead"].append({"name": name, "key": key.hex(), "nonce": nonce.hex(), + "aad": (aad if use_aad else b"").hex(), "plaintext": plaintext.hex(), + "sealed": sealed.hex()}) + + # --- X25519: base multiplication and agreement, including the §2 checks + out["x25519"] = [] + for name, priv in (("alice", seed("x25519 alice")), ("bob", seed("x25519 bob"))): + pub = sodium.crypto_scalarmult_base(priv) + out["x25519"].append({"name": name, "priv": priv.hex(), "pub": pub.hex()}) + out["x25519"].append({"name": "agree", + "shared": sodium.crypto_scalarmult( + seed("x25519 alice"), + sodium.crypto_scalarmult_base(seed("x25519 bob"))).hex()}) + for bad in (b"\x00" * 32, bytes.fromhex("0100" + "00" * 30)): + try: + sodium.crypto_scalarmult(seed("x25519 alice"), bad) + raised = False + except Exception: + raised = True + out["x25519"].append({"name": f"low-order-{bad[0]}", "peer": bad.hex(), + "peer_rejected": True, "raised": raised}) + assert raised, "PyNaCl must reject this low-order point for the vector to mean anything" + + # --- Ed25519: keypair, signature, verification, and a tampered case + out["ed25519"] = [] + for name, s in (("vector-seed-a", seed("ed25519 a")), ("vector-seed-b", seed("ed25519 b"))): + pub, _ = sodium.crypto_sign_seed_keypair(s) + for message in (b"", b"smolmail/1 auth" + bytes(32), bytes(range(64))): + sig = sodium.crypto_sign(message, s + pub)[:64] + out["ed25519"].append({"name": name, "seed": s.hex(), "pub": pub.hex(), + "message": message.hex(), "signature": sig.hex(), + "valid": True}) + bad = bytes([sig[0] ^ 1]) + sig[1:] + out["ed25519"].append({"name": name, "seed": s.hex(), "pub": pub.hex(), + "message": message.hex(), "signature": bad.hex(), + "valid": False}) + + # --- §2 conversions between the identity key and X25519 + out["ed_to_x25519"] = [] + for name, s in (("vector-seed-a", seed("ed25519 a")), ("vector-seed-b", seed("ed25519 b"))): + pub, sk64 = sodium.crypto_sign_seed_keypair(s) + out["ed_to_x25519"].append({ + "name": name, "seed": s.hex(), + "x_priv": sodium.crypto_sign_ed25519_sk_to_curve25519(sk64).hex(), + "x_pub": sodium.crypto_sign_ed25519_pk_to_curve25519(pub).hex()}) + + # --- §5 envelope sealed with a fixed ephemeral, mirroring smolmail.py seal() + sender_seed, recipient_seed, esk = seed("envelope sender"), seed("envelope recipient"), seed("envelope esk") + sender_pub, _ = sodium.crypto_sign_seed_keypair(sender_seed) + recipient_pub, _ = sodium.crypto_sign_seed_keypair(recipient_seed) + epk = sodium.crypto_scalarmult_base(esk) + shared = sodium.crypto_scalarmult(esk, sodium.crypto_sign_ed25519_pk_to_curve25519(recipient_pub)) + key = hkdf_sha256(shared, epk + recipient_pub, LABEL_SEAL, 32) + body = b"---\nSubject: vector\n---\nhello bob\n" + sig = sodium.crypto_sign(LABEL_MSG + recipient_pub + epk + + bytes([1]) + sender_pub + struct.pack(">qI", TIME, len(body)) + body, + sender_seed + sender_pub)[:64] + plaintext = (bytes([1]) + sender_pub + struct.pack(">qI", TIME, len(body)) + + body + sig) + aad = b"SMOL" + bytes([1]) + recipient_pub + epk + envelope = aad + sodium.crypto_aead_chacha20poly1305_ietf_encrypt(plaintext, aad, bytes(12), key) + out["envelope"] = { + "sender_seed": sender_seed.hex(), "recipient_seed": recipient_seed.hex(), + "esk": esk.hex(), "body": body.hex(), "time": TIME, + "envelope": envelope.hex(), + "id": hashlib.sha256(LABEL_ID + envelope).digest()[:16].hex(), + "unpadded_plaintext_len": len(plaintext)} + + # --- Noise NX transcript with every key fixed + i_e, r_e, s_priv = seed("nx initiator eph"), seed("nx responder eph"), seed("nx server static") + s_pub = sodium.crypto_scalarmult_base(s_priv) + n1 = NoiseConnection.from_name(b"Noise_NX_25519_ChaChaPoly_SHA256") + n1.set_as_initiator() + n1.set_prologue(b"smolmail/1") + n1.set_keypair_from_private_bytes(Keypair.EPHEMERAL, i_e) + n1.start_handshake() + m1 = n1.write_message() + + n2 = NoiseConnection.from_name(b"Noise_NX_25519_ChaChaPoly_SHA256") + n2.set_as_responder() + n2.set_prologue(b"smolmail/1") + n2.set_keypair_from_private_bytes(Keypair.STATIC, s_priv) + n2.set_keypair_from_private_bytes(Keypair.EPHEMERAL, r_e) + n2.start_handshake() + n2.read_message(m1) + m2 = n2.write_message() + n1.read_message(m2) + assert n1.handshake_finished and n2.handshake_finished + + out["noise"] = { + "initiator_eph_priv": i_e.hex(), "responder_eph_priv": r_e.hex(), + "server_static_priv": s_priv.hex(), "server_static_pub": s_pub.hex(), + "message1": m1.hex(), "message2": m2.hex(), + "handshake_hash": n1.get_handshake_hash().hex(), + "assert_hash_equal": n1.get_handshake_hash() == n2.get_handshake_hash(), + "initiator_frames": [{"plaintext": p.hex(), "sealed": n1.encrypt(p).hex()} + for p in (b"ping", b"second frame to test the counter")], + "responder_frames": [{"plaintext": p.hex(), "sealed": n2.encrypt(p).hex()} + for p in (b"pong", b"x" * 300)], + } + + path = Path(__file__).parent / "vectors.json" + path.write_text(json.dumps(out, indent=2) + "\n") + print(f"wrote {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/vectors.json b/test/vectors.json new file mode 100644 index 0000000..61fb897 --- /dev/null +++ b/test/vectors.json @@ -0,0 +1,226 @@ +{ + "sha256": [ + { + "in": "", + "out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "in": "616263", + "out": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + }, + { + "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "out": "fdeab9acf3710362bd2658cdc9a29e8f9c757fcf9811603a8c447cd1d9151108" + }, + { + "in": "736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c", + "out": "58a0adce483c517ae1b37025dbe3b534880972be4d9d10b78959a13fb8b13462" + } + ], + "sha512": [ + { + "in": "", + "out": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" + }, + { + "in": "616263", + "out": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" + }, + { + "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "out": "ee4320ebaf3fdb4f2c832b137200c08e235e0fa7bbd0eb1740c7063ba8a0d151da77e003398e1714a955d475b05e3e950b639503b452ec185de4229bc4873949" + } + ], + "hkdf": [ + { + "name": "rfc5869-1", + "ikm": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "salt": "000102030405060708090a0b0c", + "info": "f0f1f2f3f4f5f6f7f8f9", + "len": 42, + "out": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + }, + { + "name": "seal-shaped", + "ikm": "61677265656d656e7420736861726564", + "salt": "65706b726563697069656e74", + "info": "736d6f6c6d61696c2f31207365616c", + "len": 32, + "out": "b9f729e45d7bab89598edbce35f3f5bb91d6f403a5ff85943a838defbfcd7a0c" + } + ], + "aead": [ + { + "name": "empty", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "", + "plaintext": "", + "sealed": "941b286ea0ee86bb66236fc3c16b2e48" + }, + { + "name": "short", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "50515253c0c1c2c3c4c5c6c7", + "plaintext": "68656c6c6f", + "sealed": "7dbede6dd11ffa046f102dedea535912be561e3c29" + }, + { + "name": "multiline", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "50515253c0c1c2c3c4c5c6c7", + "plaintext": "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a206966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e", + "sealed": "59bad668dbf00561dd86add05afe35b269f9997d57e46cee0e42fd69693c3df16b681f3905bbea4135cb379f4a0c730b5dbb3ba06d1231ce396660a16f8cadb8b422d745b932df3c1eed2df9636750551a0333b3d06877582f172f3ec2d437061143699bfc7258aa98e319f23a1f31f88fd15a24a97b46fd2e05c266d31ee96f5e24" + } + ], + "x25519": [ + { + "name": "alice", + "priv": "c63095403fea13cb2c43380599e669ebfb41ab184b04df28a143472e4283aa33", + "pub": "712e68cefc13d0e1778226b7f7aaeb59042225e7a4def3816344da256e031664" + }, + { + "name": "bob", + "priv": "33485a5b40b70730b3c3e468a8262543e5a4d9dc98de06399de66a4d579e02a7", + "pub": "b8540c30e8fb348aed0abc8189cf8025ce09a9c5d74e0681c4e50f8d281da252" + }, + { + "name": "agree", + "shared": "e6a3b1d2ae10c29b51519a71255af4bd20deee697c6ced1a44eadff428439e13" + }, + { + "name": "low-order-0", + "peer": "0000000000000000000000000000000000000000000000000000000000000000", + "peer_rejected": true, + "raised": true + }, + { + "name": "low-order-1", + "peer": "0100000000000000000000000000000000000000000000000000000000000000", + "peer_rejected": true, + "raised": true + } + ], + "ed25519": [ + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "", + "signature": "b20543ac2e0ba66c1b437de2afda7ca8ca6f9feb2af3e3e7ac3183e388d1786c9c027bfe549639140d0e45e7e850999b3fb992c7c003946c1c5aaeb09892cc0c", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", + "signature": "f7e74a0540e05843b52efb7dee4f3622ab8a88333142f6dd1d5386612f7f0f1410bd0b1f1ff09dea9419922b756920a4c1fb31a95eac3cc55a410d0d6f1dab03", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "e55b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "e45b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", + "valid": false + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "", + "signature": "2dfb4b1e65dfd4bf991ef50be88acddf2d59fe158daf2879bec5641ab80b1e0c542b9ea2bd9a6bc76f2020b76b14dc80ae9f68c62ea58dbd8f5b1990ff069e08", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", + "signature": "f6d398cd25fec0ba882af13b85c6addcc2f546181fba84f8925e6e196b759897337a2291f4b73c40a062b0a2283ef096ded790154af58e9d4bd39c32b3db2f05", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "24f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "25f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", + "valid": false + } + ], + "ed_to_x25519": [ + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "x_priv": "30dc8ba3a49892d4b8a626cd371fd43bff4e5651c2a45f1be16e89d84fa89e68", + "x_pub": "77bc3dae84c9b4085862725a21e0a366c09563d6da8f29c408ac2b6928937910" + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "x_priv": "b03deb6f9f4ab25d15471a355ff4ee23ea98f7d24695c500ed80b6af4b7b9963", + "x_pub": "253d4b5b14df341a5dde486ddd588e292444118ed8eed1fe0738823b82e4243d" + } + ], + "envelope": { + "sender_seed": "89c16df9e4352e706abe701928c230d8bd169cd31633bf4589c2d410cb3ae8cc", + "recipient_seed": "6f845ccc435252d39dd08e964d219258e92c3d6c54af0376fa45d5e55eec0aaf", + "esk": "c31fee505964c44b711cf354b431f9716c644a3dbf8c1d29c5e3cedf884d0a48", + "body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a", + "time": 1730000000, + "envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab", + "id": "b05d15a2ab5293164eda564de02a6901", + "unpadded_plaintext_len": 143 + }, + "noise": { + "initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2", + "responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80", + "server_static_priv": "c7b206a746c9b167da412a6b1a235869e316e9f08dbbc8478430b2998130f79e", + "server_static_pub": "fa111eca8e853320f8e076674143fd4d1cd181bddeda7d9950a65922b9eafc32", + "message1": "b8b73e6f132dab5659270e6496d2c575ae7daf45a0961ae8e19405a12ed1cc2e", + "message2": "0b03ec78fd19cf7b8480881f33c6b4ca1094fac03c87860095c87c6737349c28256e498747bcf2018420d145c378e6605e63b217f92925ae5771dbe387b94cf9c995f7a3a8314fe2e671ca8fa1463e0642c1d7974f326737cadf630b8aac8ed9", + "handshake_hash": "a039471479680a596a8a61b8827afb01853274b03de2870095edb9b7dd4e2c72", + "assert_hash_equal": true, + "initiator_frames": [ + { + "plaintext": "70696e67", + "sealed": "f1885096d9b9383b0bc38b08dff77c005d69f0f0" + }, + { + "plaintext": "7365636f6e64206672616d6520746f20746573742074686520636f756e746572", + "sealed": "4a6481a2f7d0c909d9b321bc097e09a1929aeaf8647751f64d65b5e1f92abe960796dbf1c619bef48845aea257f2c73c" + } + ], + "responder_frames": [ + { + "plaintext": "706f6e67", + "sealed": "a7dda7fe3ef32435b3e72fda49714e9977318f0b" + }, + { + "plaintext": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878", + "sealed": "9aa3af13c00a8a0cd81167b48a5443541e0c862297638e4b7e5c71196657bc565aad46c2d147b23f752ac48c29b69699209e252d3e840c13fc466dd0445515dec2d289902c7177a237f9256afe9508a45b64ea12cdb597a8c38b6ce0c43891ec772c21ca360980094268686642eae8e01ccf89650a427297dbab052aabfeb08adbb2087ae303dd168ceb3beffa63c8d3ccd1c5b2687c2a9c0d43eaab237fde648bf8b0f347b4952ff6da2212360b4a2a5b1316e9e776d82961c498e51f35a58c999f080ac1c12d7ac08f6ddd7addb70c37ea6bef42ed2c498362f4fd75a222068035a7f372c4f57e613427193ffd8ed40a2d0765ba62cbfd6cb5103165dc15be7ad2db723a4edc73cefe9f7fe5ff78a8ea06ecbc7c5135e5d810a1bd4e47f0cd4a1b6e8dd88b85236dd4b41296e00b7054139ee4fd4faa5876e01d62" + } + ] + } +} diff --git a/test/vectors.test.mjs b/test/vectors.test.mjs new file mode 100644 index 0000000..baacbc7 --- /dev/null +++ b/test/vectors.test.mjs @@ -0,0 +1,187 @@ +// Unit tests: web/js must reproduce test/vectors.json byte for byte, plus +// protocol-level checks for frontmatter, addresses and rotation chains. +// Run: node test/vectors.test.mjs + +import { readFileSync } from "node:fs"; +import * as crypto from "../web/js/crypto.js"; +import * as noise from "../web/js/noise.js"; +import * as proto from "../web/js/proto.js"; + +const vectors = JSON.parse(readFileSync(new URL("./vectors.json", import.meta.url), "utf8")); +const unhex = crypto.unhex; +const hex = crypto.hex; +let passed = 0; + +function ok(name, condition, detail = "") { + if (!condition) throw new Error(`FAIL ${name} ${detail}`); + passed++; +} + +const eq = (name, got, want) => ok(name, got === want, `got ${got}, want ${want}`); + +// --- hashes, HMAC/HKDF, AEAD -------------------------------------------------- + +for (const v of vectors.sha256) eq(`sha256[${v.in.slice(0, 12)}]`, hex(crypto.sha256(unhex(v.in))), v.out); +for (const v of vectors.sha512) eq(`sha512[${v.in.slice(0, 12)}]`, hex(crypto.sha512(unhex(v.in))), v.out); +for (const v of vectors.hkdf) + eq(`hkdf[${v.name}]`, hex(crypto.hkdfSha256(unhex(v.ikm), unhex(v.salt), unhex(v.info), v.len)), v.out); +for (const v of vectors.aead) { + const sealed = crypto.aeadEncrypt(unhex(v.key), unhex(v.nonce), unhex(v.plaintext), unhex(v.aad)); + eq(`aead-seal[${v.name}]`, hex(sealed), v.sealed); + eq(`aead-open[${v.name}]`, + hex(crypto.aeadDecrypt(unhex(v.key), unhex(v.nonce), unhex(v.sealed), unhex(v.aad))), v.plaintext); + let threw = false; + try { crypto.aeadDecrypt(unhex(v.key), unhex(v.nonce), unhex(v.sealed).slice(0, -1), unhex(v.aad)); } + catch { threw = true; } + ok(`aead-tamper[${v.name}]`, threw); +} + +// --- X25519 ------------------------------------------------------------------- + +const x = Object.fromEntries(vectors.x25519.map(v => [v.name, v])); +for (const name of ["alice", "bob"]) + eq(`x25519-base[${name}]`, hex(crypto.x25519Base(unhex(x[name].priv))), x[name].pub); +eq("x25519-agree", hex(crypto.x25519(unhex(x.alice.priv), unhex(x.bob.pub))), x.agree.shared); +for (const v of vectors.x25519.filter(v => v.name.startsWith("low-order"))) { + let threw = false; + try { crypto.x25519(unhex(x.alice.priv), unhex(v.peer)); } catch { threw = true; } + ok(`x25519-rejects[${v.name}]`, threw); +} + +// --- Ed25519 ------------------------------------------------------------------ + +for (const v of vectors.ed25519) { + const seed = unhex(v.seed); + eq(`ed25519-pub[${v.name}]`, hex(crypto.ed25519PublicKey(seed)), v.pub); + const sig = crypto.ed25519Sign(seed, unhex(v.message)); + if (v.valid) { + eq(`ed25519-sign[${v.name}]`, hex(sig), v.signature); + ok(`ed25519-verify[${v.name}]`, crypto.ed25519Verify(unhex(v.pub), unhex(v.message), sig)); + ok(`ed25519-verify-pynacl[${v.name}]`, crypto.ed25519Verify(unhex(v.pub), unhex(v.message), unhex(v.signature))); + } else { + ok(`ed25519-reject[${v.name}]`, + !crypto.ed25519Verify(unhex(v.pub), unhex(v.message), unhex(v.signature))); + } +} + +// --- §2 conversions ----------------------------------------------------------- + +for (const v of vectors.ed_to_x25519) { + eq(`x_priv[${v.name}]`, hex(crypto.ed25519SeedToX25519(unhex(v.seed))), v.x_priv); + eq(`x_pub[${v.name}]`, hex(crypto.ed25519ToX25519(crypto.ed25519PublicKey(unhex(v.seed)))), v.x_pub); +} + +// --- §5 envelope -------------------------------------------------------------- + +{ + const v = vectors.envelope; + const sender = proto.identityFromSeed(unhex(v.sender_seed)); + const recipient = proto.identityFromSeed(unhex(v.recipient_seed)); + const seal = (opts) => proto.seal(sender, recipient.publicKey, unhex(v.body), v.time, + { esk: unhex(v.esk), ...opts }); + eq("envelope", hex(seal({ pad: false })), v.envelope); + eq("envelope-id", hex(proto.messageId(unhex(v.envelope))), v.id); + const opened = proto.unseal([recipient], unhex(v.envelope)); + eq("envelope-unseal-sender", hex(opened.sender), hex(sender.publicKey)); + eq("envelope-unseal-time", String(opened.time), String(v.time)); + eq("envelope-unseal-body", hex(opened.body), v.body); + let threw = false; + try { proto.unseal([proto.identityFromSeed(unhex(v.esk))], unhex(v.envelope)); } catch { threw = true; } + ok("envelope-wrong-recipient", threw); + // padding round-trips and is ignored by the receiver (§5.3) + const padded = seal({}); + // the padded envelope is 69 header + 16 tag + a multiple of 1 KiB of plaintext + ok("envelope-padded", + (padded.length - proto.ENVELOPE_HEADER - 16) % proto.PAD_TO === 0 + && padded.length > v.envelope.length / 2); + eq("envelope-padded-body", hex(proto.unseal([recipient], padded).body), v.body); +} + +// --- Noise NX transcript ------------------------------------------------------ + +{ + const v = vectors.noise; + ok("noise-transcript-selfcheck", v.assert_hash_equal); + const nx = new noise.NxInitiator(); + eq("noise-m1", hex(nx.writeMessage1(unhex(v.initiator_eph_priv))), v.message1); + const ciphers = nx.readMessage2(unhex(v.message2)); + eq("noise-server-static", hex(nx.serverStatic), v.server_static_pub); + eq("noise-handshake-hash", hex(nx.handshakeHash), v.handshake_hash); + for (const [i, frame] of v.initiator_frames.entries()) { + eq(`noise-frame-i${i}`, hex(ciphers.send.encrypt(unhex(frame.plaintext))), frame.sealed); + } + for (const [i, frame] of v.responder_frames.entries()) { + eq(`noise-frame-r${i}`, hex(ciphers.recv.decrypt(unhex(frame.sealed))), frame.plaintext); + } + // the responder direction must also produce identical ciphertexts (AEAD is + // deterministic), so the recv cipher can be checked in both directions + const mirrored = new noise.NxInitiator(); + mirrored.writeMessage1(unhex(v.initiator_eph_priv)); + const mirrorCiphers = mirrored.readMessage2(unhex(v.message2)); + eq("noise-frame-r0-mirror", + hex(mirrorCiphers.recv.encrypt(unhex(v.responder_frames[0].plaintext))), v.responder_frames[0].sealed); +} + +// --- frontmatter (§5.5) ------------------------------------------------------- + +{ + const spec = "---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here."; + const { fields, body } = proto.parseFrontmatter(spec); + eq("fm-subject", fields.Subject, "Re: the thing"); + eq("fm-reply", fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d"); + eq("fm-body", body, "Body text starts here."); + // a malformed line invalidates the whole block, which fails closed toward display + eq("fm-malformed", proto.parseFrontmatter("---\nno colon here\n---\nrest").body, "---\nno colon here\n---\nrest"); + eq("fm-unterminated", proto.parseFrontmatter("---\nSubject: x\nno end").body, "---\nSubject: x\nno end"); + eq("fm-first-wins", proto.parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields.A, "1"); + eq("fm-escape", proto.buildFrontmatter([], "---\nactual body"), "---\n---\n---\nactual body"); + eq("fm-build", proto.buildFrontmatter([["Subject", "hi"]], "there"), "---\nSubject: hi\n---\nthere"); + eq("fm-no-block", proto.buildFrontmatter([], "plain"), "plain"); + eq("fm-too-many-keys", proto.parseFrontmatter("---\n" + "X: y\n".repeat(65) + "---\nbody").fields.Subject ?? "none", "none"); +} + +// --- addresses (§3) ----------------------------------------------------------- + +{ + const a = proto.parseAddress("Alice@Example.ORG:1961"); + eq("addr-user", a.user, "alice"); + eq("addr-port", String(a.port), "1961"); + eq("addr-short", a.short, "alice@example.org"); // a default port is dropped, as in the reference + eq("addr-default-port", String(proto.parseAddress("bob@host").port), "1961"); + const key = unhex(vectors.ed25519[0].pub); + const parsed = proto.parseAddress(proto.parseAddress("bob@h").uri(key)); + ok("addr-uri-roundtrip", key.every((b, i) => b === parsed.identity[i]) && parsed.user === "bob"); + let threw = false; + try { proto.parseAddress("-bob@h"); } catch { threw = true; } + ok("addr-separator-rejected", threw); + // §3: fingerprints are the first 20 base32 characters in groups of four + const b32 = proto.b32encode(key); + eq("fingerprint", proto.fingerprint(key), + [b32.slice(0, 4), b32.slice(4, 8), b32.slice(8, 12), b32.slice(12, 16), b32.slice(16, 20)].join(" ")); + for (const n of [1, 2, 5, 32, 52, 64]) { + const raw = crypto.randomBytes(n); + eq(`b32[${n}]`, proto.b32encode(proto.b32decode(proto.b32encode(raw))), proto.b32encode(raw)); + } +} + +// --- rotation chains (§7) ------------------------------------------------------ + +{ + const old = proto.identityFromSeed(crypto.randomBytes(32)); + const mid = crypto.randomBytes(32); + const midPub = crypto.ed25519PublicKey(mid); + const fresh = crypto.randomBytes(32); + const freshPub = crypto.ed25519PublicKey(fresh); + const when = proto.nowSeconds(); + const chain = [proto.makeCert(old, mid, when), proto.makeCert(proto.identityFromSeed(mid), fresh, when)]; + ok("chain-valid", proto.walkChain(old.publicKey, freshPub, chain)); + ok("chain-unchanged", proto.walkChain(old.publicKey, old.publicKey, [])); + ok("chain-missing-link", !proto.walkChain(old.publicKey, freshPub, chain.slice(1))); + const forged = chain.slice(); + forged[1] = proto.makeCert(proto.identityFromSeed(mid), crypto.randomBytes(32), when); + ok("chain-broken", !proto.walkChain(old.publicKey, freshPub, forged)); + ok("chain-oversize", !proto.walkChain(old.publicKey, freshPub, Array(17).fill(chain[0]))); + eq("cert-len", String(chain[0].length), "136"); +} + +console.log(`${passed} checks passed`); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..43cc861 --- /dev/null +++ b/web/index.html @@ -0,0 +1,92 @@ + + + + + +smol mail + + + +
+

smol mail

+ + +
+ +
+

welcome

+

Identity is one 32-byte seed. Back it up: it is the only secret, and losing it means losing the address.

+
+ + or restore from a saved seed: + + +
+
+ +
+ +
+

select a message

+
+ + +
+ + + +
+ + + +
+
+
+ + +

settings

+
+

identity

+
+ +
+
+

account

+
+ + + +
+

registration requires a pinned server key (SPEC.md §4). Pin one first.

+
+
+

server keys

+
+ + + +
+
    +
    +
    +

    contacts

    +
    + + +
    +
    +
    +
    + +
    + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..6b81d64 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,492 @@ +// UI wiring for the smol mail client: identity setup, folders, reading, +// composing, fetching with verify-before-ack, and the §4/§7 trust surfaces. + +import { hex, timingSafeEqual, utf8Bytes } from "./crypto.js"; +import { + DEFAULT_PORT, SmolError, authenticate, b32decode, b32encode, buildFrontmatter, deleteOp, + fetchOp, fingerprint, makeCert, messageId, newIdentity, openSession, parseAddress, + parseFrontmatter, registerOp, resolveOp, seal, sendOp, unseal, walkChain, +} from "./proto.js"; +import * as store from "./store.js"; +import { connectStream } from "./transport.js"; + +const $ = (id) => document.getElementById(id); + +function status(text, warn = false) { + $("status").textContent = text; + $("status").className = warn ? "status warn" : "status"; +} + +const fmtTime = (seconds) => + new Date(seconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); + +// The stored account as an address object, default port included when custom. +function accountAddress() { + const account = store.account(); + if (!account) return null; + const suffix = account.port === DEFAULT_PORT ? "" : `:${account.port}`; + return parseAddress(`${account.user}@${account.host}${suffix}`); +} + +// --- connection -------------------------------------------------------------- + +// §4: registration and fetching demand a pinned key; sending to a recipient +// whose key we already hold tolerates an unpinned server. +async function connect(addr, { requirePin }) { + const pinned = store.serverPin(addr.host); + if (requirePin && !pinned) + throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` + + `trusted channel, then pin it in settings.`); + const stream = await connectStream(addr.host, addr.port); + const opened = await openSession(stream, addr.host, pinned); + if (!pinned) + status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` + + `RESOLVE results from this session are UNVERIFIED (SPEC.md §8).`, true); + return opened; +} + +// --- identity setup ------------------------------------------------------------ + +function showSetup() { + $("setup").hidden = false; + $("app").hidden = true; + $("account").textContent = ""; +} + +function showApp() { + $("setup").hidden = true; + $("app").hidden = false; + renderAccount(); + renderFolder(); +} + +$("create-identity").onclick = () => { + store.setIdentity(newIdentity().seed); + showApp(); + status("identity created. Back up the seed in settings now; it is the only secret."); + openSettings(); +}; + +$("restore-identity").onclick = () => { + const text = $("restore-seed").value.trim(); + if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true); + const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16)); + try { + store.setIdentity(seedBytes); + } catch (err) { + return status(err.message, true); + } + showApp(); + status("identity restored."); +}; + +// --- header --------------------------------------------------------------------- + +function renderAccount() { + const me = store.identity(); + const account = store.account(); + $("account").textContent = me + ? `${account ? account.user + "@" + account.host : "(not registered)"} · ${fingerprint(me.publicKey)}` + : ""; +} + +// --- folders and list --------------------------------------------------------- + +let folder = "inbox"; +let selectedId = null; + +for (const button of $("folders").querySelectorAll("button")) + button.onclick = () => { + folder = button.dataset.folder; + for (const b of $("folders").querySelectorAll("button")) b.classList.toggle("active", b === button); + selectedId = null; + renderFolder(); + }; + +async function renderFolder() { + const list = $("list"); + if (folder === "contacts") return renderContacts(list); + const rows = await store.listMessages(folder); + list.replaceChildren(); + for (const row of rows) { + const opened = describe(row); + const item = document.createElement("div"); + item.className = "item" + (row.id === selectedId ? " selected" : ""); + const who = document.createElement("div"); + who.className = "who" + (store.isRead(row.id) ? "" : " unread"); + who.textContent = folder === "sent" ? row.recipient : (opened.from ?? "?"); + const subject = document.createElement("div"); + subject.className = "subject"; + subject.textContent = opened.error ? `` + : (opened.subject || "(no subject)"); + const when = document.createElement("span"); + when.className = "when"; + when.textContent = fmtTime(folder === "sent" ? row.sentAt : opened.time ?? row.receivedAt); + item.append(who, subject, when); + item.onclick = () => openMessage(row.id); + list.append(item); + } + if (!rows.length) list.append(Object.assign(document.createElement("p"), + { className: "placeholder", textContent: folder === "inbox" ? "no mail — fetch" : "nothing sent" })); +} + +// Open an envelope and split its body; anything unreadable shows as such +// rather than throwing, so one bad message cannot blank the list. +function describe(row) { + try { + const opened = unseal(store.identities(), row.envelope); + const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body)); + return { + ...opened, + fields, body, + subject: fields.Subject || "", + from: store.addressForKey(opened.sender) ?? `<${b32encode(opened.sender).slice(0, 16)}…>`, + }; + } catch (err) { + return { error: err.message }; + } +} + +// --- reading ------------------------------------------------------------------ + +async function openMessage(id) { + const row = await store.getMessage(folder, id); + if (!row) return; + selectedId = id; + store.markRead(id); + renderFolder(); + const opened = describe(row); + const view = $("view"); + if (opened.error) { + view.innerHTML = `

    ${opened.error}

    `; + return; + } + const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject"); + view.innerHTML = `

    ` + + `
    signature verified
    `;
    +  view.querySelector("h2").textContent = opened.subject || "(no subject)";
    +  view.querySelector(".meta").textContent = folder === "sent"
    +    ? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}`
    +    : `from: ${opened.from}\nkey: ${b32encode(opened.sender)}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}`;
    +  view.querySelector(".fields").textContent = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
    +  view.querySelector("pre").textContent = opened.body;
    +  if (folder === "inbox") {
    +    const reply = document.createElement("button");
    +    reply.textContent = "reply";
    +    reply.onclick = () => openCompose({
    +      to: store.addressForKey(opened.sender) ?? "",
    +      subject: opened.subject ? (opened.subject.startsWith("Re:") ? opened.subject : `Re: ${opened.subject}`) : "",
    +      replyTo: row.id,
    +    });
    +    view.append(reply);
    +  }
    +}
    +
    +// --- compose and send -----------------------------------------------------------
    +
    +const compose = $("compose");
    +
    +function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) {
    +  const form = compose.querySelector("form");
    +  form.to.value = to;
    +  form.subject.value = subject;
    +  form.body.value = body;
    +  form.dataset.replyTo = replyTo ?? "";
    +  $("compose-error").textContent = "";
    +  compose.showModal();
    +}
    +
    +$("write").onclick = () => openCompose();
    +
    +compose.querySelector("form").onsubmit = async (event) => {
    +  event.preventDefault();
    +  if (event.submitter?.value !== "send") {
    +    compose.close();
    +    return;
    +  }
    +  const form = event.target;
    +  $("compose-error").textContent = "";
    +  try {
    +    await sendMail(form.to.value, form.subject.value, form.body.value, form.dataset.replyTo);
    +    compose.close();
    +  } catch (err) {
    +    $("compose-error").textContent = err.message;
    +  }
    +};
    +
    +async function sendMail(toText, subject, body, replyTo) {
    +  const me = store.identity();
    +  if (!me) throw new SmolError("no identity yet");
    +  const addr = parseAddress(toText);
    +  const recipient = await resolveRecipient(addr);
    +  const fields = [["Subject", subject], ["In-Reply-To", replyTo]].filter(([, v]) => v);
    +  const bodyBytes = utf8Bytes(buildFrontmatter(fields, body.replace(/\s+$/, "") + "\n"));
    +  const envelope = seal(me, recipient, bodyBytes);
    +  const opened = await connect(addr, { requirePin: false });
    +  try {
    +    await sendOp(opened.session, envelope);
    +  } finally {
    +    opened.session.stream.close();
    +  }
    +  // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
    +  await store.storeMessage("sent", {
    +    id: hex(messageId(envelope)),
    +    recipient: addr.short,
    +    envelope: seal(me, me.publicKey, bodyBytes),
    +    sentAt: Math.floor(Date.now() / 1000),
    +  });
    +  status(`sent to ${addr.short}`);
    +  if (folder === "sent") renderFolder();
    +}
    +
    +// Prefer a key we already trust; fall back to RESOLVE with trust on first use.
    +async function resolveRecipient(addr) {
    +  if (addr.identity) {
    +    store.saveContact(addr.short, addr.identity, true);
    +    return addr.identity;
    +  }
    +  const known = store.contact(addr.short);
    +  if (known) return known.key;
    +  const opened = await connect(addr, { requirePin: false });
    +  let current;
    +  try {
    +    ({ identity: current } = await resolveOp(opened.session, addr.user));
    +  } finally {
    +    opened.session.stream.close();
    +  }
    +  store.saveContact(addr.short, current, false);
    +  status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
    +  return current;
    +}
    +
    +// --- fetch ----------------------------------------------------------------------
    +
    +$("fetch").onclick = async () => {
    +  const me = store.identity();
    +  const addr = accountAddress();
    +  if (!me) return status("no identity yet", true);
    +  if (!addr) return status("not registered; register in settings first", true);
    +  status("fetching…");
    +  let stored = 0, rejected = 0;
    +  try {
    +    const opened = await connect(addr, { requirePin: true });
    +    try {
    +      await authenticate(opened.session, opened.handshakeHash, addr.user, me);
    +      for (;;) {
    +        const records = await fetchOp(opened.session);
    +        if (!records.length) break;
    +        const acked = [];
    +        for (const record of records) {
    +          try {
    +            if (!timingSafeEqual(messageId(record.envelope), record.id))
    +              throw new SmolError("id does not match the envelope");
    +            unseal(store.identities(), record.envelope);
    +          } catch (err) {
    +            // Left on the server rather than destroyed, so a client-side bug
    +            // cannot lose mail.
    +            rejected++;
    +            status(`${hex(record.id)}: ${err.message}; left on server`, true);
    +            continue;
    +          }
    +          if (await store.storeIfNew("inbox", {
    +            id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt,
    +          })) stored++;
    +          acked.push(record.id);
    +        }
    +        if (!acked.length) break;
    +        await deleteOp(opened.session, acked);
    +      }
    +    } finally {
    +      opened.session.stream.close();
    +    }
    +    status(`${stored} new, ${rejected} rejected` + (rejected ? " (left on the server)" : ""));
    +  } catch (err) {
    +    status(err.message, true);
    +  }
    +  renderFolder();
    +};
    +
    +// --- contacts -------------------------------------------------------------------
    +
    +function renderContacts(list) {
    +  list.replaceChildren();
    +  const contacts = store.allContacts();
    +  for (const { address, key, verified } of contacts) {
    +    const item = document.createElement("div");
    +    item.className = "item";
    +    const who = document.createElement("div");
    +    who.className = "who";
    +    who.textContent = address;
    +    const fp = document.createElement("div");
    +    fp.className = "subject";
    +    fp.textContent = `${fingerprint(b32decode(key))} · ${verified ? "verified key" : "tofu"}`;
    +    const refresh = document.createElement("button");
    +    refresh.textContent = "refresh";
    +    refresh.onclick = () => refreshContact(address);
    +    item.append(who, fp, refresh);
    +    list.append(item);
    +  }
    +  if (!contacts.length) list.append(Object.assign(document.createElement("p"),
    +    { className: "placeholder", textContent: "no contacts; import a smol:// address or write to someone" }));
    +}
    +
    +// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
    +// surfaced; anything else requires out-of-band verification.
    +async function refreshContact(address) {
    +  try {
    +    const addr = parseAddress(address);
    +    if (addr.identity) throw new SmolError("that address already carries a key; use import instead");
    +    const known = store.contact(addr.short);
    +    const opened = await connect(addr, { requirePin: false });
    +    let current, chain;
    +    try {
    +      ({ identity: current, chain } = await resolveOp(opened.session, addr.user));
    +    } finally {
    +      opened.session.stream.close();
    +    }
    +    if (!known) {
    +      store.saveContact(addr.short, current, false);
    +      status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
    +    } else if (timingSafeEqual(known.key, current)) {
    +      status(`${addr.short}: key unchanged`);
    +    } else if (walkChain(known.key, current, chain)) {
    +      store.saveContact(addr.short, current, known.verified);
    +      status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true);
    +    } else {
    +      status(`${addr.short} presents a different key with no valid rotation chain.\n` +
    +        `Verify out of band, then import the new smol:// address.`, true);
    +    }
    +  } catch (err) {
    +    status(err.message, true);
    +  }
    +  renderFolder();
    +}
    +
    +// --- settings ---------------------------------------------------------------------
    +
    +const settings = $("settings");
    +
    +function openSettings() {
    +  renderSettings();
    +  settings.showModal();
    +}
    +
    +$("settings-open").onclick = openSettings;
    +$("settings-close").onclick = () => settings.close();
    +
    +function renderSettings() {
    +  const me = store.identity();
    +  const account = store.account();
    +  const card = $("identity-card");
    +  if (!me) { card.textContent = ""; return; }
    +  const share = account ? accountAddress().uri(me.publicKey) : "(register to get a shareable address)";
    +  card.innerHTML = `
    fingerprint: ${fingerprint(me.publicKey)}
    ` + + `
    address: ${account ? account.user + "@" + account.host : "(not registered)"}
    ` + + `
    share: ${share}
    ` + + `
    ${hex(me.seed)}
    ` + + `
    back this seed up; it is the only secret
    ` + + `
    retired keys: ${store.identities().length - 1} (kept to read old mail)
    `; + + const pins = $("pins"); + pins.replaceChildren(); + for (const { host, key } of store.allPins()) { + const li = document.createElement("li"); + const name = document.createElement("span"); + name.textContent = host; + const value = document.createElement("span"); + value.className = "key"; + value.textContent = key; + const remove = document.createElement("button"); + remove.textContent = "remove"; + remove.onclick = () => { store.unpinServer(host); renderSettings(); }; + li.append(name, value, remove); + pins.append(li); + } + if (!store.allPins().length) pins.append(Object.assign(document.createElement("li"), + { className: "placeholder", textContent: "no pinned servers" })); +} + +$("pin-form").onsubmit = (event) => { + event.preventDefault(); + const form = event.target; + try { + const host = form.host.value.trim().toLowerCase(); + const key = b32decode(form.key.value.trim()); + if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`); + store.pinServer(host, key); + status(`pinned ${host}`); + renderSettings(); + form.reset(); + } catch (err) { + status(err.message, true); + } +}; + +$("import-form").onsubmit = (event) => { + event.preventDefault(); + const form = event.target; + try { + const addr = parseAddress(form.uri.value.trim()); + if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key"); + store.saveContact(addr.short, addr.identity, true); + status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` + + `fingerprint: ${fingerprint(addr.identity)}`); + renderSettings(); + renderFolder(); + form.reset(); + } catch (err) { + status(err.message, true); + } +}; + +$("register-form").onsubmit = async (event) => { + event.preventDefault(); + const form = event.target; + try { + const me = store.identity(); + if (!me) throw new SmolError("no identity yet"); + const addr = parseAddress(form.address.value.trim()); + const opened = await connect(addr, { requirePin: true }); + try { + await registerOp(opened.session, { username: addr.user, identity: me, token: form.token.value.trim() }); + } finally { + opened.session.stream.close(); + } + store.setAccount(addr); + status(`registered ${addr.short}\nshare: ${addr.uri(me.publicKey)}`); + renderAccount(); + form.reset(); + } catch (err) { + status(err.message, true); + } +}; + +$("rotate").onclick = async () => { + const me = store.identity(); + const addr = accountAddress(); + if (!me || !addr) return status("rotate needs a registered account", true); + if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " + + "chain. The old key is kept, since mail sealed to it stays readable with nothing else.")) + return; + try { + const fresh = newIdentity(); + const cert = makeCert(me, fresh.seed); + const opened = await connect(addr, { requirePin: true }); + try { + await registerOp(opened.session, { username: addr.user, identity: fresh, cert }); + } finally { + opened.session.stream.close(); + } + store.rotateIdentity(fresh.seed); + status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`); + renderAccount(); + renderSettings(); + } catch (err) { + status(err.message, true); + } +}; + +// --- boot -------------------------------------------------------------------------- + +store.identity() ? showApp() : showSetup(); +status(store.identity() ? "" : "create an identity to begin"); diff --git a/web/js/crypto.js b/web/js/crypto.js new file mode 100644 index 0000000..d897099 --- /dev/null +++ b/web/js/crypto.js @@ -0,0 +1,437 @@ +// Dependency-free primitives for Smol Mail (SPEC.md §1): SHA-256, SHA-512, +// HMAC/HKDF-SHA256, ChaCha20-Poly1305, X25519, Ed25519, and the §2 key +// conversions between the two curves. Pure JS so the same code runs in the +// browser and in Node for testing against the reference implementation. + +const utf8 = new TextEncoder(); + +export function concat(...parts) { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let off = 0; + for (const p of parts) { out.set(p, off); off += p.length; } + return out; +} + +export function utf8Bytes(text) { return utf8.encode(text); } + +export function hex(bytes) { + return [...bytes].map(b => b.toString(16).padStart(2, "0")).join(""); +} + +export function unhex(text) { + if (text.length % 2) throw new Error(`odd-length hex string: ${text}`); + const out = new Uint8Array(text.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16); + return out; +} + +export function leBytesToBigInt(bytes) { + let n = 0n; + for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]); + return n; +} + +export function bigIntToLeBytes(value, length) { + const out = new Uint8Array(length); + for (let i = 0; i < length; i++) { out[i] = Number(value & 0xffn); value >>= 8n; } + return out; +} + +export function randomBytes(n) { + const out = new Uint8Array(n); + crypto.getRandomValues(out); + return out; +} + +export function timingSafeEqual(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +// --- SHA-256 ---------------------------------------------------------------- + +const K256 = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const rotl32 = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0; +const rotr32 = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0; + +export function sha256(message) { + const padded = new Uint8Array((((message.length + 9) + 63) >> 6 << 6)); + padded.set(message); + padded[message.length] = 0x80; + const bits = BigInt(message.length) * 8n; + new DataView(padded.buffer).setBigUint64(padded.length - 8, bits, false); + + const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const w = new Uint32Array(64); + const view = new DataView(padded.buffer); + for (let off = 0; off < padded.length; off += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4); + for (let i = 16; i < 64; i++) { + const s0 = rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K256[i] + w[i]) >>> 0; + const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh = g; g = f; f = e; e = (d + t1) >>> 0; + d = c; c = b; b = a; a = (t1 + t2) >>> 0; + } + const add = [a, b, c, d, e, f, g, hh]; + for (let i = 0; i < 8; i++) h[i] = (h[i] + add[i]) >>> 0; + } + const out = new Uint8Array(32); + for (let i = 0; i < 8; i++) new DataView(out.buffer).setUint32(i * 4, h[i]); + return out; +} + +// --- HMAC-SHA256 and HKDF (RFC 2104, RFC 5869) ------------------------------- + +export function hmacSha256(key, message) { + let block = new Uint8Array(64).fill(0x36); + for (let i = 0; i < Math.min(64, key.length); i++) block[i] ^= key[i]; + const inner = sha256(concat(block, message)); + block = new Uint8Array(64).fill(0x5c); + for (let i = 0; i < Math.min(64, key.length); i++) block[i] ^= key[i]; + return sha256(concat(block, inner)); +} + +export function hkdfSha256(ikm, salt, info, length = 32) { + const prk = hmacSha256(salt, ikm); + let out = new Uint8Array(0), block = new Uint8Array(0), counter = 1; + while (out.length < length) { + block = hmacSha256(prk, concat(block, info, Uint8Array.of(counter))); + out = concat(out, block); + counter++; + } + return out.slice(0, length); +} + +// --- SHA-512 (Ed25519 only) -------------------------------------------------- + +const M64 = (1n << 64n) - 1n; +const K512 = [ + 0x428a2f98d728ae22n, 0x7137449123ef65cdn, 0xb5c0fbcfec4d3b2fn, 0xe9b5dba58189dbbcn, + 0x3956c25bf348b538n, 0x59f111f1b605d019n, 0x923f82a4af194f9bn, 0xab1c5ed5da6d8118n, + 0xd807aa98a3030242n, 0x12835b0145706fben, 0x243185be4ee4b28cn, 0x550c7dc3d5ffb4e2n, + 0x72be5d74f27b896fn, 0x80deb1fe3b1696b1n, 0x9bdc06a725c71235n, 0xc19bf174cf692694n, + 0xe49b69c19ef14ad2n, 0xefbe4786384f25e3n, 0x0fc19dc68b8cd5b5n, 0x240ca1cc77ac9c65n, + 0x2de92c6f592b0275n, 0x4a7484aa6ea6e483n, 0x5cb0a9dcbd41fbd4n, 0x76f988da831153b5n, + 0x983e5152ee66dfabn, 0xa831c66d2db43210n, 0xb00327c898fb213fn, 0xbf597fc7beef0ee4n, + 0xc6e00bf33da88fc2n, 0xd5a79147930aa725n, 0x06ca6351e003826fn, 0x142929670a0e6e70n, + 0x27b70a8546d22ffcn, 0x2e1b21385c26c926n, 0x4d2c6dfc5ac42aedn, 0x53380d139d95b3dfn, + 0x650a73548baf63den, 0x766a0abb3c77b2a8n, 0x81c2c92e47edaee6n, 0x92722c851482353bn, + 0xa2bfe8a14cf10364n, 0xa81a664bbc423001n, 0xc24b8b70d0f89791n, 0xc76c51a30654be30n, + 0xd192e819d6ef5218n, 0xd69906245565a910n, 0xf40e35855771202an, 0x106aa07032bbd1b8n, + 0x19a4c116b8d2d0c8n, 0x1e376c085141ab53n, 0x2748774cdf8eeb99n, 0x34b0bcb5e19b48a8n, + 0x391c0cb3c5c95a63n, 0x4ed8aa4ae3418acbn, 0x5b9cca4f7763e373n, 0x682e6ff3d6b2b8a3n, + 0x748f82ee5defb2fcn, 0x78a5636f43172f60n, 0x84c87814a1f0ab72n, 0x8cc702081a6439ecn, + 0x90befffa23631e28n, 0xa4506cebde82bde9n, 0xbef9a3f7b2c67915n, 0xc67178f2e372532bn, + 0xca273eceea26619cn, 0xd186b8c721c0c207n, 0xeada7dd6cde0eb1en, 0xf57d4f7fee6ed178n, + 0x06f067aa72176fban, 0x0a637dc5a2c898a6n, 0x113f9804bef90daen, 0x1b710b35131c471bn, + 0x28db77f523047d84n, 0x32caab7b40c72493n, 0x3c9ebe0a15c9bebcn, 0x431d67c49c100d4cn, + 0x4cc5d4becb3e42b6n, 0x597f299cfc657e2an, 0x5fcb6fab3ad6faecn, 0x6c44198c4a475817n, +]; + +export function sha512(message) { + const padded = new Uint8Array((((message.length + 17) + 127) >> 7 << 7)); + padded.set(message); + padded[message.length] = 0x80; + const bits = BigInt(message.length) * 8n; + new DataView(padded.buffer).setBigUint64(padded.length - 8, bits, false); + + let h = [0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, + 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n]; + const rotr = (x, n) => ((x >> BigInt(n)) | (x << (64n - BigInt(n)))) & M64; + const view = new DataView(padded.buffer); + const w = new Array(80); + for (let off = 0; off < padded.length; off += 128) { + for (let i = 0; i < 16; i++) w[i] = view.getBigUint64(off + i * 8); + for (let i = 16; i < 80; i++) { + const s0 = rotr(w[i - 15], 1) ^ rotr(w[i - 15], 8) ^ (w[i - 15] >> 7n); + const s1 = rotr(w[i - 2], 19) ^ rotr(w[i - 2], 61) ^ (w[i - 2] >> 6n); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & M64; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 80; i++) { + const S1 = rotr(e, 14) ^ rotr(e, 18) ^ rotr(e, 41); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K512[i] + w[i]) & M64; + const S0 = rotr(a, 28) ^ rotr(a, 34) ^ rotr(a, 39); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) & M64; + hh = g; g = f; f = e; e = (d + t1) & M64; + d = c; c = b; b = a; a = (t1 + t2) & M64; + } + const sum = [a, b, c, d, e, f, g, hh]; + h = h.map((v, i) => (v + sum[i]) & M64); + } + const out = new Uint8Array(64); + for (let i = 0; i < 8; i++) new DataView(out.buffer).setBigUint64(i * 8, h[i], false); + return out; +} + +// --- ChaCha20-Poly1305 AEAD (RFC 8439) -------------------------------------- + +function chachaBlock(key, counter, nonce) { + const state = new Uint32Array(16); + state.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); + const kview = new DataView(key.buffer); + for (let i = 0; i < 8; i++) state[4 + i] = kview.getUint32(i * 4, true); + state[12] = counter >>> 0; + const nview = new DataView(nonce.buffer); + for (let i = 0; i < 3; i++) state[13 + i] = nview.getUint32(i * 4, true); + const x = Uint32Array.from(state); + const qr = (a, b, c, d) => { + x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 16); + x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 12); + x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 8); + x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 7); + }; + for (let i = 0; i < 10; i++) { + qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15); + qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14); + } + const out = new Uint8Array(64); + const view = new DataView(out.buffer); + for (let i = 0; i < 16; i++) view.setUint32(i * 4, (x[i] + state[i]) >>> 0, true); + return out; +} + +function chacha20Xor(key, counter, nonce, data) { + const out = new Uint8Array(data.length); + for (let off = 0; off < data.length; off += 64) { + const stream = chachaBlock(key, counter + (off / 64), nonce); + const n = Math.min(64, data.length - off); + for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ stream[i]; + } + return out; +} + +// Poly1305 over BigInt; correctness over speed, messages here stay small. +function poly1305(key, message) { + const P1305 = (1n << 130n) - 5n; + const r = leBytesToBigInt(key.slice(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn; + const s = leBytesToBigInt(key.slice(16, 32)); + let acc = 0n; + for (let off = 0; off < message.length; off += 16) { + const block = message.slice(off, Math.min(off + 16, message.length)); + acc = ((acc + leBytesToBigInt(block) + (1n << BigInt(block.length * 8))) * r) % P1305; + } + return bigIntToLeBytes((acc + s) & ((1n << 128n) - 1n), 16); +} + +export function aeadEncrypt(key, nonce, plaintext, aad) { + const polyKey = chachaBlock(key, 0, nonce).slice(0, 32); + const ciphertext = chacha20Xor(key, 1, nonce, plaintext); + const le64 = (n) => bigIntToLeBytes(BigInt(n), 8); + const pad = (n) => new Uint8Array((16 - (n % 16)) % 16); + const mac = poly1305(polyKey, concat( + aad, pad(aad.length), ciphertext, pad(ciphertext.length), le64(aad.length), le64(ciphertext.length))); + return concat(ciphertext, mac); +} + +export function aeadDecrypt(key, nonce, sealed, aad) { + if (sealed.length < 16) throw new Error("ciphertext shorter than the Poly1305 tag"); + const ciphertext = sealed.slice(0, sealed.length - 16); + const polyKey = chachaBlock(key, 0, nonce).slice(0, 32); + const le64 = (n) => bigIntToLeBytes(BigInt(n), 8); + const pad = (n) => new Uint8Array((16 - (n % 16)) % 16); + const expect = poly1305(polyKey, concat( + aad, pad(aad.length), ciphertext, pad(ciphertext.length), le64(aad.length), le64(ciphertext.length))); + if (!timingSafeEqual(expect, sealed.slice(sealed.length - 16))) + throw new Error("decryption failed: bad Poly1305 tag"); + return chacha20Xor(key, 1, nonce, ciphertext); +} + +// --- X25519 (RFC 7748) ------------------------------------------------------- + +const P_ED = (1n << 255n) - 19n; + +function mod(value, p = P_ED) { return ((value % p) + p) % p; } + +function powMod(base, exponent, p = P_ED) { + let out = 1n; + base = mod(base, p); + while (exponent > 0n) { + if (exponent & 1n) out = out * base % p; + base = base * base % p; + exponent >>= 1n; + } + return out; +} + +function clampScalar(scalar) { + const k = Uint8Array.from(scalar); + k[0] &= 248; k[31] &= 127; k[31] |= 64; + return k; +} + +function x25519Raw(scalar, u) { + const k = leBytesToBigInt(clampScalar(scalar)); + const x1 = leBytesToBigInt(u) & ((1n << 255n) - 1n); + const a24 = 121665n; + let x2 = 1n, z2 = 0n, x3 = x1, z3 = 1n, swap = 0n; + for (let t = 254n; t >= 0n; t--) { + const kt = (k >> t) & 1n; + swap ^= kt; + if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; } + swap = kt; + const a = mod(x2 + z2), aa = a * a % P_ED; + const b = mod(x2 - z2), bb = b * b % P_ED; + const e = mod(aa - bb); + const c = mod(x3 + z3), d = mod(x3 - z3); + const da = d * a % P_ED, cb = c * b % P_ED; + x3 = mod(da + cb) ** 2n % P_ED; + z3 = x1 * mod(da - cb) ** 2n % P_ED; + x2 = aa * bb % P_ED; + z2 = e * mod(aa + a24 * e) % P_ED; + } + if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; } + return x2 * powMod(z2, P_ED - 2n) % P_ED; +} + +// §2's low-order rejection: a clamped scalar is a multiple of 8, so any +// low-order peer point yields an all-zero shared secret — rejecting the zero +// output rejects all of them. +export function x25519(scalar, peerPublic) { + const shared = bigIntToLeBytes(x25519Raw(scalar, peerPublic), 32); + if (shared.every(b => b === 0)) throw new Error("rejected low-order key agreement point"); + return shared; +} + +export function x25519Base(scalar) { + return bigIntToLeBytes(x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")), 32); +} + +// --- Ed25519 (RFC 8032) ------------------------------------------------------ + +const L_ED = (1n << 252n) + 27742317777372353535851937790883648493n; +const D_ED = mod(-121665n * powMod(121666n, P_ED - 2n)); +const B_ED = { x: 15112221349535400772501151409588531511454012693041857206046113283949847762202n, + y: mod(4n * powMod(5n, P_ED - 2n)) }; +const IDENTITY = { x: 0n, y: 1n, z: 1n, t: 0n }; + +const toProjective = ({ x, y }) => ({ x, y, z: 1n, t: mod(x * y) }); + +function pointAdd(p, q) { + const a = mod(p.y - p.x) * mod(q.y - q.x) % P_ED; + const b = mod(p.y + p.x) * mod(q.y + q.x) % P_ED; + const c = 2n * p.t * q.t % P_ED * D_ED % P_ED; + const d = 2n * p.z * q.z % P_ED; + const e = mod(b - a), f = mod(d - c), g = mod(d + c), h = b + a; + return { x: e * f % P_ED, y: g * h % P_ED, z: f * g % P_ED, t: e * h % P_ED }; +} + +function pointDouble(p) { + const a = p.x * p.x % P_ED; + const b = p.y * p.y % P_ED; + const c = 2n * p.z * p.z % P_ED; + const d = P_ED - a; // a = -1 on this curve, so d = -A + const e = mod(mod(p.x + p.y) ** 2n - a - b); + const g = mod(d + b); + const f = mod(g - c); + const h = mod(d - b); + return { x: e * f % P_ED, y: g * h % P_ED, z: f * g % P_ED, t: e * h % P_ED }; +} + +function scalarMult(scalar, point) { + let result = IDENTITY; + for (let t = 254n; t >= 0n; t--) { + result = pointDouble(result); + if ((scalar >> t) & 1n) result = pointAdd(result, point); + } + return result; +} + +function encodePoint(p) { + const zInv = powMod(p.z, P_ED - 2n); + const x = p.x * zInv % P_ED, y = p.y * zInv % P_ED; + const out = bigIntToLeBytes(y, 32); + out[31] |= Number(x & 1n) << 7; + return out; +} + +function decodePoint(bytes) { + if (bytes.length !== 32) throw new Error("Ed25519 public key must be 32 bytes"); + const sign = bytes[31] >> 7; + const y = leBytesToBigInt(bytes) & ((1n << 255n) - 1n); + if (y >= P_ED) throw new Error("non-canonical Ed25519 public key"); + const u = mod(y * y - 1n), v = mod(D_ED * y * y + 1n); + const v2 = v * v % P_ED, v3 = v2 * v % P_ED, v4 = v2 * v2 % P_ED; + let x = u * v3 % P_ED * powMod(u * v4 % P_ED * v3 % P_ED, (P_ED - 5n) / 8n) % P_ED; + if (mod(v * x % P_ED * x) !== u) { + if (mod(v * x % P_ED * x) === mod(-u)) x = x * powMod(2n, (P_ED - 1n) / 4n) % P_ED; + else throw new Error("not a point on the Ed25519 curve"); + } + if (x === 0n && sign) throw new Error("invalid sign bit on x = 0"); + if (Number(x & 1n) !== sign) x = P_ED - x; + return { x, y }; +} + +function seedToScalar(seed) { + const h = sha512(seed); + return leBytesToBigInt(clampScalar(h.slice(0, 32))); +} + +export function ed25519PublicKey(seed) { + if (seed.length !== 32) throw new Error("identity seed must be 32 bytes"); + return encodePoint(scalarMult(seedToScalar(seed), toProjective(B_ED))); +} + +export function ed25519Sign(seed, message) { + const h = sha512(seed); + const a = leBytesToBigInt(clampScalar(h.slice(0, 32))); + const publicKey = encodePoint(scalarMult(a, toProjective(B_ED))); + const r = leBytesToBigInt(sha512(concat(h.slice(32), message))) % L_ED; + const rEnc = encodePoint(scalarMult(r, toProjective(B_ED))); + const k = leBytesToBigInt(sha512(concat(rEnc, publicKey, message))) % L_ED; + return concat(rEnc, bigIntToLeBytes((r + k * a) % L_ED, 32)); +} + +export function ed25519Verify(publicKey, message, signature) { + try { + const a = toProjective(decodePoint(publicKey)); + const r = toProjective(decodePoint(signature.slice(0, 32))); + const s = leBytesToBigInt(signature.slice(32, 64)); + if (signature.length !== 64 || s >= L_ED) return false; + const k = leBytesToBigInt(sha512(concat(signature.slice(0, 32), publicKey, message))) % L_ED; + const lhs = scalarMult(s, toProjective(B_ED)); + const rhs = pointAdd(scalarMult(k, a), r); + return lhs.x * rhs.z % P_ED === rhs.x * lhs.z % P_ED + && lhs.y * rhs.z % P_ED === rhs.y * lhs.z % P_ED; + } catch { + return false; + } +} + +// --- §2 conversions between the identity key and X25519 ---------------------- + +export function ed25519ToX25519(publicKey) { + const y = leBytesToBigInt(publicKey) & ((1n << 255n) - 1n); + if (y >= P_ED) throw new Error("non-canonical Ed25519 public key"); + if (mod(1n - y) === 0n) throw new Error("identity element has no X25519 image"); + return bigIntToLeBytes(mod(1n + y) * powMod(1n - y, P_ED - 2n) % P_ED, 32); +} + +export function ed25519SeedToX25519(seed) { + return clampScalar(sha512(seed).slice(0, 32)); +} diff --git a/web/js/noise.js b/web/js/noise.js new file mode 100644 index 0000000..bd29f51 --- /dev/null +++ b/web/js/noise.js @@ -0,0 +1,99 @@ +// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics. +// The initiator is anonymous; the responder's static key arrives encrypted in +// message two, which is what server pinning checks. + +import { aeadDecrypt, aeadEncrypt, concat, hkdfSha256, randomBytes, sha256, timingSafeEqual, utf8Bytes, x25519, x25519Base } from "./crypto.js"; + +const PROTOCOL = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name +const PROLOGUE = utf8Bytes("smolmail/1"); + +// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE. +function nonce(n) { + const out = new Uint8Array(12); + new DataView(out.buffer).setBigUint64(4, BigInt(n), true); + return out; +} + +// One direction of the post-handshake transport. The key is unique per +// session, so the counter starting at zero is safe. +class CipherState { + constructor(key) { + this.key = key; + this.counter = 0; + } + + encrypt(plaintext) { + const sealed = aeadEncrypt(this.key, nonce(this.counter), plaintext, new Uint8Array(0)); + this.counter++; + return sealed; + } + + decrypt(sealed) { + const plaintext = aeadDecrypt(this.key, nonce(this.counter), sealed, new Uint8Array(0)); + this.counter++; + return plaintext; + } +} + +export class NxInitiator { + constructor() { + this.h = utf8Bytes(PROTOCOL); + this.ck = this.h.slice(); + this.mixHash(PROLOGUE); + this.key = null; + } + + mixHash(data) { + this.h = sha256(concat(this.h, data)); + } + + mixKey(ikm) { + const okm = hkdfSha256(ikm, this.ck, new Uint8Array(0), 64); + this.ck = okm.slice(0, 32); + this.key = okm.slice(32); + } + + // Message one is just our ephemeral public key. No key is set yet, so the + // empty payload travels in the clear — and is still mixed into h. + // `esk` is pinned only by the test vectors, like the spec's fixed-ephemeral + // envelope. + writeMessage1(esk) { + this.esk = esk ?? randomBytes(32); + this.epk = x25519Base(this.esk); + this.mixHash(this.epk); + this.mixHash(new Uint8Array(0)); + return this.epk; + } + + // Message two: e (plaintext), ee, then the responder's static and the + // (empty) payload as AEAD ciphertexts chained through h. Each MixKey + // restarts the nonce at zero. + readMessage2(message) { + if (message.length !== 32 + 48 + 16) + throw new Error(`unexpected NX message length ${message.length}`); + const re = message.slice(0, 32); + this.mixHash(re); + this.mixKey(x25519(this.esk, re)); + this.serverStatic = this.decryptAndHash(message.slice(32, 80)); + this.mixKey(x25519(this.esk, this.serverStatic)); // es + const payload = this.decryptAndHash(message.slice(80)); + if (payload.length !== 0) throw new Error("unexpected payload in handshake"); + this.handshakeHash = this.h; + // Split(): two transport keys from the final chaining key, zero-length ikm + const okm = hkdfSha256(new Uint8Array(0), this.ck, new Uint8Array(0), 64); + return { send: new CipherState(okm.slice(0, 32)), recv: new CipherState(okm.slice(32)) }; + } + + decryptAndHash(sealed) { + const plaintext = aeadDecrypt(this.key, nonce(0), sealed, this.h); + this.mixHash(sealed); + return plaintext; + } +} + +// §4: a pinned key must match the one the handshake revealed, or everything stops. +export function checkPin(pinned, serverStatic, host) { + if (pinned && !timingSafeEqual(pinned, serverStatic)) + throw new Error(`${host} presented a different key than the one pinned`); + return pinned !== null && pinned !== undefined; +} diff --git a/web/js/proto.js b/web/js/proto.js new file mode 100644 index 0000000..e58445a --- /dev/null +++ b/web/js/proto.js @@ -0,0 +1,407 @@ +// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and +// signed envelopes, body frontmatter, key rotation, and the framed request +// and response bodies of the five operations. + +import { + aeadDecrypt, aeadEncrypt, concat, ed25519PublicKey, ed25519SeedToX25519, + ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, randomBytes, sha256, timingSafeEqual, + utf8Bytes, x25519, x25519Base, +} from "./crypto.js"; +import { NxInitiator } from "./noise.js"; + +export const DEFAULT_PORT = 1961; +export const KEY_LEN = 32, SIG_LEN = 64, CERT_LEN = 136, ID_LEN = 16; +export const MAX_FRAME = 1 << 20, NOISE_PAYLOAD = 65535 - 16, PAD_TO = 1024; +export const ENVELOPE_HEADER = 69, PAYLOAD_HEADER = 45, MAX_CHAIN = 16; +const FRONTMATTER_MAX = 4096, FRONTMATTER_KEYS = 64; + +export const OP = { AUTH: 0x00, RESOLVE: 0x01, SEND: 0x02, FETCH: 0x03, DELETE: 0x04, REGISTER: 0x05 }; +export const STATUS = { 0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user", + 4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large", + 8: "rate limited", 9: "not permitted", 10: "internal error" }; + +export class SmolError extends Error {} + +const LABEL = Object.freeze({ + auth: utf8Bytes("smolmail/1 auth"), seal: utf8Bytes("smolmail/1 seal"), + msg: utf8Bytes("smolmail/1 msg"), id: utf8Bytes("smolmail/1 id"), + rotate: utf8Bytes("smolmail/1 rotate"), +}); + +// --- encoding helpers ------------------------------------------------------- + +const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function b32encode(bytes) { + let out = "", value = 0, bits = 0; + for (const b of bytes) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { bits -= 5; out += B32[(value >>> bits) & 31]; } + } + if (bits) out += B32[(value << (5 - bits)) & 31]; + return out.toLowerCase(); +} + +export function b32decode(text) { + let out = [], value = 0, bits = 0; + for (const ch of text.trim().toUpperCase().replace(/=+$/, "")) { + const idx = B32.indexOf(ch); + if (idx < 0) throw new SmolError(`invalid base32 character '${ch}'`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { bits -= 8; out.push((value >>> bits) & 0xff); } + } + return Uint8Array.from(out); +} + +// §3: the first 20 base32 characters of the identity, in groups of four. +export function fingerprint(identity) { + const s = b32encode(identity).slice(0, 20); + return s.match(/.{4}/g).join(" "); +} + +export const u16BE = (n) => Uint8Array.of((n >> 8) & 0xff, n & 0xff); + +export const u32BE = (n) => Uint8Array.of((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff); + +export const i64BE = (n) => { + const out = new Uint8Array(8); + new DataView(out.buffer).setBigInt64(0, BigInt(n), false); + return out; +}; + +export const nowSeconds = () => Math.floor(Date.now() / 1000); + +// Fail-closed reader; every parse raises rather than reading past the end. +export class Reader { + constructor(buf) { this.buf = buf; this.pos = 0; } + take(n) { + if (n < 0 || this.pos + n > this.buf.length) throw new SmolError("truncated message"); + this.pos += n; + return this.buf.slice(this.pos - n, this.pos); + } + u8() { return this.take(1)[0]; } + u16() { const b = this.take(2); return (b[0] << 8) | b[1]; } + u32() { return new DataView(this.take(4).buffer).getUint32(0); } + i64() { return new DataView(this.take(8).buffer).getBigInt64(0); } + get left() { return this.buf.length - this.pos; } + get done() { return this.pos === this.buf.length; } +} + +// --- identity --------------------------------------------------------------- + +// §2: an Ed25519 keypair with the X25519 agreement keys derived from it. +export function identityFromSeed(seed) { + if (seed.length !== KEY_LEN) throw new SmolError(`identity seed must be ${KEY_LEN} bytes`); + return { seed, publicKey: ed25519PublicKey(seed) }; +} + +export function newIdentity() { + return identityFromSeed(randomBytes(KEY_LEN)); +} + +// --- addressing (§3) -------------------------------------------------------- + +const ADDRESS = /^(?[a-z0-9._-]{1,63})@(?[^/:]+)(?::(?\d+))?$/; + +export function parseAddress(text) { + text = text.trim(); + let identity = null; + if (text.startsWith("smol://")) { + const rest = text.slice("smol://".length); + const slash = rest.lastIndexOf("/"); + if (slash < 0) throw new SmolError(`${text}: smol:// address carries no key`); + identity = b32decode(rest.slice(slash + 1)); + if (identity.length !== KEY_LEN) + throw new SmolError(`${text}: key is ${identity.length} bytes, expected ${KEY_LEN}`); + text = rest.slice(0, slash); + } + const m = ADDRESS.exec(text.toLowerCase()); + if (!m) throw new SmolError(`'${text}' is not a valid address`); + const { user, host } = m.groups; + if ("._-".includes(user[0]) || "._-".includes(user.at(-1))) + throw new SmolError(`${user} may not begin or end with a separator`); + const port = m.groups.port ? Number(m.groups.port) : DEFAULT_PORT; + return { + user, host, port, identity, + get short() { return `${user}@${host}${port === DEFAULT_PORT ? "" : ":" + port}`; }, + uri: (key) => `smol://${user}@${host}${port === DEFAULT_PORT ? "" : ":" + port}/${b32encode(key)}`, + }; +} + +// --- message format (§5) ---------------------------------------------------- + +export function messageId(envelope) { + return sha256(concat(LABEL.id, envelope)).slice(0, ID_LEN); +} + +// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender +// cannot decrypt what they sent; opts.esk exists only so tests can pin it. +export function seal(identity, recipient, body, when = nowSeconds(), opts = {}) { + const esk = opts.esk ?? randomBytes(KEY_LEN); + const epk = x25519Base(esk); + const key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)), concat(epk, recipient), LABEL.seal); + const header = concat(Uint8Array.of(1), identity.publicKey, i64BE(when), u32BE(body.length)); + let plaintext = concat(header, body, + ed25519Sign(identity.seed, concat(LABEL.msg, recipient, epk, header, body))); + if (opts.pad !== false) + plaintext = concat(plaintext, new Uint8Array((PAD_TO - plaintext.length % PAD_TO) % PAD_TO)); + const aad = concat(utf8Bytes("SMOL"), Uint8Array.of(1), recipient, epk); + return concat(aad, aeadEncrypt(key, new Uint8Array(12), plaintext, aad)); +} + +// Inverse of seal(); throws unless the signature and the recipient both check +// out. `identities` may include retired keys, per §7. +export function unseal(identities, envelope) { + if (envelope.length < ENVELOPE_HEADER + 16) throw new SmolError("envelope too short"); + if (utf8Bytes("SMOL").some((b, i) => b !== envelope[i])) throw new SmolError("not a Smol Mail envelope"); + if (envelope[4] !== 1) throw new SmolError(`unsupported envelope version ${envelope[4]}`); + const to = envelope.slice(5, 37), epk = envelope.slice(37, 69), sealed = envelope.slice(69); + const me = identities.find(i => timingSafeEqual(i.publicKey, to)); + if (!me) throw new SmolError(`addressed to ${b32encode(to).slice(0, 16)}…, not one of our keys`); + const key = hkdfSha256(x25519(ed25519SeedToX25519(me.seed), epk), concat(epk, to), LABEL.seal); + let plaintext; + try { + plaintext = aeadDecrypt(key, new Uint8Array(12), sealed, envelope.slice(0, ENVELOPE_HEADER)); + } catch { + throw new SmolError("decryption failed: wrong key or corrupt envelope"); + } + const r = new Reader(plaintext); + if (r.u8() !== 1) throw new SmolError("unsupported payload version"); + const sender = r.take(KEY_LEN), when = r.i64(), bodyLen = r.u32(); + if (bodyLen > r.left) throw new SmolError("payload body length exceeds the payload"); + const body = r.take(bodyLen), signature = r.take(SIG_LEN); // trailing bytes are padding + if (!ed25519Verify(sender, concat(LABEL.msg, to, epk, plaintext.slice(0, PAYLOAD_HEADER), body), signature)) + throw new SmolError("signature does not verify"); + return { sender, time: Number(when), body, id: messageId(envelope) }; +} + +// --- body frontmatter (§5.5) ------------------------------------------------ + +const FM_KEY = /^[A-Za-z0-9-]{1,64}$/; + +// A flat `Key: value` block, deliberately not YAML. Any malformed line +// invalidates the whole block, which is then returned as ordinary body text: +// frontmatter fails closed toward display, never toward silent discard. +export function parseFrontmatter(text) { + if (!text.startsWith("---\n")) return { fields: {}, body: text }; + const lines = text.split("\n"); + const close = lines.indexOf("---", 1); + if (close < 0) return { fields: {}, body: text }; + const block = lines.slice(1, close), rest = lines.slice(close + 1).join("\n"); + const encoded = block.map(line => utf8Bytes(line).length + 1); + if (block.length > FRONTMATTER_KEYS || encoded.reduce((a, b) => a + b, 0) > FRONTMATTER_MAX) + return { fields: {}, body: text }; + const fields = {}; + for (let i = 0; i < block.length; i++) { + const line = block[i], colon = line.indexOf(":"); + if (colon < 0 || !FM_KEY.test(line.slice(0, colon))) return { fields: {}, body: text }; + const key = line.slice(0, colon); + if (!(key in fields)) fields[key] = line.slice(colon + 1).trim(); // first occurrence wins + } + return { fields, body: rest }; +} + +// Emit a block only when needed, including to escape a body that genuinely +// begins with `---` (§5.5). +export function buildFrontmatter(fields, body) { + if (!fields.length && !body.startsWith("---\n")) return body; + return `---\n${fields.map(([k, v]) => `${k}: ${v}\n`).join("")}---\n${body}`; +} + +// --- key rotation (§7) ------------------------------------------------------- + +export function makeCert(oldIdentity, newSeed, when = nowSeconds()) { + const newPub = ed25519PublicKey(newSeed), time = i64BE(when); + return concat(oldIdentity.publicKey, newPub, time, + ed25519Sign(oldIdentity.seed, concat(LABEL.rotate, oldIdentity.publicKey, newPub, time))); +} + +// Accept a key change only when a signed chain leads from the key we hold to +// the one the server now returns (§7). +export function walkChain(pinned, current, chain) { + const same = (a, b) => timingSafeEqual(a, b); + if (same(pinned, current)) return true; + if (!chain.length || chain.length > MAX_CHAIN) return false; + let key = pinned, started = false; + for (const cert of chain) { + const old = cert.slice(0, 32), next = cert.slice(32, 64), when = cert.slice(64, 72), sig = cert.slice(72); + if (!started) { + if (!same(old, key)) continue; // a link predating the key we hold + started = true; + } else if (!same(old, key)) { + return false; // the chain is not continuous + } + if (!ed25519Verify(old, concat(LABEL.rotate, old, next, when), sig)) return false; + key = next; + } + return started && same(key, current); +} + +// --- framing and operations (§4, §6) ----------------------------------------- + +// Reassembles a byte stream (WebSocket or TCP) into exact-length reads. +class ByteStream { + constructor(stream) { + this.chunks = []; + this.length = 0; + this.closed = null; + this.waiters = []; + stream.onData = (data) => { this.chunks.push(data); this.length += data.length; this.wake(); }; + stream.onClose = () => this.fail(new SmolError("server closed the connection")); + } + + wake() { + this.waiters = this.waiters.filter(w => { + if (this.closed) { w.reject(this.closed); return false; } + if (this.length >= w.need) { w.resolve(); return false; } + return true; + }); + } + + fail(error) { + this.closed = error; + this.waiters.forEach(w => w.reject(error)); + this.waiters = []; + } + + async readExact(n) { + if (this.closed) throw this.closed; + if (this.length < n) { + await new Promise((resolve, reject) => this.waiters.push({ need: n, resolve, reject })); + if (this.closed) throw this.closed; + } + const out = new Uint8Array(n); + let off = 0; + while (off < n) { + const chunk = this.chunks[0]; + const take = Math.min(chunk.length, n - off); + out.set(off ? chunk.slice(0, take) : chunk.subarray(0, take), off); + if (take === chunk.length) this.chunks.shift(); + else this.chunks[0] = chunk.subarray(take); + off += take; + this.length -= take; + } + return out; + } +} + +// One Noise session: application frames split across u16-prefixed Noise +// messages, requests and responses as in §6.1. +export class Session { + constructor(wire, stream, send, recv) { + this.wire = wire; + this.stream = stream; + this.send = send; + this.recv = recv; + } + + async readNoise() { + const head = await this.wire.readExact(2); + const length = (head[0] << 8) | head[1]; + if (length < 16) throw new SmolError(`server sent a ${length}-byte Noise message`); + return this.recv.decrypt(await this.wire.readExact(length)); + } + + async call(op, body = new Uint8Array(0)) { + const frame = concat(u32BE(1 + body.length), Uint8Array.of(op), body); + if (frame.length > MAX_FRAME + 4) throw new SmolError("request exceeds the maximum frame size"); + for (let off = 0; off < frame.length; off += NOISE_PAYLOAD) { + const packet = this.send.encrypt(frame.slice(off, off + NOISE_PAYLOAD)); + this.stream.send(concat(u16BE(packet.length), packet)); + } + let length = -1; + let have = new Uint8Array(0); + while (length < 0 || have.length < 4 + length) { + have = concat(have, await this.readNoise()); + if (length < 0 && have.length >= 4) { + length = new DataView(have.buffer).getUint32(0); + if (length < 1 || length > MAX_FRAME) + throw new SmolError(`server sent a frame of length ${length}`); + } + } + const payload = have.slice(4, 4 + length); + return { status: payload[1], body: payload.slice(2) }; // op echo, status, body (§6.1) + } +} + +// Handshake plus §4 pinning. Returns the session, the server's static key as +// revealed by the handshake, and whether that key was already pinned. +export async function openSession(stream, host, pinned = null) { + const wire = new ByteStream(stream); + const nx = new NxInitiator(); + const m1 = nx.writeMessage1(); + stream.send(concat(u16BE(m1.length), m1)); + const head = await wire.readExact(2); + const ciphers = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1])); + if (pinned && !timingSafeEqual(pinned, nx.serverStatic)) + throw new SmolError(`${host} presented a different key than the one pinned\n` + + ` pinned: ${b32encode(pinned)}\n presented: ${b32encode(nx.serverStatic)}`); + return { + session: new Session(wire, stream, ciphers.send, ciphers.recv), + serverStatic: nx.serverStatic, + pinned: pinned !== null, + handshakeHash: nx.handshakeHash, + }; +} + +export function expectOk(status, what) { + if (status !== 0) throw new SmolError(`${what} failed: ${STATUS[status] ?? status} (${status})`); +} + +// §4 session authentication: sign the handshake hash, which binds the +// signature to this session's server ephemeral and cannot be replayed. +export async function authenticate(session, handshakeHash, username, identity) { + const name = utf8Bytes(username); + if (name.length > 255) throw new SmolError("username too long"); + const body = concat(Uint8Array.of(name.length), name, identity.publicKey, + ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash))); + expectOk((await session.call(OP.AUTH, body)).status, "authentication"); +} + +// RESOLVE, returning the current key and its rotation chain (§6.1). +export async function resolveOp(session, user) { + const name = utf8Bytes(user); + if (name.length > 255) throw new SmolError("username too long"); + const { status, body } = await session.call(OP.RESOLVE, concat(Uint8Array.of(name.length), name)); + expectOk(status, `resolving ${user}`); + const r = new Reader(body); + return { identity: r.take(KEY_LEN), chain: Array.from({ length: r.u8() }, () => r.take(CERT_LEN)) }; +} + +export async function sendOp(session, envelope) { + const { status, body } = await session.call(OP.SEND, envelope); + expectOk(status, "sending"); + return body.length === ID_LEN ? body : messageId(envelope); +} + +export async function fetchOp(session) { + const { status, body } = await session.call(OP.FETCH); + expectOk(status, "fetching"); + const r = new Reader(body); + return Array.from({ length: r.u16() }, () => { + const id = r.take(ID_LEN), receivedAt = Number(r.i64()); + return { id, receivedAt, envelope: r.take(r.u32()) }; + }); +} + +export async function deleteOp(session, ids) { + if (ids.length > 0xffff) throw new SmolError("too many ids for one DELETE"); + const body = concat(u16BE(ids.length), ...ids); + const { status, body: reply } = await session.call(OP.DELETE, body); + expectOk(status, "acknowledging"); + return new DataView(reply.buffer).getUint16(0); +} + +export async function registerOp(session, { username, identity, token = "", cert = null }) { + const name = utf8Bytes(username); + const tokenBytes = utf8Bytes(token); + if (name.length > 255 || tokenBytes.length > 255 || cert && cert.length > 255) + throw new SmolError("REGISTER field too long"); + const body = concat(Uint8Array.of(name.length), name, identity.publicKey, + Uint8Array.of(tokenBytes.length), tokenBytes, + Uint8Array.of(cert ? cert.length : 0), cert ?? new Uint8Array(0)); + const { status } = await session.call(OP.REGISTER, body); + expectOk(status, `registering ${username}`); +} diff --git a/web/js/store.js b/web/js/store.js new file mode 100644 index 0000000..60eb671 --- /dev/null +++ b/web/js/store.js @@ -0,0 +1,191 @@ +// Browser state: identity, pins, contacts and read markers in localStorage; +// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest +// is plaintext (the seed excepted — it is the user's browser profile). + +import { hex, unhex } from "./crypto.js"; +import { b32decode, b32encode, identityFromSeed } from "./proto.js"; + +const KEY = "gsmol"; + +function load() { + try { + return JSON.parse(localStorage.getItem(KEY) || "{}"); + } catch { + return {}; + } +} + +function save(state) { + localStorage.setItem(KEY, JSON.stringify(state)); +} + +function update(fn) { + const state = load(); + const next = fn(state); + save(next ?? state); +} + +// --- identity ---------------------------------------------------------------- + +export function seed() { + const raw = load().seed; + return raw ? unhex(raw) : null; +} + +export function identity() { + const raw = seed(); + return raw ? identityFromSeed(raw) : null; +} + +export function setIdentity(newSeed) { + if (seed()) throw new Error("an identity already exists; rotate it instead"); + update(state => { state.seed = hex(newSeed); }); +} + +// Rotation (§7): the old seed is retained, since mail sealed to a superseded +// key is readable with nothing else. +export function rotateIdentity(newSeed) { + const old = seed(); + if (!old) throw new Error("no identity to rotate"); + update(state => { + state.retired = [...(state.retired || []), { seed: hex(old), at: Date.now() }]; + state.seed = hex(newSeed); + }); +} + +// §7: seeds rotated away from are retained, since mail sealed to a superseded +// key is readable with nothing else. +export function identities() { + const me = identity(); + if (!me) return []; + const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s))); + return [me, ...retired]; +} + +// --- account and server pins --------------------------------------------------- + +export function account() { + const a = load().account; + return a ? { ...a } : null; +} + +export function setAccount(addr) { + update(state => { + state.account = { user: addr.user, host: addr.host, port: addr.port }; + }); +} + +export function serverPin(host) { + const raw = (load().servers || {})[host]; + return raw ? b32decode(raw) : null; +} + +export function pinServer(host, keyBytes) { + update(state => { + state.servers = { ...(state.servers || {}), [host]: b32encode(keyBytes) }; + }); +} + +export function unpinServer(host) { + update(state => { + delete (state.servers || {})[host]; + }); +} + +export function allPins() { + return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key })); +} + +// --- contacts ------------------------------------------------------------------ + +export function contact(address) { + const c = (load().contacts || {})[address]; + return c ? { key: b32decode(c.key), verified: c.verified } : null; +} + +export function saveContact(address, keyBytes, verified) { + update(state => { + state.contacts = { + ...(state.contacts || {}), + [address]: { key: b32encode(keyBytes), verified, seenAt: Date.now() }, + }; + }); +} + +export function addressForKey(keyBytes) { + return Object.entries(load().contacts || {}) + .find(([, c]) => c.key === b32encode(keyBytes))?.[0] ?? null; +} + +export function allContacts() { + return Object.entries(load().contacts || {}) + .map(([address, c]) => ({ address, key: c.key, verified: c.verified })); +} + +// --- read markers --------------------------------------------------------------- + +export function markRead(idHex) { + update(state => { + state.read = { ...(state.read || {}), [idHex]: true }; + }); +} + +export function isRead(idHex) { + return Boolean((load().read || {})[idHex]); +} + +// --- sealed mail (IndexedDB) ------------------------------------------------------ + +const DB_NAME = "gsmol", DB_VERSION = 1; + +function openDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + for (const box of ["inbox", "sent"]) { + if (!db.objectStoreNames.contains(box)) db.createObjectStore(box, { keyPath: "id" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function tx(db, box, mode, fn) { + return new Promise((resolve, reject) => { + const request = fn(db.transaction(box, mode).objectStore(box)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +export async function storeMessage(box, record) { + const db = await openDb(); + await tx(db, box, "readwrite", store => store.put(record)); + db.close(); +} + +// Returns null when the id already exists, so fetch can leave server state alone. +export async function storeIfNew(box, record) { + const db = await openDb(); + const existing = await tx(db, box, "readonly", store => store.get(record.id)); + if (existing) { db.close(); return null; } + await tx(db, box, "readwrite", store => store.put(record)); + db.close(); + return record; +} + +export async function listMessages(box) { + const db = await openDb(); + const rows = await tx(db, box, "readonly", store => store.getAll()); + db.close(); + return rows.sort((a, b) => (a.receivedAt ?? a.sentAt) - (b.receivedAt ?? b.sentAt)); +} + +export async function getMessage(box, id) { + const db = await openDb(); + const row = await tx(db, box, "readonly", store => store.get(id)); + db.close(); + return row ?? null; +} diff --git a/web/js/transport.js b/web/js/transport.js new file mode 100644 index 0000000..7d0e7b0 --- /dev/null +++ b/web/js/transport.js @@ -0,0 +1,22 @@ +// The byte pipe to a smolmaild server. Browsers cannot open TCP sockets, so +// bytes travel through the local bridge's WebSocket relay unchanged; the +// Noise session lives in this page either way. + +const BRIDGE = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`; + +export function connectStream(host, port) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`${BRIDGE}/tcp/${host}/${port}`); + socket.binaryType = "arraybuffer"; + const stream = { + send: bytes => socket.send(bytes), + close: () => socket.close(), + onData: null, + onClose: null, + }; + socket.onopen = () => resolve(stream); + socket.onerror = () => reject(new Error(`cannot reach the bridge at ${BRIDGE}`)); + socket.onmessage = event => stream.onData?.(new Uint8Array(event.data)); + socket.onclose = () => stream.onClose?.(); + }); +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..809d25f --- /dev/null +++ b/web/style.css @@ -0,0 +1,147 @@ +:root { + --bg: #fafaf7; + --fg: #26251f; + --muted: #7a766b; + --line: #dcd8cc; + --accent: #3a5a40; + --warn: #9c3f2c; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + display: flex; + flex-direction: column; + height: 100vh; + font: 15px/1.5 system-ui, sans-serif; + color: var(--fg); + background: var(--bg); +} + +button { + font: inherit; + padding: 4px 14px; + border: 1px solid var(--line); + border-radius: 4px; + background: #fff; + cursor: pointer; +} +button:hover { border-color: var(--accent); color: var(--accent); } +button.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +header { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 16px; + border-bottom: 1px solid var(--line); +} +header h1 { font-size: 16px; margin: 0; font-weight: 600; } +.actions { margin-left: auto; display: flex; gap: 8px; } +.account { font-family: var(--mono); font-size: 13px; color: var(--muted); } + +main { flex: 1; min-height: 0; display: flex; } + +.setup { flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; } +.setup h2 { margin: 0; } +.setup .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; } + +input, textarea { + font: inherit; + font-family: var(--mono); + font-size: 13px; + padding: 4px 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: #fff; +} + +.folders { + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + min-width: 110px; +} +.folders button { + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + text-align: left; + padding: 10px 14px; + background: none; +} + +.list { + min-width: 280px; + max-width: 380px; + overflow-y: auto; + border-right: 1px solid var(--line); +} +.item { padding: 10px 14px; border-bottom: 1px solid var(--line); cursor: pointer; } +.item:hover { background: #f1efe6; } +.item.selected { background: #ece9dd; } +.item .who { font-weight: 600; } +.item .who.unread::before { content: "● "; color: var(--accent); } +.item .subject { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item .when { float: right; font-size: 12px; color: var(--muted); } + +.view { flex: 1; overflow-y: auto; padding: 20px 24px; } +.placeholder { color: var(--muted); font-style: italic; } +.view h2 { margin: 0 0 2px; font-size: 17px; } +.view .meta { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-bottom: 16px; } +.view .fields { font-family: var(--mono); font-size: 12px; color: var(--muted); } +.view pre { + white-space: pre-wrap; + font-family: inherit; + margin: 12px 0 0; + border-top: 1px solid var(--line); + padding-top: 12px; +} +.verified { color: var(--accent); font-size: 12px; } +.warn { color: var(--warn); } + +dialog { + border: 1px solid var(--line); + border-radius: 8px; + padding: 20px; + min-width: 460px; + max-width: 640px; +} +dialog::backdrop { background: rgba(0, 0, 0, 0.25); } +#compose form { display: flex; flex-direction: column; gap: 8px; } +#compose label { display: flex; gap: 8px; align-items: center; } +#compose input { flex: 1; } +#compose textarea { font-family: inherit; } +#compose footer { display: flex; gap: 8px; justify-content: flex-end; align-items: center; } +.error { color: var(--warn); font-size: 13px; margin-right: auto; } + +#settings section { margin-bottom: 20px; } +#settings form { display: flex; gap: 8px; margin: 8px 0; } +#settings form input { flex: 1; } +#settings h3 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); } +#settings footer { display: flex; justify-content: flex-end; } +.hint { font-size: 12px; color: var(--muted); margin: 4px 0; } + +.kv { list-style: none; padding: 0; margin: 4px 0; font-family: var(--mono); font-size: 12px; } +.kv li { display: flex; gap: 10px; padding: 3px 0; align-items: baseline; } +.kv li button { padding: 0 8px; font-size: 11px; } +.kv .key { word-break: break-all; color: var(--muted); flex: 1; } + +#identity-card { font-family: var(--mono); font-size: 12px; } +#identity-card div { margin: 4px 0; word-break: break-all; } +#identity-card .seed { border: 1px dashed var(--warn); padding: 6px; } + +.status { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 6px 16px; + font-size: 13px; + border-top: 1px solid var(--line); + background: #fff; + min-height: 30px; + white-space: pre-wrap; +}