Android: the app, and the two things only a phone could tell us

There is now a Flamenet Messenger app that installs, launches, creates an
account on a self-hosted relay, registers a device, publishes prekeys, and
stores all of it in an Android Keystore-backed vault. Verified on an emulator
through the actual screens, not by unit test.

92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays.

Two findings, both of which the JVM suite was structurally incapable of
producing.

**Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and
`KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed
on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every
Ed25519 key in this engine is one we must hold: a device identity in our own
vault, an account key derived from a passphrase. So the JCA route could never
have worked. It compiled, passed 81 tests on a laptop, and threw "Not
initialized" on the first launch on a phone. Worse, it was LATENT: a wrong
password failed before any key was minted, so the error path worked perfectly
while the happy path was broken.

Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's
one real dependency, present because the platform leaves a hole, and a strictly
better answer than the non-constant-time keygen it replaces. ProviderProbeTest
records the measurement so nobody guesses again.

**The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the
registration, returned 200, and stored nothing -- so the device was published
and could not be started with, a failure with no error anywhere. Caught by a
live test against the throwaway fleet, which exists because the same path
through the emulator takes four minutes to say "timed out" and nothing about
where.

Two testing traps, recorded where they bite:

An indeterminate progress spinner animates for ever, so Compose's test clock
never goes idle and `waitUntil` never evaluates its condition -- it fails on the
full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is
now disabled around the wait only; disabling it for the whole test is the next
mistake, because the app opens on a spinner while it reads the vault.

Wiping the Keystore in `@Before` deletes the key the already-launched activity
is holding, and every later write fails against a key that no longer exists. The
test signs out through the UI instead, which is both safe and what a person does.

Failure messages now include what the screen was showing. A bare timeout sends
you to the network, the relay and the crypto in turn while the app has been
displaying the reason the whole time.

Also corrects a note that said this Mac had no Android SDK. It does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 17:02:16 -04:00
co-authored by Claude Opus 5
parent b1ba9cdc58
commit 5f983c1455
29 changed files with 1949 additions and 215 deletions
@@ -0,0 +1,191 @@
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())
}
/** 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. Without the name in the AAD, a peer's session pasted into a
* different peer's slot decrypts perfectly and the app attributes their
* messages to the wrong person.
*/
@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". The caller treats
* null as a new install and mints a fresh device, which every contact sees
* as "security keys changed" — the warning that is supposed to mean an
* attack. Absent and unreadable are different answers.
*/
@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. Repeating one under the same key is the
// catastrophic AES-GCM failure, not a degraded one.
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")))
}
}