diff --git a/web/js/app.js b/web/js/app.js index 475f54f..383bfdd 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -946,10 +946,11 @@ 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 @@ -968,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; });