Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt
T
Paul HittandClaude Fable 5.1 2fa430f21c API-style comments and ktlint formatting
Every comment is reduced to its first sentence plus @param, @return
and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied
with its default (official Kotlin) style across all modules; 262 files
reformatted. The 264 remaining ktlint findings are naming and filename
rules, which are code changes, and are left alone.

Comments and formatting only. 1208 tests pass, counted from the XML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:02:07 -04:00

364 lines
14 KiB
Kotlin

package io.flamenet.e2e
import org.json.JSONObject
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class VaultTest {
/** Stands in for a directory on a phone, and lets a test see the raw bytes. */
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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun bundleFor(identity: DeviceIdentity): PeerBundle {
val oneTime = identity.generateOneTimePreKeys(1).first()
return PeerBundle.fromWire(
identity.registrationWire().put(
"opk",
JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey)),
),
)
}
@Test
fun `an identity survives being stored and read back`() {
val storage = MapStorage()
val secret = key()
val identity = DeviceIdentity.create()
identity.generateOneTimePreKeys(2)
Vault(storage, secret).saveIdentity(identity)
val restored = Vault(storage, secret).loadIdentity()!!
assertEquals(identity.deviceId, restored.deviceId)
assertEquals(2, restored.oneTimePreKeyCount)
// The private halves came back too, which only a real handshake proves.
val peer = DeviceIdentity.create()
val session = E2ESession.initiate(peer, bundleFor(restored))
val envelope = session.encrypt("after a cold start")
assertEquals("after a cold start", E2ESession.respond(restored, envelope).decrypt(envelope))
}
@Test
fun `a conversation survives being stored and read back`() {
val storage = MapStorage()
val secret = key()
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
val a = E2ESession.initiate(alice, bundleFor(bob))
val first = a.encrypt("hello")
val b = E2ESession.respond(bob, first)
b.decrypt(first)
Vault(storage, secret).saveSession(alice.deviceId, b)
val restored = Vault(storage, secret).loadSession(alice.deviceId)!!
assertEquals("still talking", restored.decrypt(a.encrypt("still talking")))
assertEquals(listOf(alice.deviceId), Vault(storage, secret).knownPeers())
}
/**
* The chain a re-establishment replaces must survive it.
*/
@Test
fun `a retired session is kept and still opens its chain`() {
val storage = MapStorage()
val secret = key()
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
// Bob starts his own chain before hearing from Alice.
val bobsOwn = E2ESession.initiate(bob, bundleFor(alice))
val bobsFirst = bobsOwn.encrypt("from bob")
Vault(storage, secret).saveSession(bob.deviceId, bobsOwn)
// Alice's message arrives on a different chain, so Alice's is adopted
// and Bob's own is retired rather than dropped.
val alices = E2ESession.initiate(alice, bundleFor(bob))
val alicesFirst = alices.encrypt("from alice")
val adopted = E2ESession.respond(bob, alicesFirst)
assertEquals("from alice", adopted.decrypt(alicesFirst))
val vault = Vault(storage, secret)
vault.saveRetiredSession(bob.deviceId, vault.loadSession(bob.deviceId)!!)
vault.saveSession(bob.deviceId, adopted)
// Alice is still on the chain Bob started, she answers on it.
val onTheRetiredChain = E2ESession.respond(alice, bobsFirst)
assertEquals("from bob", onTheRetiredChain.decrypt(bobsFirst))
val reply = onTheRetiredChain.encrypt("bob, this is the old chain")
val fresh = Vault(storage, secret)
assertNull(
runCatching { fresh.loadSession(bob.deviceId)!!.decrypt(reply) }.getOrNull(),
"the adopted session must NOT open the retired chain, otherwise this " +
"test would pass without the retired copy existing at all",
)
assertEquals(
"bob, this is the old chain",
fresh.loadRetiredSession(bob.deviceId)!!.decrypt(reply),
)
}
/**
* A failed decrypt damages the session object it was handed, so the thing
* that gets retired must be re-read from the vault.
*/
@Test
fun `the session retired after a failed decrypt must be re-read, not reused`() {
val storage = MapStorage()
val secret = key()
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
// Bob's own chain, stored, with a message Alice will later answer on
// it.
val bobsOwn = E2ESession.initiate(bob, bundleFor(alice))
val bobsFirst = bobsOwn.encrypt("from bob")
Vault(storage, secret).saveSession(bob.deviceId, bobsOwn)
val alicesReplyOnBobsChain =
E2ESession.respond(alice, bobsFirst).let {
it.decrypt(bobsFirst)
it.encrypt("answering on the chain bob started")
}
// The receive path: Bob's stored session is handed a foreign envelope
// and fails.
val foreign = E2ESession.initiate(alice, bundleFor(bob)).encrypt("different chain")
val damaged = Vault(storage, secret).loadSession(bob.deviceId)!!
assertNull(
runCatching { damaged.decrypt(foreign) }.getOrNull(),
"the foreign envelope must NOT open, or this test proves nothing",
)
// Retiring the damaged instance loses the chain.
val wrong = MapStorage().also { storage.records.forEach(it.records::put) }
Vault(wrong, secret).saveRetiredSession(bob.deviceId, damaged)
assertNull(
runCatching {
Vault(wrong, secret)
.loadRetiredSession(bob.deviceId)!!
.decrypt(alicesReplyOnBobsChain)
}.getOrNull(),
"a session damaged by a failed decrypt should NOT still open its chain, " +
"if this ever starts passing, the hazard is gone and this test can go",
)
// Re-reading the vault copy keeps it.
val pristine = Vault(storage, secret).loadSession(bob.deviceId)!!
Vault(storage, secret).saveRetiredSession(bob.deviceId, pristine)
assertEquals(
"answering on the chain bob started",
Vault(storage, secret)
.loadRetiredSession(bob.deviceId)!!
.decrypt(alicesReplyOnBobsChain),
)
}
/** Signing out must take the retired chains too, they hold chain keys. */
@Test
fun `forgetting sessions forgets the retired ones as well`() {
val storage = MapStorage()
val secret = key()
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
val a = E2ESession.initiate(alice, bundleFor(bob))
Vault(storage, secret).saveSession(bob.deviceId, a)
Vault(storage, secret).saveRetiredSession(bob.deviceId, a)
Vault(storage, secret).forgetAllSessions()
assertNull(Vault(storage, secret).loadSession(bob.deviceId))
assertNull(Vault(storage, secret).loadRetiredSession(bob.deviceId))
assertTrue(
storage.records.keys.none { it.contains("session") },
"a record still names a session: ${storage.records.keys}",
)
}
/** What is on disk must not be readable, or none of this was worth doing. */
@Test
fun `nothing recognisable is written in the clear`() {
val storage = MapStorage()
val identity = DeviceIdentity.create()
Vault(storage, key()).saveIdentity(identity)
val onDisk = String(storage.records.values.first(), Charsets.ISO_8859_1)
assertFalse(onDisk.contains(identity.deviceId))
assertFalse(onDisk.contains("ikDH"))
assertFalse(onDisk.contains(Bytes.b64(identity.identityDHPublic)))
}
/**
* The failure this catches: a stored blob moved from one slot to another.
*/
@Test
fun `a record moved to another slot does not open`() {
val storage = MapStorage()
val secret = key()
val vault = Vault(storage, secret)
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
val session = E2ESession.initiate(alice, bundleFor(bob))
vault.saveSession("peer-a", session)
val blob = storage.records.getValue("session/peer-a")
storage.records["session/peer-b"] = blob
assertFailsWith<Vault.Corrupt> { vault.loadSession("peer-b") }
// And the record in its own slot is untouched by the attempt.
assertTrue(vault.loadSession("peer-a") != null)
}
@Test
fun `a different key does not open the vault`() {
val storage = MapStorage()
Vault(storage, key()).saveIdentity(DeviceIdentity.create())
assertFailsWith<Vault.Corrupt> { Vault(storage, key()).loadIdentity() }
}
@Test
fun `a flipped bit is refused rather than half-read`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveIdentity(DeviceIdentity.create())
val blob = storage.records.getValue("identity")
blob[blob.size / 2] = (blob[blob.size / 2].toInt() xor 1).toByte()
assertFailsWith<Vault.Corrupt> { Vault(storage, secret).loadIdentity() }
}
/**
* A corrupt identity must NOT read as "no identity".
*/
@Test
fun `an unreadable identity is not mistaken for a missing one`() {
val storage = MapStorage()
val secret = key()
assertNull(Vault(storage, secret).loadIdentity())
assertFalse(Vault(storage, secret).hasIdentity())
Vault(storage, secret).saveIdentity(DeviceIdentity.create())
storage.records["identity"] = ByteArray(64)
assertTrue(Vault(storage, secret).hasIdentity())
assertFailsWith<Vault.Corrupt> { Vault(storage, secret).loadIdentity() }
}
@Test
fun `the inbox watermark round-trips and starts at zero`() {
val storage = MapStorage()
val secret = key()
assertEquals(0, Vault(storage, secret).inboxWatermark)
Vault(storage, secret).inboxWatermark = 4207
assertEquals(4207, Vault(storage, secret).inboxWatermark)
}
@Test
fun `forgetting removes rather than marks`() {
val storage = MapStorage()
val secret = key()
val vault = Vault(storage, secret)
val alice = DeviceIdentity.create()
vault.saveIdentity(alice)
vault.saveSession("peer-a", E2ESession.initiate(alice, bundleFor(DeviceIdentity.create())))
vault.saveSession("peer-b", E2ESession.initiate(alice, bundleFor(DeviceIdentity.create())))
vault.forget("peer-a")
assertFalse(storage.records.keys.any { it.contains("peer-a") })
assertEquals(listOf("peer-b"), vault.knownPeers())
vault.forgetAllSessions()
assertEquals(emptyList(), vault.knownPeers())
assertTrue(vault.hasIdentity(), "signing out of a chat is not signing out of the app")
// Sign-out takes the identity too: the next person to use this phone
// must not inherit a device the relay still has messages for.
vault.forgetEverything()
assertFalse(vault.hasIdentity())
assertEquals(emptyList(), storage.names())
}
@Test
fun `two writes of the same value do not produce the same bytes`() {
// A fresh nonce per write.
val storage = MapStorage()
val secret = key()
val identity = DeviceIdentity.create()
Vault(storage, secret).saveIdentity(identity)
val first = storage.records.getValue("identity").copyOf()
Vault(storage, secret).saveIdentity(identity)
assertNotEquals(Bytes.b64(first), Bytes.b64(storage.records.getValue("identity")))
}
// ---- Trust records and the platform's JSON ---------------------------.
/**
* A contact with no known address must come back with NO address.
*/
@Test
fun `a trust record with no address writes no address at all`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveTrust(7, Vault.Trust(listOf("aaa"), verified = false, fnid = null))
assertNull(
Vault(storage, secret).loadTrust(7)?.fnid,
"an absent address came back as a value",
)
}
/**
* This is the test that reproduces the device, and the one above is not.
*/
@Test
fun `a record already holding the literal string null reads as no address`() {
// Records written the old way exist on phones already.
val storage = MapStorage()
val secret = key()
val vault = Vault(storage, secret)
vault.saveTrust(7, Vault.Trust(listOf("aaa"), verified = false, fnid = "null"))
assertNull(
Vault(storage, secret).loadTrust(7)?.fnid,
"the literal string \"null\" was accepted as somebody's global address",
)
}
@Test
fun `a real address still round-trips`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveTrust(7, Vault.Trust(listOf("aaa"), verified = true, fnid = "fnid:abc123"))
val back = Vault(storage, secret).loadTrust(7)
assertEquals("fnid:abc123", back?.fnid)
assertTrue(back!!.verified)
}
}