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
@@ -27,7 +27,7 @@ import java.security.KeyPair
class DeviceIdentity private constructor(
val deviceId: String,
val identityDH: KeyPair,
val identitySigning: KeyPair,
val identitySigning: Ed25519.KeyPair,
var signedPreKey: KeyPair,
var signedPreKeyId: Int,
var signedPreKeyCreatedAt: Long,
@@ -98,7 +98,7 @@ class DeviceIdentity private constructor(
}
val identityDHPublic: ByteArray get() = X25519.rawFromPublicKey(identityDH.public)
val identitySigningPublic: ByteArray get() = Ed25519.rawFromPublicKey(identitySigning.public)
val identitySigningPublic: ByteArray get() = identitySigning.publicKey
/**
* The signature a peer checks before trusting the signed prekey.
@@ -108,7 +108,7 @@ class DeviceIdentity private constructor(
* why [E2ESession.initiate] does, unconditionally.
*/
fun signedPreKeySignature(): ByteArray =
Ed25519.sign(identitySigning.private, X25519.rawFromPublicKey(signedPreKey.public))
Ed25519.sign(identitySigning, X25519.rawFromPublicKey(signedPreKey.public))
fun generateOneTimePreKeys(count: Int): List<PreKey> = (0 until count).map {
val id = nextPreKeyId++
@@ -172,7 +172,11 @@ class DeviceIdentity private constructor(
.put("spk", Bytes.b64(X25519.rawFromPublicKey(signedPreKey.public)))
.put("spk_sig", Bytes.b64(signedPreKeySignature()))
.put("proto", 1)
.put("opks", org.json.JSONArray().also { array ->
// ⚠️ `prekeys`, NOT `opks`. Guessed wrong once; the relay accepted the
// registration, returned 200, and silently stored no prekeys — so the
// device was published and unreachable, which is the worst of both. A
// live test against a real relay is what caught it.
.put("prekeys", org.json.JSONArray().also { array ->
oneTime.forEach {
array.put(JSONObject().put("id", it.id).put("pub", Bytes.b64(it.publicKey)))
}
@@ -206,7 +210,7 @@ class DeviceIdentity private constructor(
// because that is the one form both the JCA and a passphrase-derived
// account key agree on.
.put("ikSig", JSONObject()
.put("seed", Bytes.b64(Ed25519.seedOf(identitySigning.private)))
.put("seed", Bytes.b64(identitySigning.seed))
.put("pub", Bytes.b64(identitySigningPublic)))
.put("spk", x(signedPreKey))
.put("spkId", signedPreKeyId)
+49 -177
View File
@@ -1,216 +1,88 @@
package io.flamenet.e2e
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import org.bouncycastle.math.ec.rfc8032.Ed25519 as Rfc8032
import java.security.SecureRandom
/**
* Ed25519 signatures — the signing half of an identity.
*
* Two distinct jobs use this. A **device** signs its prekeys, so a peer
* fetching a bundle from an untrusted relay can tell the relay did not
* substitute keys of its own. An **account** signs its home records and its
* substitute keys of its own, and signs each relay request to prove it holds
* the key its token names. An **account** signs its home records and its
* transparency-log claims, and its public half *is* the `fnid`.
*
* Keys move as raw 32-byte values on the wire and as JCA objects in memory, for
* the same reason as [X25519]: Android's provider will not build an EdDSA key
* from raw bytes, it wants a DER-encoded spec. An implementation that passed
* raw bytes to the JCA compiles, passes on a laptop, and throws on a phone.
* ⚠️ **This does not go through the JCA, and that is not a preference.**
* Android serves `KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` from
* `AndroidKeyStore` and from nowhere else — measured on API 35, see
* `ProviderProbeTest` — and Keystore only ever produces keys it keeps. Every
* Ed25519 key here is one we must hold ourselves: a device identity that lives
* in our own vault, and an account key derived from a passphrase. A JCA
* implementation compiles, passes 81 tests on a laptop, and throws
* `IllegalStateException: Not initialized` on the first launch on a phone.
* That is precisely what happened.
*
* ⚠️ **A seed is not a private key.** Ed25519's 32-byte seed expands, by
* hashing, into the actual scalar and the nonce prefix. `AccountKdf` derives a
* *seed*, and every implementation must expand it the same way — which they do,
* because RFC 8032 fixes it. Feeding a seed somewhere a scalar is expected
* produces a valid-looking key with a different public half, which surfaces as
* a stranger's `fnid` rather than as an error.
* hashing, into the actual scalar and the nonce prefix. [AccountKdf] derives a
* *seed*. Feeding a seed where a scalar is expected produces a valid-looking
* key with a different public half, which surfaces as a stranger's `fnid`
* rather than as an error.
*/
object Ed25519 {
const val KEY_LEN = 32
const val SIG_LEN = 64
const val KEY_LEN = Rfc8032.SECRET_KEY_SIZE // 32
const val SIG_LEN = Rfc8032.SIGNATURE_SIZE // 64
private val random = SecureRandom()
/**
* X.509 wrapper for a public key: SEQUENCE, the id-Ed25519 OID
* (1.3.101.112), then a 33-byte BIT STRING. Identical in shape to X25519's,
* and differing in one byte of the OID — which is precisely why it is
* written out here rather than shared with a `when`: a single wrong byte
* yields the *other* algorithm's key type, and the JCA reports that as an
* opaque `InvalidKeySpecException`.
* A keypair as the two things that actually exist: the seed we store, and
* the public half we publish.
*
* Not a `java.security.KeyPair`, because there is no JCA key object behind
* this on Android — and a type that implies one would invite somebody to
* pass it somewhere that needs a real `PrivateKey`.
*/
private val SPKI_PREFIX = byteArrayOf(
0x30, 0x2a, // SEQUENCE, 42 bytes
0x30, 0x05, // SEQUENCE, 5 bytes
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused
)
data class KeyPair(val seed: ByteArray, val publicKey: ByteArray) {
override fun equals(other: Any?): Boolean =
other is KeyPair && seed.contentEquals(other.seed) &&
publicKey.contentEquals(other.publicKey)
/**
* PKCS#8 wrapper for a seed. The seed sits inside an OCTET STRING nested in
* another OCTET STRING — a quirk of RFC 8410, not a mistake here.
*/
private val PKCS8_PREFIX = byteArrayOf(
0x30, 0x2e, // SEQUENCE, 46 bytes
0x02, 0x01, 0x00, // INTEGER 0 (version)
0x30, 0x05, // SEQUENCE, 5 bytes
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
0x04, 0x22, // OCTET STRING, 34 bytes
0x04, 0x20, // OCTET STRING, 32 bytes
)
override fun hashCode(): Int = publicKey.contentHashCode()
}
fun generate(): KeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
fun generate(): KeyPair = fromSeed(ByteArray(KEY_LEN).also(random::nextBytes))
/** A keypair from a 32-byte seed — the bridge from [AccountKdf] to a usable key. */
/** The bridge from [AccountKdf] — and from the vault — to a usable key. */
fun fromSeed(seed: ByteArray): KeyPair {
require(seed.size == KEY_LEN) { "an Ed25519 seed is $KEY_LEN bytes, got ${seed.size}" }
val factory = KeyFactory.getInstance("Ed25519")
val private = factory.generatePrivate(PKCS8EncodedKeySpec(PKCS8_PREFIX + seed))
// The public half is not recoverable from a PKCS#8 seed through the JCA
// without re-deriving it, so it is derived the only portable way: sign
// nothing and let the provider hand back the matching public key.
return KeyPair(publicFromPrivate(private), private)
val publicKey = ByteArray(Rfc8032.PUBLIC_KEY_SIZE)
Rfc8032.generatePublicKey(seed, 0, publicKey, 0)
return KeyPair(seed.copyOf(), publicKey)
}
fun publicKeyFromRaw(raw: ByteArray): java.security.PublicKey {
require(raw.size == KEY_LEN) { "an Ed25519 public key is $KEY_LEN bytes, got ${raw.size}" }
return KeyFactory.getInstance("Ed25519")
.generatePublic(X509EncodedKeySpec(SPKI_PREFIX + raw))
fun sign(seed: ByteArray, message: ByteArray): ByteArray {
require(seed.size == KEY_LEN) { "an Ed25519 seed is $KEY_LEN bytes, got ${seed.size}" }
val signature = ByteArray(SIG_LEN)
Rfc8032.sign(seed, 0, message, 0, message.size, signature, 0)
return signature
}
fun rawFromPublicKey(key: java.security.PublicKey): ByteArray {
val encoded = key.encoded
require(encoded.size == SPKI_PREFIX.size + KEY_LEN) {
"unexpected Ed25519 SPKI length ${encoded.size}"
}
return encoded.copyOfRange(SPKI_PREFIX.size, encoded.size)
}
fun sign(privateKey: java.security.PrivateKey, message: ByteArray): ByteArray {
val signer = Signature.getInstance("Ed25519")
signer.initSign(privateKey)
signer.update(message)
return signer.sign()
}
fun sign(key: KeyPair, message: ByteArray): ByteArray = sign(key.seed, message)
/**
* Verify, returning false rather than throwing.
*
* A malformed key or a truncated signature is a *failed verification*, not
* an exceptional condition: both arrive over the wire from someone who may
* be hostile, and a caller that has to wrap every check in a try/catch
* eventually wraps one badly and treats a thrown exception as a pass.
* be hostile, and a caller forced to wrap every check in a try/catch
* eventually wraps one badly and reads a thrown exception as a pass.
*/
fun verify(publicKeyRaw: ByteArray, signature: ByteArray, message: ByteArray): Boolean =
fun verify(publicKey: ByteArray, signature: ByteArray, message: ByteArray): Boolean =
try {
if (signature.size != SIG_LEN) false else {
val verifier = Signature.getInstance("Ed25519")
verifier.initVerify(publicKeyFromRaw(publicKeyRaw))
verifier.update(message)
verifier.verify(signature)
}
signature.size == SIG_LEN && publicKey.size == KEY_LEN &&
Rfc8032.verify(signature, 0, publicKey, 0, message, 0, message.size)
} catch (_: Exception) {
false
}
/**
* Derive the public half of a private key.
*
* The JCA offers no route from a private EdDSA key to its public one, so
* this does the RFC 8032 §5.1.5 derivation itself: hash the seed, clamp the
* low half into a scalar, and multiply the base point by it.
*/
private fun publicFromPrivate(privateKey: java.security.PrivateKey): java.security.PublicKey =
publicKeyFromRaw(Rfc8032.publicKeyFromSeed(seedOf(privateKey)))
/**
* The 32 seed bytes back out of a PKCS#8 encoding.
*
* Public because the vault stores a seed, not a DER blob: it is the one
* representation the JCA, CryptoKit and a passphrase-derived account key
* all agree on.
*/
fun seedOf(privateKey: java.security.PrivateKey): ByteArray {
val encoded = privateKey.encoded
require(encoded.size == PKCS8_PREFIX.size + KEY_LEN) {
"unexpected Ed25519 PKCS#8 length ${encoded.size}"
}
return encoded.copyOfRange(PKCS8_PREFIX.size, encoded.size)
}
}
/**
* Just enough Ed25519 arithmetic to turn a seed into its public key.
*
* This exists for exactly one reason: an account's address is derived from a
* passphrase, so the client must be able to compute `A = a·B` for a seed the
* KDF produced. Nothing else here reimplements a primitive — signing and
* verification go to the platform provider, which is audited and
* constant-time. Keep it that way.
*
* ⚠️ **This is not constant-time.** `BigInteger` branches on values, so the
* scalar multiplication below leaks timing about a secret scalar. The exposure
* is bounded and deliberate: it runs once, at sign-in, in the user's own
* process, on a device where an attacker able to measure it can already read
* the passphrase. It would NOT be acceptable for signing, which is why signing
* is not here.
*
* Verified against the RFC 8032 §7.1 test vectors rather than against a reading
* of this code.
*/
private object Rfc8032 {
private val P = java.math.BigInteger.TWO.pow(255) - java.math.BigInteger.valueOf(19)
private val D = java.math.BigInteger(
"37095705934669439343138083508754565189542113879843219016388785533085940283555")
private val BX = java.math.BigInteger(
"15112221349535400772501151409588531511454012693041857206046113283949847762202")
private val BY = java.math.BigInteger(
"46316835694926478169428394003475163141307993866256225615783033603165251855960")
/** Affine points on -x² + y² = 1 + d·x²·y². The identity is (0, 1). */
private data class Point(val x: java.math.BigInteger, val y: java.math.BigInteger)
private fun add(a: Point, b: Point): Point {
val xx = a.x * b.x
val yy = a.y * b.y
val dxy = D * xx % P * yy % P
val x = (a.x * b.y + b.x * a.y) * (java.math.BigInteger.ONE + dxy).modInverse(P) % P
val y = (yy + xx) * (java.math.BigInteger.ONE - dxy).modInverse(P) % P
return Point(x.mod(P), y.mod(P))
}
fun publicKeyFromSeed(seed: ByteArray): ByteArray {
val h = Bytes.sha512(seed)
// Clamp (RFC 8032 §5.1.5): clearing the low three bits puts the scalar
// in the prime-order subgroup, and forcing bit 254 fixes its length so
// a ladder cannot leak it. Both are required; a key derived without
// them is a different key, not a weaker one.
val s = h.copyOfRange(0, 32)
s[0] = (s[0].toInt() and 0xF8).toByte()
s[31] = (s[31].toInt() and 0x7F or 0x40).toByte()
var scalar = java.math.BigInteger.ZERO
for (i in 31 downTo 0) {
scalar = scalar.shiftLeft(8) + java.math.BigInteger.valueOf((s[i].toInt() and 0xFF).toLong())
}
// Double-and-add, from the top bit down.
var result = Point(java.math.BigInteger.ZERO, java.math.BigInteger.ONE)
var addend = Point(BX, BY)
var bit = 0
while (bit < 255) {
if (scalar.testBit(bit)) result = add(result, addend)
addend = add(addend, addend)
bit += 1
}
// Encode: y little-endian, with the parity of x in the top bit.
val out = ByteArray(32)
val y = result.y
for (i in 0 until 32) out[i] = y.shiftRight(i * 8).toInt().toByte()
if (result.x.testBit(0)) out[31] = (out[31].toInt() or 0x80).toByte()
return out
}
}
@@ -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)
}