Apply inbound edits and pins instead of filing them as messages #68

Merged
paulhitt merged 3 commits from feat/apply-inbound-edits-and-pins into main 2026-09-04 20:49:23 +00:00
3 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 e0420a00c6 Apply inbound edits and pins instead of filing them as messages (§6.15/#6.16/#6.19)
🔴 This was an interop bug, not a missing feature. The engine has parsed
`editOf`, `pins` and `fwd` for as long as they have existed, and it exposes
`isRenderable` — "does this belong in a thread" — as the single answer.
`Session`'s inbound loop never consulted it.

So three things arrived from the iPhone app and were filed as ordinary bubbles.
An edit carries its new wording in `text`, so it drew as a DUPLICATE beside the
typo it was meant to replace — a correction that made the thread worse than the
mistake did. A pin carries only an id, so it drew as a blank bubble. And a
forward was drawn as the sender's own words.

Worst of the three is the one nobody had hit yet: a control envelope from a
NEWER peer carries `ctl` and a kind this build has never heard of, and would
have drawn a bubble full of JSON in somebody's conversation. That is precisely
the failure `isControl` was written to prevent, defeated by nobody calling it —
and it would have shipped to every older Android install the first time a
control kind was added on iOS. The guard is `isRenderable` rather than a list of
kinds this build knows, because an allowlist is only correct until the other
platform adds one.

What is applied now:

- an edit rewrites the message it names and stamps `edited`, so a bubble can
  say so — silently replacing the text is worse than the typo, because the
  reader cannot tell a correction from their own misremembering;
- a pin is stored per thread, sealed in the vault like the transcript, as an ID
  and never a copy: a copy goes stale against an edit and outlives a deletion;
- `fwd` reaches the transcript so second-hand words are not drawn as the
  sender's own — THAT it was forwarded, never from whom.

🔴 An inbound edit is applied only when it comes from the person whose message
it is. Without that check any contact sharing a session could rewrite anybody's
words in the reader's transcript — a better attack than sending a message of
their own, because the words would carry somebody else's name.

⚠️ Clearing a thread clears its pin. A banner pointing at a message that was
just cleared reads as the clear not having worked.

⚠️ `fwd` is read as both a boolean and a number, for the same reason `isControl`
accepts both: `org.json`'s `optBoolean` does not coerce a number, and that is
how this class of bug shipped once already.

9 unit tests. App builds, every JVM suite green. The three PARITY rows stay 🟡:
the inbound half is done, originating each still needs the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:53:13 -04:00
Paul HittandClaude Opus 5 ab8ff658b2 Pin the relay's identity, not just its TLS (ios#19, Android half)
🔴 This was not a missing feature on Android. It was a wrong conclusion.

`Trust.verifyHeadSignature` already pinned `issuerPublicKey` the first time a
signed log head arrived. When the key later changed, the head signature simply
stopped verifying — and the only conclusion available at that point was
`KeyLogForkException`. So a relay key rotation somebody performed on purpose
was reported to the user as the relay showing two different histories, with
`forkDetected` set.

Those need different responses. A fork means nothing the relay says can be
trusted and the client should stop. A rotation means somebody ran a command, or
somebody else is answering on that address, and only a person can tell which.
Reporting the second as the first is alarming and unactionable — and an alarm
people cannot act on is one they learn to dismiss, which costs the real fork
detection its credibility as well.

`RelayIdentityPin` now decides what the published key MEANS before it is used,
with the same four verdicts as iOS, and a change raises
`RelayIdentityChangedException` — deliberately NOT a `KeyLogForkException`, and
without setting `forkDetected`, because the log may be perfectly consistent.

⚠️ A relay that publishes no key is a weaker deployment, not an attack, and an
unparseable key is broken rather than hostile. Both read as "not published"
rather than as a change: refusing to talk to an older self-hosted build would
be a worse failure than the one this prevents.

⚠️ The warning names both possibilities and asserts neither, because nothing in
the client can tell a rotation from an impostor.

Two things found while writing the tests. `RelayClient.host` already existed, so
the one I added was a duplicate declaration — removed rather than renamed. And
the compiler folded `changed !is KeyLogForkException` to a constant when the
value was typed concretely, which is the compiler proving the separation
statically and also an assertion testing nothing; it is now held as the sealed
base, which is the check a caller's `when` actually performs.

9 unit tests. App builds, every JVM suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:46:57 -04:00
Paul HittandClaude Opus 5 4a587a7d4d Zero key material after use (ios#39, Android half)
The stretched master key in AccountKdf is an intermediate nobody needs after
the HKDF expansion, and it is the most valuable thing in that file: every other
account secret is derived from it. It now lives inside
`SecureBytes.withSecret`, which wipes it however the block leaves — including
out of a throw, because a derivation that failed half-way is exactly where
material gets forgotten.

🔴 The Android guarantee is WEAKER than the iOS one, and PARITY.md says 🟡
rather than rounding it up to ✅. iOS has `memset_s`, specified not to be
optimised away, and does not page application memory to disk. Neither holds on
the JVM: a compacting collector can relocate a ByteArray and leave the bytes at
the old address untouched, Android has had compressed swap on by default for
years, and `Arrays.fill` is only "not eliminated in practice" rather than
guaranteed. There is no way to pin a Java array, so that is a ceiling rather
than a gap to close later.

So the claim is written narrowly: a derived key is not left sitting in the heap
after use. Not "the key is gone". Spending the value of this feature on a
stronger claim than it supports would be the worst possible trade.

⚠️ `signInSecrets` does BOTH expansions inside the block. Wiping between them
would derive the account seed from thirty-two zero bytes — a perfectly
consistent, perfectly wrong identity, on every device, silently. That is a far
worse failure than the leak this narrows, and `signInSecrets` had no pinned
vector of its own, so a test compares the pair-path against the individual
derivations. Proven: moving the wipe one line earlier turns it red.

⚠️ The limit is asserted rather than left to prose — a copy taken before a wipe
survives it, and that is a passing test so nobody reads `wipe` as stronger than
it is.

10 unit tests. App builds, every JVM suite green including the pinned KDF
vectors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:43:39 -04:00