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:
co-authored by
Claude Opus 5
parent
b1ba9cdc58
commit
5f983c1455
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user