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,138 @@
package io.flamenet.client
import io.flamenet.e2e.AccountKdf
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.security.SecureRandom
/**
* The shared FlameNet account service — one credential across Messenger and
* Files.
*
* ⚠️ **The passphrase never leaves the device.** It is stretched here and only
* the derived secret is sent, which is why sign-in is two round trips rather
* than one: the salt and iteration count are per account, and the service
* cannot derive the secret itself. That is the entire point, and collapsing it
* to one call would mean sending the passphrase.
*
* Used only for relays whose accounts live in the shared service. A self-hosted
* relay authenticates against its own table and never touches this — see
* [SiteConfig], which decides from the hostname rather than from anything the
* relay says about itself.
*/
class AccountsClient(
baseUrl: String = "https://accounts.flamenet.io",
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
) {
private val base = baseUrl.trimEnd('/').toHttpUrl()
/** The product name this client asks for a session under. */
private val product = "messenger"
/**
* What the service advertises for a handle.
*
* ⚠️ An unknown handle gets a **deterministic decoy** salt, not an error,
* so this route cannot be used to test whether somebody has an account.
* Treat a success as "here are parameters", never as "this handle exists" —
* a UI that says "no such account" here undoes the whole design.
*/
data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int)
data class Session(val token: String, val pseudonym: String, val expiresAt: String)
sealed class Failure(message: String) : Exception(message) {
/** Wrong handle and wrong passphrase are the same answer, on purpose. */
object BadCredentials : Failure("that handle or passphrase isn't right")
object HandleTaken : Failure("that handle is already taken")
object RateLimited : Failure("too many attempts — wait a few minutes")
class Service(val status: Int) : Failure("the account service returned $status")
class Transport(cause: String) : Failure("couldn't reach the account service: $cause")
}
fun parameters(handle: String): Params {
val url = base.newBuilder().addPathSegment("accounts").addPathSegment(handle)
.addPathSegment("params").build()
val json = send(Request.Builder().url(url).get().build())
return Params(
salt = Wire.unb64(json.getString("kdfSalt")),
kdfId = json.getInt("kdfID"),
iterations = json.getInt("kdfIterations"),
)
}
fun signIn(handle: String, passphrase: String): Session {
val params = parameters(handle)
// ⚠️ The ADVERTISED iteration count wins over the local constant. An
// account created under an older count must still be derivable, and the
// service is the one that knows which — hardcoding it here silently
// locks out every older account.
val effective = AccountKdf.Params(
id = params.kdfId,
iterations = params.iterations,
saltBytes = params.salt.size,
)
val secret = AccountKdf.authSecret(passphrase, params.salt, effective)
val json = post("sessions", JSONObject()
.put("handle", handle)
.put("authSecret", Wire.b64(secret))
.put("product", product))
return Session(
token = json.getString("token"),
pseudonym = json.optString("pseudonym", ""),
expiresAt = json.optString("expiresAt", ""),
)
}
/** Create an account. The salt is generated here, not by the service. */
fun register(handle: String, passphrase: String) {
val params = AccountKdf.Params.PBKDF2_V1
val salt = ByteArray(params.saltBytes).also { SecureRandom().nextBytes(it) }
val secret = AccountKdf.authSecret(passphrase, salt, params)
post("accounts", JSONObject()
.put("handle", handle)
.put("authSecret", Wire.b64(secret))
.put("kdfSalt", Wire.b64(salt))
.put("kdfIterations", params.iterations))
}
private fun post(path: String, body: JSONObject): JSONObject = send(
Request.Builder()
.url(base.newBuilder().addPathSegments(path).build())
.post(body.toString().toRequestBody(JSON))
.header("Accept", "application/json")
.build()
)
private fun send(request: Request): JSONObject {
val response = try {
http.newCall(request).execute()
} catch (e: Exception) {
throw Failure.Transport(e.message ?: e.toString())
}
response.use {
val text = it.body?.string().orEmpty()
when (it.code) {
in 200..299 -> return if (text.isBlank()) JSONObject() else try {
JSONObject(text)
} catch (_: Exception) {
throw Failure.Transport("unreadable response")
}
401, 403 -> throw Failure.BadCredentials
409 -> throw Failure.HandleTaken
429 -> throw Failure.RateLimited
else -> throw Failure.Service(it.code)
}
}
}
private companion object {
val JSON = "application/json".toMediaType()
}
}
@@ -0,0 +1,132 @@
package io.flamenet.client
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* A relay's own account table — the identity provider a self-hosted relay is.
*
* This is what keeps self-hosting **one** service rather than two. A relay with
* no shared account service configured still signs people up, signs them in and
* resets their passwords, on its own.
*
* ⚠️ **What comes back is not the password.** The relay mints a long-lived
* credential, returns it once, and keeps only its hash. That credential is what
* every later request carries; the password is used at this moment and then
* should not be kept.
*/
class RelayAccounts(
baseUrl: String,
private val http: OkHttpClient = RelayClient.defaultHttpClient(),
) {
private val base = baseUrl.trimEnd('/').toHttpUrl()
/** Who the relay says you are, and the credential to prove it with. */
data class Account(
val userId: Long,
val username: String,
val displayName: String,
val appPassword: String,
)
fun signUp(username: String, email: String, password: String, displayName: String? = null): Account =
account(post("account/signup", JSONObject()
.put("username", username)
.put("email", email)
.put("password", password)
.put("display_name", displayName ?: username)))
/**
* ⚠️ The field is "username **or email**" and always has been. A client
* that only sends usernames rejects half the people who typed exactly what
* they were asked for.
*/
fun signIn(usernameOrEmail: String, password: String): Account =
account(post("account/signin", JSONObject()
.put("username", usernameOrEmail)
.put("password", password)))
/**
* Exchange a shared FlameNet account session for a relay session.
*
* ⚠️ Present only on a relay configured to trust an account service. A
* self-hosted relay answers 404 here, and that is the correct answer rather
* than a misconfiguration — it is its own identity provider.
*
* The account service's token carries a **product-scoped pseudonym**, not
* the handle, so the relay learns an opaque id and nothing that ties the
* person to any other FlameNet product.
*/
fun sharedSession(accountToken: String): Account =
account(post("api/flamenet/v1/messenger/session", JSONObject().put("token", accountToken)))
/**
* Ask the relay to send a reset code.
*
* ⚠️ The relay answers identically whatever the truth is — unknown handle,
* no address on file, an account that authenticates elsewhere. So this
* cannot report whether it worked, and a UI that claims it can is lying.
* Anything thrown here is a transport or server failure, never "no such
* account".
*/
fun requestPasswordReset(handle: String) {
post("account/reset/request", JSONObject().put("handle", handle))
}
fun confirmPasswordReset(handle: String, code: String, newPassword: String) {
post("account/reset/confirm", JSONObject()
.put("handle", handle)
.put("code", code)
.put("password", newPassword))
}
private fun account(json: JSONObject) = Account(
userId = json.getLong("user_id"),
username = json.getString("username"),
displayName = json.optString("display_name", json.getString("username")),
appPassword = json.getString("app_password"),
)
private fun post(path: String, body: JSONObject): JSONObject {
val request = Request.Builder()
.url(base.newBuilder().addPathSegments(path).build())
.post(body.toString().toRequestBody(JSON))
.header("Accept", "application/json")
.build()
val response = try {
http.newCall(request).execute()
} catch (e: Exception) {
throw RelayException.Transport(e.message ?: e.toString(), e)
}
response.use {
val text = it.body?.string().orEmpty()
if (it.isSuccessful) {
return if (text.isBlank()) JSONObject() else try {
JSONObject(text)
} catch (_: Exception) {
throw RelayException.Malformed("the relay's reply was not a JSON object")
}
}
// The relay puts a human-readable reason in the body, and it is
// better than anything this layer could invent from a status code.
val message = runCatching { JSONObject(text).optString("message") }
.getOrNull().orEmpty().ifBlank { text }
throw when (it.code) {
401, 403 -> RelayException.Unauthorized(message)
404 -> RelayException.NotFound(path)
409 -> RelayException.Http(409, message)
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
else -> RelayException.Http(it.code, message)
}
}
}
private companion object {
val JSON = "application/json".toMediaType()
}
}
@@ -2,7 +2,6 @@ package io.flamenet.client
import io.flamenet.e2e.Ed25519
import java.security.MessageDigest
import java.security.PrivateKey
import java.util.UUID
/**
@@ -28,8 +27,7 @@ import java.util.UUID
*/
class RelayAuth(
private val deviceId: String,
private val identityKey: PrivateKey,
private val identityPublic: ByteArray,
private val identityKey: Ed25519.KeyPair,
private val mint: (deviceId: String, identityPublicB64: String) -> Minted?,
) {
@@ -56,7 +54,7 @@ class RelayAuth(
if (unavailable) return null
cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token }
val minted = try {
mint(deviceId, Wire.b64(identityPublic))
mint(deviceId, Wire.b64(identityKey.publicKey))
} catch (_: RelayException.NotFound) {
unavailable = true
return null
@@ -68,8 +68,7 @@ class RelayClient(
fun useRelayAuth(identity: DeviceIdentity) {
auth = RelayAuth(
deviceId = identity.deviceId,
identityKey = identity.identitySigning.private,
identityPublic = identity.identitySigningPublic,
identityKey = identity.identitySigning,
mint = ::mintToken,
)
}
@@ -84,7 +83,7 @@ class RelayClient(
fun uploadPreKeys(deviceId: String, preKeys: List<DeviceIdentity.PreKey>) {
postJson("/e2e/prekeys", JSONObject()
.put("device_id", deviceId)
.put("opks", JSONArray().also { array ->
.put("prekeys", JSONArray().also { array ->
preKeys.forEach {
array.put(JSONObject().put("id", it.id).put("pub", Wire.b64(it.publicKey)))
}
@@ -0,0 +1,52 @@
package io.flamenet.client
import io.flamenet.e2e.DeviceIdentity
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The whole sign-up-and-register path against a throwaway relay.
*
* ⚠️ Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
* than production. It creates accounts, which is exactly what a disposable
* relay is for and exactly what `relay.flamenet.io` is not.
*
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
*
* This exists because the same path through the emulator takes four minutes to
* tell you it failed, and tells you nothing about where.
*/
class LiveSignUpTest {
private val relay: String? = System.getenv("FN_LIVE_FLEET")
@Test
fun `a new account can register a device and publish prekeys`() {
val base = relay ?: return
val handle = "jvmt" + (System.currentTimeMillis() % 100_000_000)
val account = RelayAccounts(base).signUp(
username = handle,
email = "$handle@example.invalid",
password = "a-long-enough-passphrase",
)
assertEquals(handle, account.username)
assertTrue(account.appPassword.isNotEmpty())
val identity = DeviceIdentity.create()
val client = RelayClient(base, username = account.username, appPassword = account.appPassword)
client.useRelayAuth(identity)
val preKeys = identity.generateOneTimePreKeys(20)
client.registerDevice(identity, preKeys)
// The device is only really registered if a stranger can fetch a bundle
// for it — that is what makes this phone reachable.
val bundles = client.fetchKeys(account.userId)
assertTrue(bundles.any { it.deviceId == identity.deviceId },
"the relay did not serve a bundle for the device we just registered")
assertTrue(bundles.first { it.deviceId == identity.deviceId }.oneTimePreKey != null,
"the relay served no one-time prekey, so the pool never landed")
}
}
@@ -86,8 +86,11 @@ class RelayClientTest {
assertEquals(Wire.b64(identity.identitySigningPublic), body.getString("ik_sig"))
assertEquals(identity.signedPreKeyId, body.getInt("spk_id"))
assertTrue(body.has("spk") && body.has("spk_sig"))
assertEquals(2, body.getJSONArray("opks").length())
assertEquals(preKeys[0].id, body.getJSONArray("opks").getJSONObject(0).getInt("id"))
// ⚠️ `prekeys`, not `opks`. The relay accepts a registration carrying
// the wrong name with a 200 and stores no prekeys, so the device is
// published and cannot be started with — a failure with no error.
assertEquals(2, body.getJSONArray("prekeys").length())
assertEquals(preKeys[0].id, body.getJSONArray("prekeys").getJSONObject(0).getInt("id"))
// The published prekey signature must verify under the published
// identity key, or every peer refuses the bundle.