From 6e4eca84e305c02677b731d8bf3188a7b59ec60b Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 14:07:22 -0400 Subject: [PATCH] =?UTF-8?q?Refuse=20a=20home=20record=20older=20than=20one?= =?UTF-8?q?=20already=20seen=20(=C2=A720.4,=20Android=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PARITY.md | 34 ++++- .../messenger/app/MessengerViewModel.kt | 18 ++- .../io/flamenet/client/AccountIdentity.kt | 45 +++++- .../flamenet/client/HomeRecordRollbackTest.kt | 142 ++++++++++++++++++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 27 ++++ 5 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt diff --git a/PARITY.md b/PARITY.md index 91f5fc3..74a0e5c 100644 --- a/PARITY.md +++ b/PARITY.md @@ -73,8 +73,8 @@ From `Sources/Auth/*` and `Sources/Views/Sign*`. | Change password | โœ… signs the device out, because that is what it does | | Delete account | โœ… confirms in place, erases the phone too | | Relay picker / `SiteConfig` equivalent | โœ… `SiteConfig.kt`, a `relay` field, and an `identity-mode` label | -| Deterministic local-relay KDF salt | โšช | -| Multi-homing (publish + read home records) | โšช | +| Deterministic local-relay KDF salt | โœ… `AccountKdf.localAccountSalt`, pinned to the published vector, and wired into sign-in for a LOCAL relay | +| Multi-homing (publish + read home records) | โœ… publish, read, verify and the serial floor โ€” see below | โš ๏ธ Sign-up is where the three deployment modes become visible to a user, and each derives its salt differently. Getting this wrong does not error โ€” it @@ -222,6 +222,36 @@ somebody is grinding attempts. The steps are clamped before the shift, and --- +## Multi-homing (ยง20.4) + +Both rows above were โšช and both were **stale**: `AccountKdf.localAccountSalt` is +pinned to the published vector and wired into sign-in for a local relay, and +`MailboxHoming` / `AccountIdentity.publishHomes` have shipped the publish and +read halves for a while. The document was describing a gap that had closed. + +๐Ÿ”ด **What was genuinely missing was on the READING side, 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. + +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 (Android this MR, iOS `ios!116`) by remembering the +highest serial seen per address and refusing anything below it. + +โš ๏ธ The floor rises only for a record that verified โ€” otherwise a hostile relay +could raise it with a forged `Int.MAX_VALUE` and lock out every later genuine +record, turning the rollback defence into a denial of service against the +account holder. + +โš ๏ธ Neither client claims an attack when it refuses. 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. + +--- + ## Inbound control envelopes ๐Ÿ”ด **The app never asked the engine whether an envelope belonged in a thread.** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 9acf849..85c5b4e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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) { diff --git a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt index e87aef4..dc3c310 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt @@ -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? { + /** + * 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? { + 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 diff --git a/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt b/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt new file mode 100644 index 0000000..e3cbacf --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt @@ -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): 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"))) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index e61e5cc..2afd894 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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"