Compare commits

...

3 commits

Author SHA1 Message Date
randogoth
d19568c1c0 chore: release 0.2.1
Trims the body before sealing instead of appending a newline, and hands an
accept token to a contact once rather than attaching it to every message.
Both made a post from this client unpublishable by a service that republishes
what it receives.
2026-10-15 09:23:53 +03:00
randogoth
6c8419aa32 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.
2026-10-15 09:18:02 +03:00
randogoth
fcb54e2b40 fix: trim the body before sealing instead of appending a newline
The body was right-trimmed and then given a trailing newline back, which is
reasonable for a mail body and fatal for a published twtxt record: smolboid
refuses a post whose text carries a line break, because a record is one line
of a feed.

Trimming belongs here, before the seal, so the signature covers exactly the
text the author meant. A receiver cannot do it — stripping is not invertible,
so it could no longer rebuild the signed bytes to verify them.
2026-10-14 22:08:55 +03:00
3 changed files with 46 additions and 7 deletions

View file

@ -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.

View file

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

View file

@ -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;
});