Both remaining ⚪ protocol rows turned out to be stale.
`AccountKdf.localAccountSalt` is pinned to the published vector and wired into
sign-in for a local relay; `MailboxHoming` and `AccountIdentity.publishHomes`
have shipped the publish and read halves for a while. PARITY.md was describing
a gap that had closed, which is its own kind of wrong — a definition of done
that overstates the work left is one people stop reading.
🔴 What was genuinely missing was on the READING side, and on BOTH clients.
An older home record is GENUINELY SIGNED — the account holder published it,
before they added a second relay. So a relay that wants to be somebody's only
mailbox does not have to forge anything: it serves the record it saw first,
that record verifies perfectly, and the sender delivers to one relay believing
it is delivering to all of them. That is the attack multi-homing exists to
survive, and the serial is what was added to stop it.
The relay refuses a lower serial on WRITE. Nothing refused one on READ:
`verifiedRelays` here and `verifyHomes` on iOS both checked the signature and
stopped. Fixed on both — iOS in ios!116, this is the Kotlin half.
The highest serial seen per address is remembered in the vault and a lower one
is refused. Equal is fine; only strictly lower is a rollback.
🔴 The floor rises only for a record that VERIFIED, via a separate
`verifiedSerial`. Otherwise a hostile relay could hand over an unsigned record
carrying Int.MAX_VALUE, have the client raise its floor to it, and lock out
every later genuine record — turning the rollback defence into a denial of
service against the account holder. Tested.
⚠️ Sealed in the vault and keyed by a hash of the address: which addresses this
device has looked up is exactly the metadata the vault exists to keep off a
stolen phone.
⚠️ The refusal does not claim an attack. A relay that has genuinely fallen
behind serves an old record for honest reasons, and nothing in a client can
tell that from the other case.
8 unit tests. App builds, every JVM suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The outbound half of the three controls whose codecs were already back. The
long-press menu gains Edit, Pin/Unpin and Forward; the thread gains a pinned
banner; a bubble says `edited` and `Forwarded`.
🔴 An edit that silently replaces the words is worse than the typo. The reader
has no way to tell a correction from their own misremembering, and no way to
know the message they replied to is not the message that is there now. So the
bubble is marked, on both ends, and the edit sheet says plainly that everyone in
the conversation sees the new wording and a mark on their copy.
🔴 A forward carries the words and NOTHING about where they came from — no
author, no thread, no quote — and the test asserts the ABSENCE of a reference
rather than only the presence of the flag. The obvious "improvement", carrying
a reference so the reader can see the original, would republish a private
message's author to somebody who was never in that conversation. It also gets a
NEW message id: reusing the original's would make the copy a target for edits
and reactions aimed at the original, one of which would silently rewrite the
other.
⚠️ Only your own message can be edited, checked in `Session` and not only in
the menu. The receiving side enforces the same rule independently, so neither
end is trusting the other. Anyone may PIN, by contrast: a pin is a shared
banner rather than a permission, and restricting it to the author would mean the
useful message in a group is exactly the one nobody but its writer can pin.
⚠️ Edits and pins are applied locally first and rolled back if the send fails,
the same shape `react` already uses. Waiting on a round trip to see your own
correction appear makes it feel broken on a slow connection; leaving one that
exists only on this phone is worse, because you would believe you had fixed it.
⚠️ A forward does not carry an attachment yet. Re-sending a blob means
re-uploading it under a new token, and passing on the ORIGINAL token would hand
the new recipient a capability to a file in a conversation they are not part of.
Text only until that upload exists.
⚠️ The pinned banner resolves the id against the lines on screen, so a pinned
message that was since deleted shows nothing rather than a stale copy. Clearing
a thread clears its pin.
Two things found while wiring it: `Content.text` has no `groupId` parameter, so
a group forward needs `groupText` — a compile error rather than a silent drop.
And the edit/forward state is hoisted above the message list, because a row's
own `remember` dies when it scrolls out and an edit box vanishing mid-typing is
the worst way to lose a correction.
9 more unit tests. App builds, every JVM suite green. The three PARITY rows are
now ✅.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>