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
@@ -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