feat: adopt smolmail protocol 1.1 with accept tokens and leave-mail-on-server

This commit is contained in:
randogoth 2026-09-27 11:14:25 +03:00
parent eef7bf9b1d
commit d6fa097626
11 changed files with 911 additions and 232 deletions

View file

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