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:
co-authored by
Claude Opus 5
parent
455cdcd818
commit
6e4eca84e3
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user