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,143 @@
package io.flamenet.e2e
import org.json.JSONObject
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Everything the app must remember, encrypted at rest.
*
* ⚠️ **This deliberately does not use [Aead].** `Aead` takes raw key bytes, and
* the key here comes from the Android Keystore, which never hands out its key
* material — that is the entire reason to use it. A design that needed the raw
* bytes would quietly have to keep them somewhere, which is the thing being
* avoided. So this works with a `SecretKey` handle and the platform `Cipher`.
*
* ⚠️ **Every record's name is bound into the AAD.** Without that, a blob is
* portable between slots: someone with file access could paste the session for
* one contact into another contact's slot, and the app would decrypt it
* happily and attribute messages to the wrong person. The name is not a
* filename here, it is part of the ciphertext's meaning.
*
* ⚠️ **Deleting has to actually delete.** "Deleted means deleted" is a promise
* the product makes, so [forget] removes the record rather than marking it, and
* [forgetEverything] takes the identity with it.
*/
class Vault(private val storage: Storage, private val key: SecretKey) {
/** Wherever bytes live. A directory on a phone, a map in a test. */
interface Storage {
fun read(name: String): ByteArray?
fun write(name: String, bytes: ByteArray)
fun delete(name: String)
fun names(): List<String>
}
class Corrupt(name: String, cause: Throwable?) :
Exception("the vault record '$name' did not open", cause)
companion object {
private const val NONCE_LEN = 12
private const val TAG_BITS = 128
private const val IDENTITY = "identity"
private const val WATERMARK = "watermark"
private const val SESSION_PREFIX = "session/"
private val random = SecureRandom()
}
// -- Bytes ---------------------------------------------------------------
private fun seal(name: String, plaintext: ByteArray): ByteArray {
val nonce = ByteArray(NONCE_LEN).also(random::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, nonce))
cipher.updateAAD(name.toByteArray(Charsets.UTF_8))
return nonce + cipher.doFinal(plaintext)
}
private fun open(name: String, box: ByteArray): ByteArray {
if (box.size <= NONCE_LEN) throw Corrupt(name, null)
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE, key,
GCMParameterSpec(TAG_BITS, box.copyOfRange(0, NONCE_LEN)),
)
cipher.updateAAD(name.toByteArray(Charsets.UTF_8))
cipher.doFinal(box, NONCE_LEN, box.size - NONCE_LEN)
} catch (e: Exception) {
// Deliberately one exception for every cause. Which of "wrong key",
// "wrong slot" and "flipped bit" it was is information an attacker
// would like and the user cannot act on.
throw Corrupt(name, e)
}
}
private fun readJson(name: String): JSONObject? =
storage.read(name)?.let { JSONObject(String(open(name, it), Charsets.UTF_8)) }
private fun writeJson(name: String, json: JSONObject) =
storage.write(name, seal(name, json.toString().toByteArray(Charsets.UTF_8)))
// -- Identity ------------------------------------------------------------
/**
* ⚠️ Returning null here means "this is a new install", and the caller will
* mint a fresh device — which every contact sees as "security keys
* changed", the warning that is supposed to mean an attack. So a record
* that exists but will not open must throw rather than read as absent.
*/
fun loadIdentity(): DeviceIdentity? = readJson(IDENTITY)?.let(DeviceIdentity::fromArchive)
fun saveIdentity(identity: DeviceIdentity) = writeJson(IDENTITY, identity.archive())
fun hasIdentity(): Boolean = storage.read(IDENTITY) != null
// -- Sessions ------------------------------------------------------------
private fun sessionName(peerDeviceId: String) = SESSION_PREFIX + peerDeviceId
fun loadSession(peerDeviceId: String): E2ESession? =
readJson(sessionName(peerDeviceId))?.let(E2ESession::fromArchive)
fun saveSession(peerDeviceId: String, session: E2ESession) =
writeJson(sessionName(peerDeviceId), session.archive())
fun knownPeers(): List<String> =
storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) }
// -- Inbox watermark -----------------------------------------------------
/**
* The highest inbox id already **decrypted and stored**.
*
* ⚠️ Advance it on receipt rather than after storing and a crash mid-batch
* loses the rest of the batch silently — the relay will never offer those
* ids again.
*/
var inboxWatermark: Long
get() = readJson(WATERMARK)?.optLong("id", 0) ?: 0
set(value) = writeJson(WATERMARK, JSONObject().put("id", value))
// -- Forgetting ----------------------------------------------------------
/** Forget one conversation. The identity and every other session survive. */
fun forget(peerDeviceId: String) = storage.delete(sessionName(peerDeviceId))
/**
* Forget everything, identity included.
*
* This is sign-out and account deletion. Leaving the identity behind would
* mean the next person to use the phone inherits the device the relay still
* has published — and messages addressed to it.
*/
fun forgetEverything() = storage.names().forEach(storage::delete)
/** Forget conversations but keep this device's identity. */
fun forgetAllSessions() =
storage.names().filter { it.startsWith(SESSION_PREFIX) }.forEach(storage::delete)
}