From 4b4126707fc6e0f619e9949d59e4186a40764b84 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 29 Aug 2026 08:32:49 -0400 Subject: [PATCH] Audit C1+C2: gate inbound sessions on the pin, and bind device to user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 (Android-only, critical): building a session from a prekey envelope accepted whatever identity key it offered — no pin check, where iOS has always had one. Anyone could hand us a handshake with their own key while claiming to be a pinned contact, and every later message from that session rendered as that contact. The same gate the send path uses now runs on receive. C2 (both platforms): a device binds first-use-wins to its user, the claim is checked against that binding on every decrypt, and control traffic (delivery key, avatar) is refused from an unproven sender — a poisoned delivery key forces the next send UNSEALED and deanonymizes it. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/client/Conversations.kt | 39 +++++++++-- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 26 ++++++++ .../io/flamenet/e2e/DeviceOwnerBindingTest.kt | 65 +++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 7e7df24..0da4399 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -581,10 +581,30 @@ class Conversations( // Each branch writes back deliberately. val fromCurrent = openWith(existing, envelope) val fromRetired = if (fromCurrent == null) openWith(retired, envelope) else null + // 🔴 **The receive-side identity gate (audit C1).** Building a + // session from a prekey envelope means accepting whatever + // identity key it offers — so it must pass the SAME pin check + // the send path applies. Without it, anyone could hand us a + // handshake with their own key while claiming to be a contact + // we have already pinned, and every later message from that + // session would render as that contact. iOS has always gated + // this branch; this is the missing half. val fromFresh = - if (fromCurrent == null && fromRetired == null) + if (fromCurrent == null && fromRetired == null) { + val offeredIK: ByteArray? = runCatching { + Wire.unb64(envelope.handshake!!.getString("ik_sig")) + }.getOrNull() + val claimant = opened?.claimedFromUser ?: message.fromUser + if (offeredIK == null || claimant <= 0L || + trust.record(claimant, listOf(offeredIK), trust.pinnedFnid(claimant)) + == IdentityTrust.CHANGED + ) { + // Refusing costs this message; establishing a session + // on a planted key costs the whole conversation. + throw IdentityChangedException(claimant) + } openWith(E2ESession.respond(identity, envelope), envelope) - else null + } else null val plaintext = when { fromCurrent != null -> { @@ -615,9 +635,20 @@ class Conversations( } else -> throw IllegalStateException("no session could open this envelope") } - val fromUser = opened?.claimedFromUser ?: message.fromUser + // 🔴 **The claim is checked against the binding (audit C2).** + // The ratchet proved the DEVICE. Who that device belongs to was + // decided the first time it was seen and is not up for + // renegotiation — a device claiming a different user now is + // lying, and its message files under the user it is bound to. + val claimed = opened?.claimedFromUser ?: message.fromUser + val fromUser = vault.bindDeviceOwner(claimed, fromDevice) + val senderProven = fromUser == claimed val content = Content.parse(plaintext) - consumeControl(fromUser, content) + // Control traffic (delivery key, avatar) only from a proven + // sender: poisoning another user's delivery key forces our next + // send to them UNSEALED, which hands the relay the sender that + // sealing exists to hide. + if (senderProven) consumeControl(fromUser, content) // 🔴 An UNSEALED message from a contact we have told our // delivery key means their copy is gone — a reinstall wiped // it. Sealing to us requires that key, and a peer who holds diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 00d7d77..6aad2cc 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -63,6 +63,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val CALL_LOG = "call-log" private const val STORY_AUDIENCE = "story-audience" private const val ROSTER = "roster" + private const val DEVICE_OWNER_PREFIX = "devowner/" /** Kept because the whole log is re-sealed on every write. */ private const val CALL_LOG_MAX = 500 @@ -573,6 +574,31 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience) + // -- Device → owner binding (audit C1/C2) -------------------------------- + + /** + * The user a device is bound to, first-use-wins. + * + * 🔴 The ratchet proves which DEVICE wrote a message; the user id inside a + * sealed envelope is a claim its author chose. Without this binding a + * contact could encrypt on their own session and stamp another user's id + * on it — the message would be filed and named as that person, and worse, + * allowed to overwrite their delivery key (which forces our next send to + * them UNSEALED, handing the relay the sender sealing exists to hide). + */ + fun bindDeviceOwner(userId: Long, deviceId: String): Long { + deviceOwner(deviceId)?.let { return it } + if (userId <= 0L) return userId + writeJson(DEVICE_OWNER_PREFIX + deviceId, JSONObject().put("user", userId)) + return userId + } + + fun deviceOwner(deviceId: String): Long? = + readJson(DEVICE_OWNER_PREFIX + deviceId)?.optLong("user")?.takeIf { it > 0L } + + /** Forget a binding — used when an identity change is accepted. */ + fun clearDeviceOwner(deviceId: String) = storage.delete(DEVICE_OWNER_PREFIX + deviceId) + // -- Contact list (P1) --------------------------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt new file mode 100644 index 0000000..311860d --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt @@ -0,0 +1,65 @@ +package io.flamenet.e2e + +import javax.crypto.KeyGenerator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Audit C1/C2: a device is bound to the user it was first proven to be, and a + * later claim of a different user does not win. + * + * The ratchet proves the DEVICE; the user id inside a sealed envelope is a + * claim its author chose. Without this binding a contact could relabel their + * own messages as anyone, and poison that person's delivery key — which forces + * the next send UNSEALED and hands the relay the sender sealing exists to hide. + */ +class DeviceOwnerBindingTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun vault() = Vault( + MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + + @Test fun `first use binds the device to its user`() { + val v = vault() + assertNull(v.deviceOwner("dev-A")) + assertEquals(7L, v.bindDeviceOwner(7L, "dev-A")) + assertEquals(7L, v.deviceOwner("dev-A")) + } + + @Test fun `a later different claim does not win`() { + val v = vault() + v.bindDeviceOwner(7L, "dev-A") + assertEquals(7L, v.bindDeviceOwner(42L, "dev-A"), + "a bound device must not be re-attributed by a later claim") + assertEquals(7L, v.deviceOwner("dev-A")) + } + + @Test fun `distinct devices bind independently`() { + val v = vault() + v.bindDeviceOwner(7L, "dev-A") + assertEquals(42L, v.bindDeviceOwner(42L, "dev-B")) + assertEquals(7L, v.deviceOwner("dev-A")) + } + + @Test fun `clearing allows a rebind when an identity change is accepted`() { + val v = vault() + v.bindDeviceOwner(7L, "dev-A") + v.clearDeviceOwner("dev-A") + assertNull(v.deviceOwner("dev-A")) + assertEquals(42L, v.bindDeviceOwner(42L, "dev-A")) + } + + @Test fun `a zero claim is never bound`() { + val v = vault() + assertEquals(0L, v.bindDeviceOwner(0L, "dev-C")) + assertNull(v.deviceOwner("dev-C")) + } +}