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
@@ -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")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user