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
@@ -1667,6 +1667,33 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
storage.delete(pinnedName(threadId))
}
// -- Home-record rollback floor (§20.4) ----------------------------------
private fun homeSerialName(fnid: String) = "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}"
/**
* The highest home-record serial seen for an address, or null.
*
* 🔴 **This is what makes the serial mean anything on the reading side.** An
* older home record is genuinely signed — published by the account holder
* before they added a second relay — so a relay serving it is handing out
* something that verifies. Remembering the highest seen is the only thing
* that turns "this signature is valid" into "this is not a rollback".
*
* ⚠️ Sealed like everything else here: which addresses this device has
* looked up is exactly the metadata the vault exists to keep off a stolen
* phone, and the name is a hash of the address for the same reason.
*/
fun homeSerial(fnid: String): Int? =
readJson(homeSerialName(fnid))?.optInt("serial", -1)?.takeIf { it >= 0 }
/** Remember a serial, never going backwards. */
fun saveHomeSerial(fnid: String, serial: Int) {
val seen = homeSerial(fnid) ?: -1
if (serial <= seen) return
writeJson(homeSerialName(fnid), JSONObject().put("serial", serial))
}
// -- Pinned message (§6.19) ----------------------------------------------
private fun pinnedName(threadId: String) = "pinned-$threadId"