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

@ -5,8 +5,8 @@
// node test/client.mjs --state bob.json <command> [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) {