diff --git a/nix/bridge.nix b/nix/bridge.nix index 6a32abf..b0dbfd3 100644 --- a/nix/bridge.nix +++ b/nix/bridge.nix @@ -21,7 +21,7 @@ assert lib.assertMsg ((serverHost == null) == (serverPublicKey == null)) "gsmol-bridge: serverHost and serverPublicKey must be set together, or not at all"; stdenvNoCC.mkDerivation { pname = "gsmol-bridge"; - version = "0.2.0"; + version = "0.2.1"; # Respects the repo's own .gitignore, so .venv/.devbox/.jj never enter the # store path even when building from an unclean local checkout. diff --git a/web/js/app.js b/web/js/app.js index a227f46..383bfdd 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -946,12 +946,19 @@ async function sendMail(toText, subject, body, replyTo, anonymous) { // A signed Reply-To lets a first-time recipient name and answer us (§5.5 // allows unknown keys); "anonymous" omits it. if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]); - // §5.8: hand an accepted correspondent the token for our own mailbox, so a - // first reply from them reaches our main tier. - const accepted = store.accepted(addr.short); - if (accepted?.active) fields.push(["Accept", b32encode(tokenFor(master, accepted.identity))]); + // §5.8: hand an accepted correspondent the token for our own mailbox, once, + // so a reply from them reaches our main tier. See store.tokenToDeliver for + // why this isn't on every message. + const owed = store.tokenToDeliver(addr.short); + if (owed) fields.push(["Accept", b32encode(tokenFor(master, owed))]); + // Trimmed before the payload is signed, so the signature covers exactly the + // text the author meant. A trailing newline is not content — but it becomes + // content the moment it is signed, and nothing downstream can strip it + // without invalidating the signature. smolboid, for one, refuses a post + // whose text carries a line break, because a published record is one line + // of a feed. const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v), - body.replace(/\s+$/, "") + "\n")); + body.trim())); const envelope = seal(me, recipient, bodyBytes); // §5.8: our token for their mailbox, if they have given us one. const held = store.tokenFrom(addr.short); @@ -962,6 +969,9 @@ async function sendMail(toText, subject, body, replyTo, anonymous) { } finally { opened.session.stream.close(); } + // Only now, with the message taken by their server: a send that threw + // earlier leaves the token still owing, so the next attempt carries it. + if (owed) store.markDelivered(addr.short); // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves. await store.storeMessage("sent", { id: hex(messageId(envelope)), diff --git a/web/js/store.js b/web/js/store.js index e2924b8..70931c0 100644 --- a/web/js/store.js +++ b/web/js/store.js @@ -247,7 +247,32 @@ export function allContacts() { export function accepted(address) { const a = (load().accepted || {})[address]; - return a ? { identity: b32decode(a.identity), active: a.active } : null; + return a ? { identity: b32decode(a.identity), active: a.active, delivered: !!a.delivered } : null; +} + +// The identity to derive a token for, but only while that token still needs +// handing over. Null once they have it. +// +// §5.8 has the owner *deliver* each token to its correspondent; it does not +// say every message carries one. Attaching it to all of them puts a secret in +// the body of ordinary mail, and a recipient that republishes what it gets +// can't carry one: a published record is checked by rebuilding the exact +// bytes that were signed, and the token must not be among them. +export function tokenToDeliver(address) { + const a = (load().accepted || {})[address]; + return a && a.active && !a.delivered ? b32decode(a.identity) : null; +} + +// Records that this correspondent now holds our token, so later messages +// carry no secret. Deliberately not undone by block(): they keep the value +// either way, and re-accepting hands back the same one, because it is derived +// from the master and the identity frozen at acceptance. +export function markDelivered(address) { + update(state => { + const table = { ...(state.accepted || {}) }; + if (table[address]) table[address] = { ...table[address], delivered: true }; + state.accepted = table; + }); } // Admit a contact to the main tier. The identity is frozen at acceptance — a @@ -261,6 +286,10 @@ export function accept(address, identityBytes) { identity: previous?.identity ?? b32encode(identityBytes), active: true, addedAt: previous?.addedAt ?? Date.now(), + // A contact accepted before this field existed is treated as still + // owing, so the next message carries the token once more. That is what + // every message did before, so it cannot break anything that works. + delivered: !!previous?.delivered, }; state.accepted = table; });