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.
|
// Each branch writes back deliberately.
|
||||||
val fromCurrent = openWith(existing, envelope)
|
val fromCurrent = openWith(existing, envelope)
|
||||||
val fromRetired = if (fromCurrent == null) openWith(retired, envelope) else null
|
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 =
|
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)
|
openWith(E2ESession.respond(identity, envelope), envelope)
|
||||||
else null
|
} else null
|
||||||
|
|
||||||
val plaintext = when {
|
val plaintext = when {
|
||||||
fromCurrent != null -> {
|
fromCurrent != null -> {
|
||||||
@@ -615,9 +635,20 @@ class Conversations(
|
|||||||
}
|
}
|
||||||
else -> throw IllegalStateException("no session could open this envelope")
|
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)
|
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
|
// 🔴 An UNSEALED message from a contact we have told our
|
||||||
// delivery key means their copy is gone — a reinstall wiped
|
// delivery key means their copy is gone — a reinstall wiped
|
||||||
// it. Sealing to us requires that key, and a peer who holds
|
// 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 CALL_LOG = "call-log"
|
||||||
private const val STORY_AUDIENCE = "story-audience"
|
private const val STORY_AUDIENCE = "story-audience"
|
||||||
private const val ROSTER = "roster"
|
private const val ROSTER = "roster"
|
||||||
|
private const val DEVICE_OWNER_PREFIX = "devowner/"
|
||||||
/** Kept because the whole log is re-sealed on every write. */
|
/** Kept because the whole log is re-sealed on every write. */
|
||||||
private const val CALL_LOG_MAX = 500
|
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)
|
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) ---------------------------------------------------
|
// -- 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