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"