From fcb54e2b40a61d2d1a902a18e8d57fb80d5bc196 Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 14 Oct 2026 22:08:55 +0300 Subject: [PATCH 1/3] fix: trim the body before sealing instead of appending a newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/js/app.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/js/app.js b/web/js/app.js index a227f46..475f54f 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -950,8 +950,14 @@ async function sendMail(toText, subject, body, replyTo, anonymous) { // 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))]); + // 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); From 6c8419aa32907b3f8dd5d77142d7186cc8eb5073 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 15 Oct 2026 09:18:02 +0300 Subject: [PATCH 2/3] fix: deliver an accept token once, not on every message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/js/app.js | 12 ++++++++---- web/js/store.js | 31 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) 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; }); From d19568c1c04b46d6a9292b2f5d26d945c412b852 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 15 Oct 2026 09:23:53 +0300 Subject: [PATCH 3/3] 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. --- nix/bridge.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.