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:
parent
fcb54e2b40
commit
6c8419aa32
2 changed files with 38 additions and 5 deletions
|
|
@ -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
|
// A signed Reply-To lets a first-time recipient name and answer us (§5.5
|
||||||
// allows unknown keys); "anonymous" omits it.
|
// allows unknown keys); "anonymous" omits it.
|
||||||
if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]);
|
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
|
// §5.8: hand an accepted correspondent the token for our own mailbox, once,
|
||||||
// first reply from them reaches our main tier.
|
// so a reply from them reaches our main tier. See store.tokenToDeliver for
|
||||||
const accepted = store.accepted(addr.short);
|
// why this isn't on every message.
|
||||||
if (accepted?.active) fields.push(["Accept", b32encode(tokenFor(master, accepted.identity))]);
|
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
|
// 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
|
// 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
|
// 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 {
|
} finally {
|
||||||
opened.session.stream.close();
|
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.
|
// §5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
|
||||||
await store.storeMessage("sent", {
|
await store.storeMessage("sent", {
|
||||||
id: hex(messageId(envelope)),
|
id: hex(messageId(envelope)),
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,32 @@ export function allContacts() {
|
||||||
|
|
||||||
export function accepted(address) {
|
export function accepted(address) {
|
||||||
const a = (load().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
|
// 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),
|
identity: previous?.identity ?? b32encode(identityBytes),
|
||||||
active: true,
|
active: true,
|
||||||
addedAt: previous?.addedAt ?? Date.now(),
|
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;
|
state.accepted = table;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue