Refuse a home record older than one already seen (§20.4, Android half)

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>
This commit is contained in:
Paul Hitt
2026-09-04 14:07:22 -04:00
co-authored by Claude Opus 5
parent 455cdcd818
commit 6e4eca84e3
5 changed files with 260 additions and 6 deletions
@@ -794,7 +794,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// survive, and showing it unchecked would make this screen a place
// that quietly confirms the attack.
runCatching {
current.relay.homes(fnid)?.let(io.flamenet.client.AccountIdentity::verifiedRelays)
val record = current.relay.homes(fnid) ?: return@runCatching null
// 🔴 **And not older than one already seen.** An old record is
// genuinely signed — published before a second home was added —
// so a relay wanting to be somebody's only mailbox does not
// have to forge anything; it just serves the record it saw
// first. The signature check alone accepts that, which is what
// the serial exists to stop.
val floor = current.vault.homeSerial(fnid)
val relays = io.flamenet.client.AccountIdentity
.verifiedRelays(record, floor = floor)
// ⚠️ Advanced only after the record was ACCEPTED. Recording the
// serial of a record that failed verification would let a
// hostile relay raise the floor with a forgery and lock out
// every later genuine record.
io.flamenet.client.AccountIdentity.verifiedSerial(record)
?.let { if (relays != null) current.vault.saveHomeSerial(fnid, it) }
relays
}.getOrNull()
}
val readable = withContext(Dispatchers.IO) {