Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/Ed25519Test.kt
T
Paul HittandClaude Opus 5 5f983c1455 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>
2026-08-23 17:02:16 -04:00

112 lines
4.6 KiB
Kotlin

package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* ⚠️ The seed→public derivation is checked against the **RFC 8032 §7.1**
* vectors, not against a round-trip through our own code. A round-trip proves
* only that this file agrees with itself, and every wrong clamp, endianness and
* sign convention agrees with itself perfectly. These vectors are what say the
* address a user gets here is the address they get on iOS and on the web.
*/
class Ed25519Test {
private fun hex(s: String) = ByteArray(s.length / 2) {
s.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
/** seed, expected public key, message, expected signature. */
private val vectors = listOf(
Triple(
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"" to "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b",
),
Triple(
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
"72" to "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",
),
Triple(
"c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
"af82" to "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a",
),
)
@Test
fun `a seed derives the public key RFC 8032 says it must`() {
for ((seed, expected, _) in vectors) {
val pair = Ed25519.fromSeed(hex(seed))
assertContentEquals(hex(expected), pair.publicKey,
"seed $seed derived the wrong public key")
}
}
@Test
fun `signatures match the published vectors byte for byte`() {
for ((seed, _, msgSig) in vectors) {
val (message, signature) = msgSig
val pair = Ed25519.fromSeed(hex(seed))
assertContentEquals(hex(signature), Ed25519.sign(pair, hex(message)))
}
}
@Test
fun `the published signatures verify`() {
for ((_, publicKey, msgSig) in vectors) {
val (message, signature) = msgSig
assertTrue(Ed25519.verify(hex(publicKey), hex(signature), hex(message)))
}
}
@Test
fun `a signature does not verify under a different key`() {
val (_, otherKey, _) = vectors[1]
val (_, _, msgSig) = vectors[0]
assertFalse(Ed25519.verify(hex(otherKey), hex(msgSig.second), hex(msgSig.first)))
}
@Test
fun `a flipped bit anywhere is refused`() {
val (seed, publicKey, _) = vectors[2]
val pair = Ed25519.fromSeed(hex(seed))
val message = "prekey bytes".toByteArray()
val signature = Ed25519.sign(pair, message)
for (i in signature.indices step 7) {
val bent = signature.copyOf()
bent[i] = (bent[i].toInt() xor 0x01).toByte()
assertFalse(Ed25519.verify(hex(publicKey), bent, message), "byte $i went undetected")
}
val bentMessage = message.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() }
assertFalse(Ed25519.verify(hex(publicKey), signature, bentMessage))
}
/**
* Garbage from the wire must be a `false`, not an exception. A caller that
* has to catch around every verification eventually catches one badly, and
* a swallowed throw reads as a pass.
*/
@Test
fun `malformed input verifies as false rather than throwing`() {
val (_, publicKey, msgSig) = vectors[0]
assertFalse(Ed25519.verify(hex(publicKey), ByteArray(0), ByteArray(0)))
assertFalse(Ed25519.verify(hex(publicKey), ByteArray(64), ByteArray(0)))
assertFalse(Ed25519.verify(ByteArray(31), hex(msgSig.second), ByteArray(0)))
assertFalse(Ed25519.verify(ByteArray(32), hex(msgSig.second), ByteArray(0)))
}
@Test
fun `a generated keypair round-trips through its raw encoding`() {
val pair = Ed25519.generate()
assertEquals(32, pair.publicKey.size)
val message = "round trip".toByteArray()
assertTrue(Ed25519.verify(pair.publicKey, Ed25519.sign(pair, message), message))
}
}