From d6fa0976266e09ae102d97ed1e8fd1eab80ff074 Mon Sep 17 00:00:00 2001
From: randogoth
Date: Sun, 27 Sep 2026 11:14:25 +0300
Subject: [PATCH] feat: adopt smolmail protocol 1.1 with accept tokens and
leave-mail-on-server
---
README.md | 23 +--
test/client.mjs | 183 ++++++++++++++++----
test/e2e.py | 38 ++--
test/gen_vectors.py | 20 ++-
test/vectors.json | 12 +-
test/vectors.test.mjs | 45 +++--
web/index.html | 32 +++-
web/js/app.js | 393 +++++++++++++++++++++++++++++++++++-------
web/js/proto.js | 123 +++++++++----
web/js/store.js | 266 +++++++++++++++++++++-------
web/style.css | 8 +
11 files changed, 911 insertions(+), 232 deletions(-)
diff --git a/README.md b/README.md
index 7333a5b..eb4cfe4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 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.
+An HTML5 browser client for [Smol Mail](../smolmail), the minimalist end-to-end encrypted mail protocol: one 32-byte master secret, five operations, Noise_NX transport, sealed and signed messages, and accept tokens that sort correspondence from cold mail. Everything the reference CLI client does, in a browser tab.
## Why there is a bridge
@@ -18,7 +18,7 @@ browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild
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.
+Then open http://127.0.0.1:8096. First use: create an identity, back up the master, pin your server's public key (from the operator, through a trusted channel), register an address, and fetch.
For a server on a non-default port, start the bridge with `uv run bridge.py --allow-port `.
@@ -64,11 +64,13 @@ The service runs under `DynamicUser`, restarts on failure, and — like the stan
## 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.
+- **Identity** (§2): one 32-byte master secret; create or restore. Every rotation index's signing seed and the accept key are derived from it with HKDF, so the X25519 agreement keys and every superseded identity are re-derivable rather than archived. The master lives in localStorage, mail stays sealed in IndexedDB — the browser profile is the trust boundary.
- **Addressing** (§3): short `user@host` and self-certifying `smol://` addresses, base32 fingerprints.
-- **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching require a pinned server.
-- **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch verifies id and signature before acknowledging — anything unreadable stays on the server. Outgoing mail carries a signed `Reply-To` frontmatter field with the sender's full `smol://` address (an "anonymous" checkbox omits it), so a first-time recipient can name the sender and answer; on receipt such a claim binds only when its key matches the message's signer, and never over an address already pinned to a different key.
-- **Rotation** (§7): rotate with a signed certificate; contacts accept the change from the chain; superseded seeds are kept, since mail sealed to them is readable with nothing else; key changes without a chain are surfaced, never applied silently.
+- **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching require a pinned server; `REGISTER` carries a proof-of-possession signature bound to the server's own key, so a binding cannot be replayed elsewhere.
+- **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch pages forward by cursor and verifies id and signature before acknowledging — anything unreadable stays on the server. Outgoing mail carries a signed `Reply-To` frontmatter field with the sender's full `smol://` address (an "anonymous" checkbox omits it), so a first-time recipient can name the sender and answer; on receipt such a claim binds only when its key matches the message's signer, and never over an address already pinned to a different key.
+- **Accept tokens** (§5.8): mail from an accepted correspondent lands in the inbox's main tier; everything else lands in a separate "requests" tier. Accepting or blocking a contact pushes the changed token set to the server immediately; a learned token is attached automatically to outgoing mail once a correspondent's own token has been received.
+- **Leave mail on server**: a settings toggle that has fetch page forward by cursor without acknowledging, so mail stays server-side until deleted from the reader — off by default, which acknowledges (and so deletes) fetched mail immediately, as before.
+- **Rotation** (§7): rotate to the next index's derived key with a certificate both the old and new keys sign, binding the change to this username; contacts accept it from the chain; every superseded key stays derivable from the master, since mail sealed to it is readable with nothing else; key changes without a chain are surfaced, never applied silently.
## Layout
@@ -91,14 +93,15 @@ The crypto is pure JavaScript rather than WebCrypto so the exact same modules ru
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.
+1. `test/gen_vectors.py` regenerates `test/vectors.json` from the reference stack (hashlib/hmac, PyNaCl, noiseprotocol): hashes, AEAD, key agreement, signatures, the §2 conversions, master-derived rotation seeds and accept tokens, a fixed-ephemeral envelope, and a deterministic Noise_NX transcript with internal states.
+2. `test/vectors.test.mjs` checks the JS implementation byte for byte against those vectors, plus protocol cases: frontmatter (case-insensitive keys), addresses, and rotation chains bound to a username (accepted, broken, missing link, oversize, wrong username).
+3. `test/e2e.py` runs a live `smolmaild.py` and drives the reference client (Alice) against this client's JS stack (Bob, headless via `test/client.mjs`): registration, unsolicited mail landing in the requests tier, accepting a correspondent and the accept-token round trip that follows, fetch with acknowledgment, and a rotation whose chain the reference client walks. Bob's reply travels through the bridge's WebSocket relay — the exact byte path the browser takes.
What is not verified here: clicking through the actual browser UI. `devbox run serve` and a manual pass remain the last step; the logic under every button is what the tests exercise.
## Notes and limits
-- The seed sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. The bridge serves a strict Content-Security-Policy (`default-src 'self'`, no inline script or style, no foreign origin) to raise that bar, but it is a mitigation and not a fix. No server push or notifications in v1 (SPEC.md §13); click fetch.
+- The master sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. The bridge serves a strict Content-Security-Policy (`default-src 'self'`, no inline script or style, no foreign origin) to raise that bar, but it is a mitigation and not a fix. No server push or notifications; click fetch.
- Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The UI surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence.
+- The requests tier is a quota boundary the server enforces, not a filter: it does not refuse anyone's mail outright, which stays the reader's decision after decryption (§5.8).
- The bridge binds to 127.0.0.1 by default and has no TLS of its own. Serving it beyond localhost directly is possible but unadvised: the relay is plaintext-agnostic (the traffic is already end-to-end encrypted, but the bridge would relay anyone's bytes to allowlisted ports). The NixOS module's `nginx.enable` puts a TLS-terminating reverse proxy in front instead, which is the deployment path "Deploy" above documents.
diff --git a/test/client.mjs b/test/client.mjs
index c443a91..87d5a74 100644
--- a/test/client.mjs
+++ b/test/client.mjs
@@ -5,8 +5,8 @@
// 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, accept, block, 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";
@@ -83,10 +83,43 @@ async function open(host, port, { requirePin }) {
return opened;
}
+// §2: the identity at the state's current rotation index, plus every earlier
+// one — mail sealed to a superseded key is readable with nothing else.
+function currentIdentity(state) {
+ return proto.identityFromSeed(proto.identitySeed(unhex(state.master), state.rotations ?? 0));
+}
+
function identities(state) {
- const current = proto.identityFromSeed(unhex(state.seed));
- const retired = (state.retired || []).map(s => proto.identityFromSeed(unhex(s)));
- return [current, ...retired];
+ const master = unhex(state.master);
+ const out = [];
+ for (let n = state.rotations ?? 0; n >= 0; n--) out.push(proto.identityFromSeed(proto.identitySeed(master, n)));
+ return out;
+}
+
+function cursor(state) {
+ return [state.afterTime ?? 0, state.afterId ? unhex(state.afterId) : new Uint8Array(proto.ID_LEN)];
+}
+
+// §4/§5.8: the accept tokens to push with AUTH, and whether to push at all —
+// a client that cannot vouch for its own set (freshly restored) must not
+// replace the server's with an incomplete one.
+function tokenSet(state) {
+ if (state.syncOk === false) return { sync: 0, tokens: [] };
+ const accepted = Object.entries(state.accepted || {}).filter(([, a]) => a.active)
+ .sort(([a], [b]) => a.localeCompare(b));
+ const master = unhex(state.master);
+ return { sync: 1, tokens: accepted.map(([, a]) => proto.tokenFor(master, b32decode(a.identity))) };
+}
+
+function addressOfSigner(state, sender, replyToField) {
+ const known = Object.entries(state.contacts || {}).find(([, c]) => c.key === b32encode(sender))?.[0];
+ if (known) return known;
+ if (!replyToField) return null;
+ try {
+ const parsed = proto.parseAddress(replyToField);
+ if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short;
+ } catch { /* malformed claim */ }
+ return null;
}
// --- commands -------------------------------------------------------------------
@@ -94,9 +127,11 @@ function identities(state) {
const commands = {
keygen() {
const state = load();
- state.seed = hex(cryptoRandom(32));
+ state.master = hex(cryptoRandom(32));
+ state.rotations = 0;
+ state.syncOk = true;
save(state);
- const me = proto.identityFromSeed(unhex(state.seed));
+ const me = currentIdentity(state);
console.log(`public key: ${b32encode(me.publicKey)}`);
console.log(`fingerprint: ${fingerprint(me.publicKey)}`);
},
@@ -112,11 +147,11 @@ const commands = {
async register() {
const state = load();
- const me = proto.identityFromSeed(unhex(state.seed));
+ const me = currentIdentity(state);
const addr = proto.parseAddress(rest[0]);
const opened = await open(addr.host, addr.port, { requirePin: true });
try {
- await proto.registerOp(opened.session, {
+ await proto.registerOp(opened.session, opened.serverStatic, {
username: addr.user, identity: me, token: parseOption("--token") || "",
});
} finally {
@@ -147,7 +182,7 @@ const commands = {
return;
}
if (timingSafeEqual(b32decode(known.key), current)) return console.log(`${addr.short}: unchanged`);
- if (proto.walkChain(b32decode(known.key), current, chain)) {
+ if (proto.walkChain(addr.user, 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`);
@@ -170,13 +205,44 @@ const commands = {
contacts() {
const state = load();
- for (const [address, c] of Object.entries(state.contacts || {}))
- console.log(`${address} ${c.key} ${c.verified ? "verified" : "tofu"}`);
+ for (const [address, c] of Object.entries(state.contacts || {})) {
+ const accepted = state.accepted?.[address];
+ const tag = accepted ? (accepted.active ? "accepted" : "blocked") : "";
+ console.log(`${address} ${c.key} ${c.verified ? "verified" : "tofu"} ${tag}`.trimEnd());
+ }
+ },
+
+ // §5.8: admit a contact to the main tier; their token travels in our next
+ // message to them. Pushed to the server right away.
+ async accept() {
+ const state = load();
+ const address = rest[0];
+ const contact = state.contacts?.[address];
+ if (!contact) throw new proto.SmolError(`no key for ${address} yet`);
+ state.accepted = { ...(state.accepted || {}),
+ [address]: { identity: state.accepted?.[address]?.identity ?? contact.key, active: true } };
+ state.syncOk = true;
+ save(state);
+ const held = await pushTokens(state);
+ console.log(`accepted ${address}; server now holds ${held} accept token(s)`);
+ },
+
+ // §5.8: withdraw a contact's accept token; their mail lands in requests
+ // from their next message on.
+ async block() {
+ const state = load();
+ const address = rest[0];
+ if (!state.accepted?.[address]) throw new proto.SmolError(`${address} was never accepted`);
+ state.accepted[address] = { ...state.accepted[address], active: false };
+ save(state);
+ const held = await pushTokens(state);
+ console.log(`blocked ${address}; server now holds ${held} accept token(s)`);
},
async send() {
const state = load();
- const me = proto.identityFromSeed(unhex(state.seed));
+ const me = currentIdentity(state);
+ const master = unhex(state.master);
const addr = proto.parseAddress(rest[0]);
let recipient;
if (addr.identity) {
@@ -204,11 +270,17 @@ const commands = {
if (state.account && !rest.includes("--anonymous"))
fields.push(["Reply-To", proto.parseAddress(`${state.account.user}@${state.account.host}` +
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)).uri(me.publicKey)]);
+ // §5.8: hand an accepted correspondent the token for our own mailbox.
+ const accepted = state.accepted?.[addr.short];
+ if (accepted?.active) fields.push(["Accept", b32encode(proto.tokenFor(master, b32decode(accepted.identity)))]);
const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n"));
const envelope = proto.seal(me, recipient, body);
+ // §5.8: our token for their mailbox, if they have given us one.
+ const held = state.tokens?.[addr.short];
+ const mac = held ? proto.acceptMac(b32decode(held.token), proto.messageId(envelope)) : null;
const opened = await open(addr.host, addr.port, { requirePin: false });
try {
- await proto.sendOp(opened.session, envelope);
+ await proto.sendOp(opened.session, envelope, mac);
} finally {
opened.session.stream.close();
}
@@ -217,29 +289,37 @@ const commands = {
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}`);
+ console.log(`sent ${hex(proto.messageId(envelope)).slice(0, 16)} to ${addr.short}` +
+ (mac ? " (accepted)" : ""));
},
async fetch() {
const state = load();
- const me = proto.identityFromSeed(unhex(state.seed));
+ const me = currentIdentity(state);
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
const ids = identities(state);
+ const keep = rest.includes("--keep");
+ if (rest.includes("--reset")) { state.afterTime = 0; state.afterId = hex(new Uint8Array(proto.ID_LEN)); }
+ let [afterTime, afterId] = keep ? cursor(state) : [0, new Uint8Array(proto.ID_LEN)];
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);
+ const { sync, tokens } = tokenSet(state);
+ await proto.authenticate(opened.session, opened.handshakeHash, account.user, me, { sync, tokens });
for (;;) {
- const records = await proto.fetchOp(opened.session);
+ const records = await proto.fetchOp(opened.session, afterTime, afterId);
if (!records.length) break;
const acked = [];
for (const record of records) {
total++;
+ afterTime = record.receivedAt;
+ afterId = record.id;
+ let unsealed;
try {
if (!timingSafeEqual(proto.messageId(record.envelope), record.id))
throw new proto.SmolError("id does not match the envelope");
- proto.unseal(ids, record.envelope);
+ unsealed = proto.unseal(ids, record.envelope);
} catch (err) {
rejected++;
console.error(`warning: ${hex(record.id)}: ${err.message}; left on server`);
@@ -249,25 +329,45 @@ const commands = {
if (!state.inbox.some(m => m.id === hex(record.id))) {
state.inbox.push({
id: hex(record.id), envelope: hex(record.envelope), receivedAt: record.receivedAt,
+ isRequest: record.isRequest, keptOnServer: keep,
});
stored++;
+ const { fields } = proto.parseFrontmatter(new TextDecoder().decode(unsealed.body));
+ const raw = fields.accept;
+ if (raw) {
+ try {
+ const token = b32decode(raw);
+ if (token.length === proto.TOKEN_LEN) {
+ const address = addressOfSigner(state, unsealed.sender, fields["reply-to"]);
+ if (address) state.tokens = { ...(state.tokens || {}), [address]: { token: b32encode(token) } };
+ }
+ } catch { /* malformed token: ignore */ }
+ }
}
acked.push(record.id);
}
- if (!acked.length) break;
- await proto.deleteOp(opened.session, acked);
+ if (keep) {
+ state.afterTime = afterTime;
+ state.afterId = hex(afterId);
+ } else if (acked.length) {
+ await proto.deleteOp(opened.session, acked);
+ }
}
} finally {
opened.session.stream.close();
}
+ if (!keep) { state.afterTime = 0; state.afterId = hex(new Uint8Array(proto.ID_LEN)); }
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");
+ const wantRequests = rest.includes("--requests");
+ for (const m of state.inbox || []) {
+ if (Boolean(m.isRequest) === wantRequests) describe(state, m, "inbox");
+ }
+ if (rest.includes("--sent")) for (const m of state.sent || []) describe(state, m, "sent");
},
read() {
@@ -281,19 +381,21 @@ const commands = {
async rotate() {
const state = load();
- const old = proto.identityFromSeed(unhex(state.seed));
+ const old = currentIdentity(state);
+ const master = unhex(state.master);
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 nextIndex = (state.rotations ?? 0) + 1;
+ const freshSeed = proto.identitySeed(master, nextIndex);
+ const fresh = proto.identityFromSeed(freshSeed);
+ const cert = proto.makeCert(account.user, old, freshSeed);
const opened = await open(account.host, account.port, { requirePin: true });
try {
- await proto.registerOp(opened.session, { username: account.user, identity: fresh, cert });
+ await proto.registerOp(opened.session, opened.serverStatic, { username: account.user, identity: fresh, cert });
} finally {
opened.session.stream.close();
}
- state.retired = [...(state.retired || []), state.seed];
- state.seed = hex(fresh.seed);
+ state.rotations = nextIndex;
save(state);
console.log(`rotated ${account.short}`);
console.log(`new key: ${b32encode(fresh.publicKey)}`);
@@ -301,8 +403,25 @@ const commands = {
},
};
+async function pushTokens(state) {
+ if (!state.account) {
+ console.error("warning: not registered; the set will be pushed with your first fetch");
+ return 0;
+ }
+ const me = currentIdentity(state);
+ const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
+ (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
+ const opened = await open(account.host, account.port, { requirePin: true });
+ try {
+ const { sync, tokens } = tokenSet(state);
+ return await proto.authenticate(opened.session, opened.handshakeHash, account.user, me, { sync, tokens });
+ } finally {
+ opened.session.stream.close();
+ }
+}
+
function describe(state, row, box, verbose = false) {
- const id = box === "inbox" ? row.id : row.id;
+ const id = row.id;
try {
const opened = proto.unseal(identities(state), unhex(row.envelope));
const { fields, body } = proto.parseFrontmatter(new TextDecoder().decode(opened.body));
@@ -311,12 +430,12 @@ function describe(state, row, box, verbose = false) {
?? `<${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)"}`);
+ `${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}`);
+ for (const [k, v] of Object.entries(fields)) if (k !== "accept") console.log(`${k}: ${v}`);
console.log("signature verified\n");
console.log(body);
} catch (err) {
diff --git a/test/e2e.py b/test/e2e.py
index cadd852..746197b 100644
--- a/test/e2e.py
+++ b/test/e2e.py
@@ -85,12 +85,18 @@ def main() -> int:
run(bob + ["register", f"bob@{host}"])
check("bob registers on the reference server", True)
- # --- reference client sends to the JS client
+ # bob needs alice as a known contact before he can accept her (§5.8).
+ run(bob + ["resolve", f"alice@{host}"])
+
+ # --- reference client sends to the JS client, unsolicited: bob has not
+ # accepted alice yet, so it lands in his requests tier, not the main one
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)])
+ bob_requests = run(bob + ["list", "--requests"])
+ check("bob's requests tier holds alice's unsolicited message", "greetings" in bob_requests,
+ bob_requests)
+ check("bob's main tier is still empty", "greetings" not in run(bob + ["list"]))
+ bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_requests).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)
@@ -99,23 +105,34 @@ def main() -> int:
check("reference client emits the signed reply address",
"reply-to: smol://alice@" in bob_read, bob_read)
- # --- JS client replies, through the bridge's WebSocket relay
+ # --- bob accepts alice (§5.8): pushed to the server right away, so her
+ # future mail reaches his main tier
+ accepted = run(bob + ["accept", f"alice@{host}"])
+ check("bob's server now holds an accept token", "server now holds 1 accept token" in accepted,
+ accepted)
+
+ # --- JS client replies, through the bridge's WebSocket relay. Bob has
+ # accepted alice, so this carries the token issued to her — but it is
+ # still unsolicited from alice's side, landing in her requests tier.
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_requests = run(alice + ["list", "--requests"])
+ check("alice's requests tier holds bob's reply", "re: greetings" in alice_requests,
+ alice_requests)
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)
# bob's reply carries his signed smol:// address; a receiver that does
# not know the field must still preserve and display it (SPEC.md §5.5)
check("reference client preserves the signed reply address",
- "reply-to:smol://bob@" in alice_read, alice_read)
+ bool(re.search(r"reply-to:\s+smol://bob@", alice_read)), alice_read)
- # --- rotation: the JS client rotates, the reference client walks the chain
+ # --- having now learned bob's accept token from that reply's Accept
+ # field, alice's next message to him carries a matching MAC and
+ # reaches his main tier even after he rotates his key
bob_rotated = run(bob + ["rotate"])
new_key = re.search(r"new key: (\S+)", bob_rotated).group(1)
resolved = run(alice + ["resolve", f"bob@{host}"])
@@ -124,7 +141,8 @@ def main() -> int:
"--anonymous"])
print(run(bob + ["fetch"]).strip())
bob_list = run(bob + ["list"])
- check("bob reads mail addressed to the new key", "post-rotation" in bob_list)
+ check("bob's main tier reaches mail addressed to the new key", "post-rotation" in bob_list,
+ bob_list)
# --anonymous omits the reply address; the field list must show none
post_read = run(bob + ["read", re.search(r"([0-9a-f]{8}) .*post-rotation",
bob_list).group(1)])
diff --git a/test/gen_vectors.py b/test/gen_vectors.py
index f9867b8..7edac53 100644
--- a/test/gen_vectors.py
+++ b/test/gen_vectors.py
@@ -23,6 +23,7 @@ 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"
+LABEL_IDENTITY, LABEL_ACCEPT, LABEL_MAC = b"smolmail/1 identity", b"smolmail/1 accept", b"smolmail/1 mac"
TIME = 1730000000 # fixed so the envelope vector is reproducible
@@ -139,9 +140,26 @@ def main() -> int:
"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(),
+ "id": hashlib.sha256(LABEL_ID + envelope).digest().hex(),
"unpadded_plaintext_len": len(plaintext)}
+ # --- §2/§5.8: master-derived rotation seeds and accept tokens
+ master = seed("master")
+ correspondent = seed("correspondent identity")
+ accept_key = hkdf_sha256(master, b"", LABEL_ACCEPT, 32)
+ token = hmac.new(accept_key, correspondent, hashlib.sha256).digest()
+ mid = seed("token message id")
+ out["tokens"] = {
+ "master": master.hex(),
+ "identity_seed_0": hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", 0), 32).hex(),
+ "identity_seed_1": hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", 1), 32).hex(),
+ "accept_key": accept_key.hex(),
+ "correspondent": correspondent.hex(),
+ "token": token.hex(),
+ "message_id": mid.hex(),
+ "accept_mac": hmac.new(token, LABEL_MAC + mid, hashlib.sha256).digest().hex(),
+ }
+
# --- 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)
diff --git a/test/vectors.json b/test/vectors.json
index 61fb897..3bf13a2 100644
--- a/test/vectors.json
+++ b/test/vectors.json
@@ -190,9 +190,19 @@
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
"time": 1730000000,
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
- "id": "b05d15a2ab5293164eda564de02a6901",
+ "id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553",
"unpadded_plaintext_len": 143
},
+ "tokens": {
+ "master": "fc613b4dfd6736a7bd268c8a0e74ed0d1c04a959f59dd74ef2874983fd443fc9",
+ "identity_seed_0": "35c2f058e7568f68e99e4fba7a730bd2d3cf7e1f6a2a266a2c5f5ca150d0074c",
+ "identity_seed_1": "fef4bd7f73b6459031a79dd589011a120c2cbc19b693b918f4aa9a8afe9dba7b",
+ "accept_key": "4fbaf4883885875100dc157864977e9a533866b15adc9daed35eedcc5f62fb20",
+ "correspondent": "fbaa80b2e87dc300cd33a2497dfe672c47ca8c8af099db8d45644054dff85297",
+ "token": "fe3085aeb2952b2bdd5abdcf8908ffd00054e871f47dfed2aa541cdb0e2006d6",
+ "message_id": "7c65ce5d23376fb508b4dda83a271095fd41e0ce47604213153520b0a0db50ac",
+ "accept_mac": "1d56673df4952dedcc1e40dfce2ad7820864090809a381e225e0f2fafa7cbfc7"
+ },
"noise": {
"initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2",
"responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80",
diff --git a/test/vectors.test.mjs b/test/vectors.test.mjs
index c969c42..13130bd 100644
--- a/test/vectors.test.mjs
+++ b/test/vectors.test.mjs
@@ -71,6 +71,18 @@ for (const v of vectors.ed_to_x25519) {
eq(`x_pub[${v.name}]`, hex(crypto.ed25519ToX25519(crypto.ed25519PublicKey(unhex(v.seed)))), v.x_pub);
}
+// --- §2/§5.8: master-derived rotation seeds and accept tokens -----------------
+
+{
+ const t = vectors.tokens;
+ const master = unhex(t.master);
+ eq("identity-seed-0", hex(proto.identitySeed(master, 0)), t.identity_seed_0);
+ eq("identity-seed-1", hex(proto.identitySeed(master, 1)), t.identity_seed_1);
+ eq("accept-key", hex(proto.acceptKeyFor(master)), t.accept_key);
+ eq("token-for", hex(proto.tokenFor(master, unhex(t.correspondent))), t.token);
+ eq("accept-mac", hex(proto.acceptMac(unhex(t.token), unhex(t.message_id))), t.accept_mac);
+}
+
// --- §5 envelope --------------------------------------------------------------
{
@@ -125,19 +137,22 @@ for (const v of vectors.ed_to_x25519) {
// --- frontmatter (§5.5) -------------------------------------------------------
{
- const spec = "---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
+ const rid = "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5"; // §5.4: 64 hex chars
+ const spec = `---\nSubject: Re: the thing\nIn-Reply-To: ${rid}\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");
+ // §5.5: keys are compared case-insensitively, so they come back lowercased.
+ eq("fm-subject", fields.subject, "Re: the thing");
+ eq("fm-reply", fields["in-reply-to"], rid);
+ eq("fm-case-insensitive", proto.parseFrontmatter("---\nSUBJECT: hi\n---\nx").fields.subject, "hi");
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-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");
+ eq("fm-too-many-keys", proto.parseFrontmatter("---\n" + "X: y\n".repeat(65) + "---\nbody").fields.subject ?? "none", "none");
}
// --- addresses (§3) -----------------------------------------------------------
@@ -167,21 +182,23 @@ for (const v of vectors.ed_to_x25519) {
// --- rotation chains (§7) ------------------------------------------------------
{
+ const user = "alice";
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 chain = [proto.makeCert(user, old, mid, when),
+ proto.makeCert(user, proto.identityFromSeed(mid), fresh, when)];
+ ok("chain-valid", proto.walkChain(user, old.publicKey, freshPub, chain));
+ ok("chain-unchanged", proto.walkChain(user, old.publicKey, old.publicKey, []));
+ ok("chain-missing-link", !proto.walkChain(user, old.publicKey, freshPub, chain.slice(1)));
+ ok("chain-wrong-username", !proto.walkChain("bob", old.publicKey, freshPub, chain));
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");
+ forged[1] = proto.makeCert(user, proto.identityFromSeed(mid), crypto.randomBytes(32), when);
+ ok("chain-broken", !proto.walkChain(user, old.publicKey, freshPub, forged));
+ ok("chain-oversize", !proto.walkChain(user, old.publicKey, freshPub, Array(17).fill(chain[0])));
+ eq("cert-len", String(chain[0].length), "200");
}
// --- response framing guards (§6.1) -------------------------------------------
diff --git a/web/index.html b/web/index.html
index 4e06cc6..cb44152 100644
--- a/web/index.html
+++ b/web/index.html
@@ -36,13 +36,13 @@
welcome
-
Identity is one 32-byte seed. Back it up: it is the only secret, and losing it means losing the address.
+
Identity is one 32-byte master secret. Back it up: it is the only secret, and losing it means losing the address.
-
or restore from a saved seed:
+
or restore from a saved master:
-
+
@@ -100,7 +104,7 @@
1 your server
Pin the key of the server that will hold your mail. Get it from its operator over a
- channel you already trust — registering, recalling and fetching all refuse an unpinned server (SPEC.md §4).
+ channel you already trust — registering, recalling and fetching all refuse an unpinned server.
already registered elsewhere?
-
Restoring a seed brings back the identity, not the memory of what address it was
+
Restoring a master brings back the identity, not the memory of what address it was
registered under in a different browser — the server was never asked. Recall it here: this
looks the address up and checks it resolves to this identity's key, without registering anything.