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:
co-authored by
Claude Opus 4.8
parent
1799ada15b
commit
4b4126707f
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user