Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt
T
Paul HittandClaude Opus 4.8 4b4126707f 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>
2026-08-29 08:32:49 -04:00

66 lines
2.3 KiB
Kotlin

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"))
}
}