fix: deliver an accept token once, not on every message

Mirrors the same fix in fumi-core. sendMail attached the token to every
message addressed to an accepted contact, so a secret rode in the body of
ordinary mail indefinitely. §5.8 has the owner deliver each token to its
correspondent; it doesn't say every message carries one.

Harmless between people, and fatal for any recipient that republishes what it
receives: a published record is checked by rebuilding the exact bytes the
author signed, and those can't include a token without disclosing it. Someone
who accepted such a service could never post to it again, and accepting a
correspondent is what the rest of the system teaches.

Contacts stored before this field existed count as still owing, so the next
message to each carries the token once more. That is what every message did
before, so nothing that works today can break. Marked only once the
recipient's server has taken the message, so a send that threw leaves the
token still owing. Not cleared by block: the token is derived from the master
and the identity frozen at acceptance, so they keep the value either way.
This commit is contained in:
randogoth 2026-10-15 09:18:02 +03:00
parent fcb54e2b40
commit 6c8419aa32
2 changed files with 38 additions and 5 deletions

View file

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