Audit C1+C2: gate inbound sessions on the pin, and bind device to user

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 08:32:49 -04:00
co-authored by Claude Opus 4.8
parent 1799ada15b
commit 4b4126707f
3 changed files with 126 additions and 4 deletions
@@ -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
@@ -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) ---------------------------------------------------
/**
@@ -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<String, ByteArray>()
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"))
}
}