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
@@ -120,15 +120,54 @@ object AccountIdentity {
return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays))
}
/** Relays named by a record, but only once it verifies. */
fun verifiedRelays(record: org.json.JSONObject): List<String>? {
/**
* Relays named by a record, but only once it verifies AND is not older than
* one already seen.
*
* 🔴 **A signature alone does not stop a rollback, and that is the whole
* reason the serial exists.** An older record is *genuinely signed*: it was
* published by the account holder, before they added a second home. 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 until this both
* clients checked only the signature. The relay refuses a lower serial on
* WRITE; nothing refused one on READ.
*
* ⚠️ [floor] is the highest serial this device has already accepted for
* this address, or null on first sight. Equal is fine — the same record
* re-read is the ordinary case. Only strictly lower is a rollback.
*
* ⚠️ **A refusal here is not proof of an attack**, and the caller must not
* present it as one. A relay that has genuinely fallen behind serves an old
* record for honest reasons; what the client can say is that it has seen a
* newer one, and that it will keep using it.
*/
fun verifiedRelays(
record: org.json.JSONObject,
floor: Int? = null,
): List<String>? {
val fnid = record.optString("fnid").ifEmpty { return null }
val serial = record.optInt("serial", -1)
if (serial < 0) return null
if (floor != null && serial < floor) return null
val array = record.optJSONArray("relays") ?: return null
val relays = (0 until array.length()).map { array.getString(it) }
val sig = record.optString("sig").ifEmpty { return null }
return if (verifyHomes(fnid, serial, relays, sig)) relays else null
}
/** The serial a record carries, once it verifies. Null if it does not. */
fun verifiedSerial(record: org.json.JSONObject): Int? {
val fnid = record.optString("fnid").ifEmpty { return null }
val serial = record.optInt("serial", -1)
if (serial < 0) return null
val array = record.optJSONArray("relays") ?: return null
val relays = (0 until array.length()).map { array.getString(it) }
val sig = record.optString("sig").ifEmpty { return null }
return if (verifyHomes(fnid, serial, relays, sig)) relays else null
return if (verifyHomes(fnid, serial, relays, sig)) serial else null
}
// The module's own helper, so this encodes identically to every other blob
@@ -0,0 +1,142 @@
package io.flamenet.client
import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.FNID
import java.security.SecureRandom
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.json.JSONArray
import org.json.JSONObject
/**
* A home record that is older than one already seen must be refused (§20.4).
*
* 🔴 **A signature alone does not stop a rollback, and both clients checked
* only the signature.** 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 precisely 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;
* until this, nothing refused one on READ — on either platform.
*/
class HomeRecordRollbackTest {
private val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
private val fnid = FNID.address(Ed25519.fromSeed(seed).publicKey)
/** A record the account holder really did sign. */
private fun record(serial: Int, relays: List<String>): JSONObject {
val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
return JSONObject()
.put("fnid", fnid)
.put("serial", serial)
.put("relays", JSONArray(relays))
.put("sig", sig)
}
private val old = record(1, listOf("one.example"))
private val current = record(4, listOf("one.example", "two.example"))
// ---- The attack ------------------------------------------------------
/**
* 🔴 The whole finding, in one assertion. Both records verify; only the
* serial separates them.
*/
@Test
fun `an older record is refused once a newer one has been seen`() {
assertNotNull(AccountIdentity.verifiedRelays(old),
"the old record does not verify, so this test proves nothing")
assertNotNull(AccountIdentity.verifiedRelays(current))
assertNull(
AccountIdentity.verifiedRelays(old, floor = 4),
"a relay serving a genuinely-signed OLDER record was believed — " +
"which is exactly how one relay makes itself somebody's only mailbox",
)
}
/** The newer one is still accepted against the same floor. */
@Test
fun `the record at the floor is accepted`() {
assertEquals(
listOf("one.example", "two.example"),
AccountIdentity.verifiedRelays(current, floor = 4),
)
}
/**
* ⚠️ Equal is fine. Re-reading the same record is the ordinary case, and
* refusing it would break every second lookup.
*/
@Test
fun `the same serial twice is not a rollback`() {
assertNotNull(AccountIdentity.verifiedRelays(record(4, listOf("a.example")), floor = 4))
}
/** First sight has no floor and accepts whatever verifies. */
@Test
fun `with no floor any verifying record is accepted`() {
assertNotNull(AccountIdentity.verifiedRelays(old, floor = null))
assertNotNull(AccountIdentity.verifiedRelays(current, floor = null))
}
// ---- The floor cannot be poisoned ------------------------------------
/**
* 🔴 **A serial is only reported for a record that VERIFIES.** Otherwise a
* hostile relay could hand over an unsigned record carrying serial
* 2,000,000,000, have the client raise its floor to it, and lock out every
* genuine record for ever — turning the rollback defence into a denial of
* service against the account holder.
*/
@Test
fun `an unsigned record reports no serial to raise the floor with`() {
val forged = JSONObject()
.put("fnid", fnid)
.put("serial", Int.MAX_VALUE)
.put("relays", JSONArray(listOf("attacker.example")))
.put("sig", Wire.b64(ByteArray(64)))
assertNull(AccountIdentity.verifiedSerial(forged),
"a forged record could raise the floor and lock out every real one")
assertNull(AccountIdentity.verifiedRelays(forged, floor = 1))
}
/** A record signed for a DIFFERENT address does not verify either. */
@Test
fun `a record signed by somebody else is refused`() {
val otherSeed = ByteArray(32).also { SecureRandom().nextBytes(it) }
val relays = listOf("attacker.example")
val sig = Wire.b64(Ed25519.sign(otherSeed, FNID.homesCanonical(fnid, 9, relays)))
val impostor = JSONObject()
.put("fnid", fnid).put("serial", 9)
.put("relays", JSONArray(relays)).put("sig", sig)
assertNull(AccountIdentity.verifiedSerial(impostor))
assertNull(AccountIdentity.verifiedRelays(impostor, floor = null))
}
/**
* ⚠️ The relay list is part of what is signed, so swapping it invalidates
* the record — a relay cannot keep a valid signature and quietly drop the
* other homes out of the list.
*/
@Test
fun `the relay list cannot be edited without breaking the signature`() {
val tampered = record(4, listOf("one.example", "two.example"))
.put("relays", JSONArray(listOf("one.example")))
assertNull(AccountIdentity.verifiedRelays(tampered),
"a relay dropped the other homes and kept a valid signature")
}
/** A negative or absent serial is malformed, not a floor of zero. */
@Test
fun `a malformed serial is refused rather than treated as zero`() {
assertNull(AccountIdentity.verifiedRelays(
JSONObject().put("fnid", fnid).put("relays", JSONArray()).put("sig", "x")))
}
}