From b1ba9cdc58486dd15a60c53efb5f03f315cb4f4d Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 23 Aug 2026 15:37:59 -0400 Subject: [PATCH] Kotlin: the relay transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine can hold a conversation but had no way to reach a relay. This is that: the §8 routes, both authentication modes, and the transparency log. 13 tests against a real HTTP server rather than a hand-written fake. That distinction is the point -- a fake written alongside the client agrees with it about everything including the field name it got wrong, which is exactly how this protocol's one shipped bug survived until a live round trip. Two more tests run against the deployed relay, opt-in via FN_LIVE_RELAY and silent when unset. A test that reaches the network cannot sit in a hermetic suite; it fails on an aeroplane and a suite that is red for unrelated reasons stops being read. What they check is the thing MockWebServer structurally cannot: that the URL this client builds is one relay.flamenet.io actually serves. It does, on both mounts, and the Kotlin engine verified an inclusion proof from the production log against a head fetched separately -- not against the root the proof response carried, because a server that supplies both halves of a comparison has proven nothing. Details that are load-bearing: The per-request signature covers the LOGICAL section-8 path, not the URL's. The relay mounts these routes twice and signs against the former, so signing the REST-prefixed path yields a valid signature over the wrong string and a refusal with no hint as to why. Tested by asserting the other one does NOT verify. The nonce is not decoration. Two inbox polls in the same second with the same empty body would otherwise produce identical signatures, and the relay's replay cache cannot tell an honest repeat from a captured one -- so it refuses the honest client and the app quietly stops receiving mail. Token minting is lazy, and a deployment with no token route is asked exactly once. Laziness cost an hour here: the tests hung, because takeRequest() blocks forever and I was waiting for a mint that had not happened yet. They now use a bounded read, so that mistake fails instead of looking like a hung build. Application passwords are stripped of spaces. The account service prints them in readable groups and users paste what they were shown; sending the spaces is a 401 nobody can diagnose, because the password on screen looks right. Kotlin nests block comments, so `/e2e/` followed by a star inside a doc comment opens one that never closes and the compiler blames the last line of the file. Co-Authored-By: Claude Opus 5 --- PARITY.md | 11 +- client/build.gradle.kts | 40 +++ .../kotlin/io/flamenet/client/RelayAuth.kt | 108 ++++++ .../kotlin/io/flamenet/client/RelayClient.kt | 336 ++++++++++++++++++ .../io/flamenet/client/RelayException.kt | 47 +++ .../io/flamenet/client/LiveRelayTest.kt | 65 ++++ .../io/flamenet/client/RelayClientTest.kt | 328 +++++++++++++++++ settings.gradle.kts | 3 + 8 files changed, 933 insertions(+), 5 deletions(-) create mode 100644 client/build.gradle.kts create mode 100644 client/src/main/kotlin/io/flamenet/client/RelayAuth.kt create mode 100644 client/src/main/kotlin/io/flamenet/client/RelayClient.kt create mode 100644 client/src/main/kotlin/io/flamenet/client/RelayException.kt create mode 100644 client/src/test/kotlin/io/flamenet/client/LiveRelayTest.kt create mode 100644 client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt diff --git a/PARITY.md b/PARITY.md index 4aa23ee..f1d5228 100644 --- a/PARITY.md +++ b/PARITY.md @@ -15,8 +15,8 @@ screen renders. | | iOS | Android | |---|---|---| | Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files | -| App layer | ~9,700 lines, 45 files | none yet | -| Tests | XCUITest + JS interop | 67 unit + 37 cross-engine interop | +| App layer | ~9,700 lines, 45 files | transport only, no UI | +| Tests | XCUITest + JS interop | 81 unit + 37 cross-engine + 2 live-relay | | Ships to | nobody (no signing team) | nobody (no app module yet) | Android's engine is **verified against the web engine in both directions** @@ -131,9 +131,10 @@ order and not a nicer-looking one. 1. ~~**Session layer** — envelope codec, device identity, archive/restore.~~ ✅ Cross-engine verified at the envelope level, restarts included. -2. **Vault** — Android Keystore, encryption at rest for the archive above. -3. **`:client`** — REST, relay auth, device registration, prekey upload, inbox - polling, attachments, groups. +2. ~~**`:client`** — REST, relay auth, device registration, prekey upload, + inbox polling, attachments, groups, transparency log.~~ ✅ 13 tests against + a real HTTP server, plus an opt-in check against the deployed relay. +3. **Vault** — Android Keystore, encryption at rest for the session archive. 4. **`:app`** — Compose UI, in the order above: sign-in, buddy list, chat, then the rest. 5. **Instrumented tests** — the JVM tests prove nothing about Conscrypt. This diff --git a/client/build.gradle.kts b/client/build.gradle.kts new file mode 100644 index 0000000..43d33a3 --- /dev/null +++ b/client/build.gradle.kts @@ -0,0 +1,40 @@ +// The relay transport. +// +// Plain Kotlin/JVM like `:engine`, and for the same reason: HTTP against a +// relay has nothing to do with Android, and keeping it off the platform means +// its tests run against a real local server on any machine, with no emulator. +// OkHttp works identically on both. +plugins { + kotlin("jvm") +} + +repositories { mavenCentral() } + +dependencies { + api(project(":engine")) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + compileOnly("org.json:json:20240303") + + testImplementation(kotlin("test")) + testImplementation("org.json:json:20240303") + // A real HTTP server, so the tests exercise headers, status codes and + // bodies rather than a hand-written fake that agrees with the client about + // everything including its mistakes. + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.test { + useJUnitPlatform() + testLogging { events("passed", "failed", "skipped") } +} diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt b/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt new file mode 100644 index 0000000..2d68887 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt @@ -0,0 +1,108 @@ +package io.flamenet.client + +import io.flamenet.e2e.Ed25519 +import java.security.MessageDigest +import java.security.PrivateKey +import java.util.UUID + +/** + * Authentication for the relay's `/e2e/…` routes (spec §8.3). + * + * The relay refuses the account's own password on these routes, and that is a + * design decision rather than an oversight to work around. A credential good + * for every route on every one of an account's devices means a token lifted + * from one device drains another's prekey pool and reads its inbox. What the + * relay wants instead is a short-lived token scoped to **one device and one + * relay**, plus a per-request signature proving the sender holds the identity + * key that token names — so a stolen token, on its own, is inert. + * + * ⚠️ **The nonce is not decoration.** Without it, two inbox polls in the same + * second with the same empty body produce byte-identical signatures, and the + * relay's replay cache cannot tell an honest repeat from a captured one. It + * then refuses the honest client, and the symptom is an app that stops + * receiving mail for no visible reason. + * + * A deployment with no token endpoint answers 404 once and is then asked no + * more; the caller falls back to Basic. Discovered rather than configured, so + * one build talks to both. + */ +class RelayAuth( + private val deviceId: String, + private val identityKey: PrivateKey, + private val identityPublic: ByteArray, + private val mint: (deviceId: String, identityPublicB64: String) -> Minted?, +) { + + /** A minted token and the absolute second it stops being accepted. */ + data class Minted(val token: String, val lifetimeSeconds: Int) + + private class Cached(val token: String, val expiresAt: Long) + + @Volatile private var cached: Cached? = null + + /** + * Set once the relay answers "no such route". + * + * Without it, a deployment that will never mint a token is asked on every + * single request — a round trip per call, forever, to learn the same thing. + */ + @Volatile private var unavailable = false + + /** + * A valid token, minting one when the cache is empty or close to expiry. + * Null when this deployment does not issue them. + */ + fun token(now: Long = System.currentTimeMillis() / 1000): String? { + if (unavailable) return null + cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token } + val minted = try { + mint(deviceId, Wire.b64(identityPublic)) + } catch (_: RelayException.NotFound) { + unavailable = true + return null + } catch (_: Exception) { + return null + } ?: return null + cached = Cached(minted.token, now + minted.lifetimeSeconds) + return minted.token + } + + /** Discard the cached token, so one stale token costs a single retry. */ + fun invalidate() { cached = null } + + /** + * The proof headers for one request. + * + * ⚠️ `path` is the **logical §8 path**, not the URL's path. The relay mounts + * these routes at both `/e2e/…` and under a REST prefix, and signs against + * the former — so the proof must be computed over the same string whichever + * mount the request is addressed to. Signing the URL path instead produces + * a valid signature over the wrong thing, and the relay rejects it with no + * hint as to why. + */ + fun proofHeaders( + method: String, + path: String, + body: ByteArray, + now: Long = System.currentTimeMillis() / 1000, + ): Map { + val timestamp = now.toString() + val nonce = UUID.randomUUID().toString() + val digest = MessageDigest.getInstance("SHA-256").digest(body) + .joinToString("") { "%02x".format(it) } + val canonical = "$method\n$path\n$timestamp\n$nonce\n$digest".toByteArray(Charsets.UTF_8) + return mapOf( + "X-FN-Timestamp" to timestamp, + "X-FN-Nonce" to nonce, + "X-FN-Signature" to Wire.b64(Ed25519.sign(identityKey, canonical)), + ) + } + + private companion object { + /** + * A token that expires between being chosen and the request landing is + * a 401 the caller cannot distinguish from a real one. + */ + const val SLACK_SECONDS = 60 + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt new file mode 100644 index 0000000..fe3312e --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -0,0 +1,336 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Envelope +import io.flamenet.e2e.PeerBundle +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * The relay's §8 routes. + * + * ⚠️ **Field names are the protocol.** The one bug this system has actually + * shipped was a client and a relay disagreeing about a field name, and nothing + * caught it until a live round trip — not the type checker, not a unit test. + * So every name here appears exactly once, and the tests run against a real + * HTTP server rather than a fake that agrees with this file by construction. + * + * Two authentication modes, discovered rather than configured, so one build + * talks to a Swift relay and to an older WordPress deployment. See [RelayAuth]. + */ +class RelayClient( + baseUrl: String, + private val username: String, + appPassword: String, + private val restPrefix: String = "api", + private val http: OkHttpClient = defaultHttpClient(), +) { + + private val base: HttpUrl = baseUrl.trimEnd('/').toHttpUrl() + + /** + * Spaces are stripped because the account service prints application + * passwords in readable groups, and users paste what they were shown. + */ + private val appPassword = appPassword.replace(" ", "") + + private var auth: RelayAuth? = null + + companion object { + private val JSON = "application/json".toMediaType() + private val OCTETS = "application/octet-stream".toMediaType() + + fun defaultHttpClient(): OkHttpClient = OkHttpClient.Builder() + .callTimeout(25, TimeUnit.SECONDS) + // No response cache, deliberately. The payloads are ciphertext, but + // sealed blobs and envelope traffic surviving on disk after the user + // deletes a chat is exactly what "deleted means deleted" forbids. + .cache(null) + .build() + } + + /** + * Adopt token authentication. + * + * Not a constructor parameter: the client is built during sign-in, before a + * device identity has been generated or loaded, and it has real work to do + * in that window. + */ + fun useRelayAuth(identity: DeviceIdentity) { + auth = RelayAuth( + deviceId = identity.deviceId, + identityKey = identity.identitySigning.private, + identityPublic = identity.identitySigningPublic, + mint = ::mintToken, + ) + } + + // -- Devices and keys ---------------------------------------------------- + + /** `POST /e2e/devices` — publish this device's identity and prekeys. */ + fun registerDevice(identity: DeviceIdentity, oneTimePreKeys: List): JSONObject = + postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys)) + + /** `POST /e2e/prekeys` — top the pool back up. */ + fun uploadPreKeys(deviceId: String, preKeys: List) { + postJson("/e2e/prekeys", JSONObject() + .put("device_id", deviceId) + .put("opks", JSONArray().also { array -> + preKeys.forEach { + array.put(JSONObject().put("id", it.id).put("pub", Wire.b64(it.publicKey))) + } + })) + } + + /** + * `GET /e2e/keys/{user}` — a bundle per device. + * + * ⚠️ **This consumes a one-time prekey per device, every call.** Use + * [listDevices] to look at who a user is; use this only when actually + * starting a session. Polling it drains the pool and pushes every peer onto + * the last-resort key, which costs initial forward secrecy for everyone. + */ + fun fetchKeys(userId: Long): List { + val body = getJson("/e2e/keys/$userId") + val devices = body.optJSONArray("devices") ?: JSONArray() + return (0 until devices.length()).map { PeerBundle.fromWire(devices.getJSONObject(it)) } + } + + /** `GET /e2e/devices/{user}` — who a user is, costing no prekey. */ + fun listDevices(userId: Long): JSONArray = + getJson("/e2e/devices/$userId").optJSONArray("devices") ?: JSONArray() + + // -- Messages ------------------------------------------------------------ + + /** One addressed envelope, as `POST /e2e/messages` carries it. */ + data class Addressed(val toUser: Long, val toDevice: String, val payload: String) { + constructor(toUser: Long, toDevice: String, envelope: Envelope) : + this(toUser, toDevice, envelope.toPayload()) + } + + fun sendEnvelopes(envelopes: List): JSONObject = + postJson("/e2e/messages", JSONObject().put("messages", JSONArray().also { array -> + envelopes.forEach { + array.put(JSONObject() + .put("to_user", it.toUser) + .put("to_device", it.toDevice) + .put("payload", it.payload)) + } + })) + + /** One envelope from the inbox, with the server id that acknowledges it. */ + data class Incoming(val id: Long, val fromUser: Long, val fromDevice: String, val payload: String) + + /** + * `GET /e2e/messages` — this device's inbox. + * + * `after` is the highest id already ingested; the relay returns `id > after` + * in ascending order. Storing that watermark only once a message has been + * **decrypted and saved** is what makes the poll idempotent — advance it on + * receipt and a crash mid-batch loses the rest silently. + */ + fun pollInbox(deviceId: String, after: Long, limit: Int = 100): List { + val url = base.newBuilder().apply { + addPathSegments(routePath("/e2e/messages")) + addQueryParameter("device_id", deviceId) + addQueryParameter("after", after.toString()) + addQueryParameter("limit", limit.toString()) + }.build() + val body = requestJson("GET", url, "/e2e/messages", null) + val messages = body.optJSONArray("messages") ?: JSONArray() + return (0 until messages.length()).map { + val m = messages.getJSONObject(it) + Incoming( + id = m.getLong("id"), + fromUser = m.optLong("from_user", 0), + fromDevice = m.optString("from_device", ""), + payload = m.getString("payload"), + ) + } + } + + /** + * `POST /e2e/delivery-key` — publish SHA-256 of our delivery key so peers + * may send sealed (§14.4). Only the digest goes to the relay; the key + * itself travels inside end-to-end messages. + */ + fun setDeliveryKey(digest: ByteArray) { + postJson("/e2e/delivery-key", JSONObject().put("digest", Wire.b64(digest))) + } + + // -- Attachments --------------------------------------------------------- + + /** + * `POST /e2e/attachments` — one sealed blob, raw `nonce||ct||tag` (§8.7). + * + * ⚠️ The returned id is the download capability. It must only ever travel + * inside the ratchet; putting it anywhere the relay can read defeats the + * point of encrypting the blob. + */ + fun uploadAttachment(blob: ByteArray): String = + request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS)) + .use { json(it).getString("id") } + + /** `GET /e2e/attachments/{id}`. The caller verifies the digest and decrypts. */ + fun downloadAttachment(id: String): ByteArray = + request("GET", url("/e2e/attachments/$id"), "/e2e/attachments/$id", null) + .use { it.body?.bytes() ?: ByteArray(0) } + + // -- Groups (§8.9) ------------------------------------------------------- + + fun createGroup(name: String, memberIds: List): JSONObject = + postJson("/e2e/groups", JSONObject() + .put("name", name) + .put("members", JSONArray(memberIds))) + + fun listGroups(): JSONArray = getJson("/e2e/groups").optJSONArray("groups") ?: JSONArray() + + fun updateGroupMembers(groupId: String, add: List, remove: List): JSONObject = + postJson("/e2e/groups/$groupId/members", JSONObject() + .put("add", JSONArray(add)) + .put("remove", JSONArray(remove))) + + fun leaveGroup(groupId: String) { + postJson("/e2e/groups/$groupId/leave", JSONObject()) + } + + // -- Transparency log (§19) --------------------------------------------- + // + // Unauthenticated on the relay, and no proof headers are sent: the value of + // a transparency log is that anyone can read it, including someone who is + // not a user of the relay. + + fun logHead(): JSONObject = getJson("/e2e/log/head") + + fun logDeviceEntries(deviceId: String): JSONObject = getJson("/e2e/log/device/$deviceId") + + fun logInclusion(index: Long, size: Long): JSONObject = + getJson("/e2e/log/inclusion", mapOf("index" to index.toString(), "size" to size.toString())) + + fun logConsistency(from: Long, to: Long): JSONObject = + getJson("/e2e/log/consistency", mapOf("from" to from.toString(), "to" to to.toString())) + + // -- Calls (§8.10) ------------------------------------------------------- + + fun turnCredentials(): JSONObject = getJson("/e2e/turn") + + // -- Internals ----------------------------------------------------------- + + /** + * `POST /account/token` — the only route where the account password is + * still presented, and deliberately not under the REST prefix: it belongs + * to the identity provider, not to §8. + */ + private fun mintToken(deviceId: String, identityPublicB64: String): RelayAuth.Minted? { + val url = base.newBuilder().addPathSegments("account/token").build() + val body = JSONObject().put("device_id", deviceId).put("ik_sig", identityPublicB64) + val request = Request.Builder() + .url(url) + .post(body.toString().toRequestBody(JSON)) + .header("Authorization", basicAuthHeader()) + .header("Accept", "application/json") + .build() + http.newCall(request).execute().use { response -> + if (response.code == 404) throw RelayException.NotFound("/account/token") + if (!response.isSuccessful) return null + val json = json(response) + return RelayAuth.Minted( + token = json.getString("token"), + lifetimeSeconds = json.optInt("expires_in", 300), + ) + } + } + + private fun routePath(path: String): String = "$restPrefix/flamenet/v1${path}".trimStart('/') + + private fun url(path: String, query: Map = emptyMap()): HttpUrl = + base.newBuilder().apply { + addPathSegments(routePath(path)) + query.forEach { (name, value) -> addQueryParameter(name, value) } + }.build() + + private fun getJson(path: String, query: Map = emptyMap()): JSONObject = + requestJson("GET", url(path, query), path, null) + + private fun postJson(path: String, body: JSONObject): JSONObject = + requestJson("POST", url(path), path, body.toString().toByteArray(Charsets.UTF_8)) + + private fun requestJson(method: String, url: HttpUrl, logicalPath: String, body: ByteArray?): JSONObject = + request(method, url, logicalPath, body?.toRequestBody(JSON)).use(::json) + + /** + * One request, with one retry on 401. + * + * A token can expire between being chosen and the request landing, and a + * relay restart invalidates every outstanding one. Without the retry the + * client stays broken until something else happens to refresh it — which, + * on a phone that is only polling, may be never. + */ + private fun request(method: String, url: HttpUrl, logicalPath: String, body: RequestBody?): Response { + val first = http.newCall(build(method, url, logicalPath, body)).execute() + if (first.code != 401 || auth == null) return checked(first, logicalPath) + first.close() + auth?.invalidate() + return checked(http.newCall(build(method, url, logicalPath, body)).execute(), logicalPath) + } + + private fun build(method: String, url: HttpUrl, logicalPath: String, body: RequestBody?): Request { + val builder = Request.Builder().url(url).header("Accept", "application/json") + if (method == "GET") builder.get() else builder.method(method, body ?: ByteArray(0).toRequestBody(JSON)) + + val bytes = bodyBytes(body) + val token = auth?.token() + if (token == null) { + builder.header("Authorization", basicAuthHeader()) + } else { + builder.header("Authorization", "Bearer $token") + auth?.proofHeaders(method, logicalPath, bytes)?.forEach { (name, value) -> + builder.header(name, value) + } + } + return builder.build() + } + + /** The exact bytes the signature must cover — read once, from the body itself. */ + private fun bodyBytes(body: RequestBody?): ByteArray { + if (body == null) return ByteArray(0) + val sink = okio.Buffer() + body.writeTo(sink) + return sink.readByteArray() + } + + private fun checked(response: Response, path: String): Response { + if (response.isSuccessful) return response + val body = response.body?.string().orEmpty() + val code = response.code + response.close() + throw when (code) { + 401 -> RelayException.Unauthorized(body) + 404 -> RelayException.NotFound(path) + 429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull()) + else -> RelayException.Http(code, body) + } + } + + private fun json(response: Response): JSONObject { + val text = response.body?.string().orEmpty() + if (text.isBlank()) return JSONObject() + return try { + JSONObject(text) + } catch (e: Exception) { + throw RelayException.Malformed("relay returned a body that is not a JSON object: $e") + } + } + + private fun basicAuthHeader(): String = + "Basic " + Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)) +} diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt new file mode 100644 index 0000000..85d4629 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -0,0 +1,47 @@ +package io.flamenet.client + +/** + * What can go wrong talking to a relay. + * + * Typed rather than a single message-carrying exception because two of these + * are decisions, not diagnostics: [NotFound] tells [RelayAuth] this deployment + * mints no tokens, and [Unauthorized] triggers exactly one retry. A caller that + * has to match on a string to make those decisions eventually matches wrong. + */ +sealed class RelayException(message: String, cause: Throwable? = null) : Exception(message, cause) { + + /** The network, TLS, or a response that was not HTTP at all. */ + class Transport(message: String, cause: Throwable? = null) : RelayException(message, cause) + + /** 401. The caller discards its token and tries once more. */ + class Unauthorized(val body: String) : RelayException("relay refused the credential") + + /** + * 404. On the token route this means "this deployment has no token + * endpoint", which is a supported configuration and not an error. + */ + class NotFound(val path: String) : RelayException("no such route: $path") + + /** 429, with the relay's own retry hint when it gave one. */ + class RateLimited(val retryAfterSeconds: Int?) : + RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: "")) + + /** Any other non-2xx. The body is kept because relays put the reason in it. */ + class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body") + + /** A 2xx whose body was not what the route promised. */ + class Malformed(message: String) : RelayException(message) +} + +/** + * Base64 for this module. + * + * The engine keeps its own helper `internal` on purpose — its public surface is + * the protocol, not its utilities — and widening that so the transport can + * reach in would be the tail wagging the dog. Base64 has one right answer, so a + * second call site cannot drift. + */ +internal object Wire { + fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data) + fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text) +} diff --git a/client/src/test/kotlin/io/flamenet/client/LiveRelayTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveRelayTest.kt new file mode 100644 index 0000000..ed55190 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/LiveRelayTest.kt @@ -0,0 +1,65 @@ +package io.flamenet.client + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The client against a real relay, not a MockWebServer. + * + * ⚠️ **Opt-in, and silent when off.** Set `FN_LIVE_RELAY` to a relay's base URL + * to run it. A test that reaches the network cannot be part of a hermetic suite + * — it fails on an aeroplane and on a build machine with no egress, and a suite + * that is red for reasons unrelated to the code stops being read. + * + * FN_LIVE_RELAY=https://relay.flamenet.io ./gradlew :client:test + * + * What it checks is the one thing MockWebServer structurally cannot: that the + * URL this client builds is a URL the deployed relay actually serves. The + * transparency-log routes are unauthenticated by design — anyone may audit the + * log, including someone who is not a user — so this needs no credentials. + */ +class LiveRelayTest { + + private val relay: String? = System.getenv("FN_LIVE_RELAY") + + @Test + fun `the deployed relay serves the routes this client asks for`() { + val base = relay ?: return + val head = RelayClient(base, username = "", appPassword = "").logHead() + + // `audience` is the hostname the relay signs under, and a mismatch + // rejects every token with no other symptom — so it is worth asserting + // that the relay agrees with the address we reached it at. + assertEquals(java.net.URI(base).host, head.getString("audience")) + assertTrue(head.getInt("size") >= 0) + assertTrue(head.getString("root").isNotEmpty()) + assertTrue(head.getString("signature").isNotEmpty()) + } + + @Test + fun `an inclusion proof from the deployed log verifies`() { + val base = relay ?: return + val client = RelayClient(base, username = "", appPassword = "") + val head = client.logHead() + val size = head.getInt("size") + if (size == 0) return // a fresh relay has nothing to prove + + val proof = client.logInclusion(index = 0, size = size.toLong()) + val path = proof.getJSONArray("proof") + val audit = (0 until path.length()).map { Wire.unb64(path.getString(it)) } + + // The point of the log is that a client can check it, so the client + // checks it here rather than trusting the relay's own head. + assertTrue(io.flamenet.e2e.KeyLog.verifyInclusion( + leaf = Wire.unb64(proof.getString("leaf")), + index = 0, + size = size, + proof = audit, + // Checked against the head fetched separately, NOT against the + // `root` the proof response carries. A server that gets to supply + // both halves of the comparison has proven nothing. + root = Wire.unb64(head.getString("root")), + ), "the deployed relay's inclusion proof does not reconstruct its own root") + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt b/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt new file mode 100644 index 0000000..1f2b5dc --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt @@ -0,0 +1,328 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Ed25519 +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONObject +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Against a real HTTP server, not a hand-written fake. + * + * ⚠️ A fake written alongside the client agrees with it about everything, + * including the field name it got wrong — which is exactly how this protocol's + * one shipped bug survived until a live round trip. MockWebServer at least + * makes the client produce genuine bytes and parse genuine responses. + */ +class RelayClientTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { server = MockWebServer().also { it.start() } } + @AfterTest fun stop() { server.shutdown() } + + private fun client(): RelayClient = + RelayClient(server.url("/").toString(), "alice", "app pass word") + + private fun json(body: String) = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) + + /** + * The next request the client made. + * + * ⚠️ Bounded on purpose. `takeRequest()` with no timeout blocks forever, and + * because token minting is **lazy** — `useRelayAuth` does not mint, the + * first authenticated call does — it is easy to wait for a request that has + * not happened yet. That reads as a hung build, not a failing test. + */ + private fun next(): RecordedRequest = + server.takeRequest(5, TimeUnit.SECONDS) ?: fail("no request arrived") + + /** + * A client with a token response already queued. + * + * The mint has NOT happened when this returns — it happens on the first + * authenticated call — so `/account/token` is the first request to read + * back, after whatever the test then does. + */ + private fun authedClient(): Pair { + val identity = DeviceIdentity.create() + val client = client() + server.enqueue(json("""{"token":"tok-1","expires_in":300}""")) + client.useRelayAuth(identity) + return client to identity + } + + // -- Field names --------------------------------------------------------- + + @Test + fun `registering a device sends the names the relay expects`() { + val identity = DeviceIdentity.create() + val preKeys = identity.generateOneTimePreKeys(2) + server.enqueue(json("""{"ok":true}""")) + + client().registerDevice(identity, preKeys) + + val request = next() + assertEquals("POST", request.method) + assertEquals("/api/flamenet/v1/e2e/devices", request.path) + val body = JSONObject(request.body.readUtf8()) + assertEquals(identity.deviceId, body.getString("device_id")) + assertEquals(Wire.b64(identity.identityDHPublic), body.getString("ik_dh")) + 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")) + + // The published prekey signature must verify under the published + // identity key, or every peer refuses the bundle. + assertTrue(Ed25519.verify( + Wire.unb64(body.getString("ik_sig")), + Wire.unb64(body.getString("spk_sig")), + Wire.unb64(body.getString("spk")), + )) + } + + @Test + fun `polling the inbox asks the right question and reads the answer`() { + server.enqueue(json(""" + {"messages":[ + {"id":7,"from_user":3,"from_device":"phone","payload":"cGF5"}, + {"id":9,"from_user":4,"from_device":"tablet","payload":"bG9hZA=="} + ]} + """.trimIndent())) + + val messages = client().pollInbox("my-device", after = 6, limit = 50) + + val request = next() + val url = request.requestUrl!! + assertEquals("my-device", url.queryParameter("device_id")) + assertEquals("6", url.queryParameter("after")) + assertEquals("50", url.queryParameter("limit")) + assertEquals(listOf(7L, 9L), messages.map { it.id }) + assertEquals("tablet", messages[1].fromDevice) + } + + @Test + fun `an application password pasted with spaces still authenticates`() { + // The account service prints these in readable groups and users paste + // what they were shown. Sending the spaces is a 401 the user cannot + // diagnose, because the password on their screen looks correct. + server.enqueue(json("{}")) + client().logHead() + val header = next().getHeader("Authorization")!! + assertEquals("alice:apppassword", + String(Wire.unb64(header.removePrefix("Basic ")), Charsets.UTF_8)) + } + + // -- Authentication ------------------------------------------------------ + + @Test + fun `a token is minted once and reused`() { + val (client, _) = authedClient() + server.enqueue(json("{}")) + server.enqueue(json("{}")) + client.logHead() + client.logHead() + + assertEquals("/account/token", next().path) + assertEquals("Bearer tok-1", next().getHeader("Authorization")) + assertEquals("Bearer tok-1", next().getHeader("Authorization")) + // Two calls, three requests: the token was minted once and reused. + assertEquals(3, server.requestCount) + } + + /** + * ⚠️ The signature covers the LOGICAL §8 path, not the URL's path. The + * relay mounts these routes twice and signs against `/e2e/…`; signing the + * REST-prefixed path produces a valid signature over the wrong string, and + * the relay rejects it with no hint as to why. + */ + @Test + fun `the proof signature covers the logical path, not the url path`() { + val (client, identity) = authedClient() + server.enqueue(json("{}")) + client.logHead() + + next() // the mint + val request = next() + assertEquals("/api/flamenet/v1/e2e/log/head", request.path) + assertTrue(verifyProof(request, identity, logicalPath = "/e2e/log/head", body = ByteArray(0))) + assertTrue(!verifyProof(request, identity, logicalPath = request.path!!, body = ByteArray(0))) + } + + @Test + fun `the proof signature covers the request body`() { + val (client, identity) = authedClient() + server.enqueue(json("""{"ok":true}""")) + client.setDeliveryKey(ByteArray(32) { 3 }) + + next() // the mint + val request = next() + val body = request.body.readByteArray() + assertTrue(verifyProof(request, identity, "/e2e/delivery-key", body)) + // A different body must not verify, or the signature secures nothing. + assertTrue(!verifyProof(request, identity, "/e2e/delivery-key", body + 1)) + } + + /** + * Two polls in the same second with the same empty body must not produce + * identical signatures — the relay's replay cache cannot tell an honest + * repeat from a captured one, so it refuses the honest client and the app + * silently stops receiving mail. + */ + @Test + fun `repeated identical requests do not repeat a signature`() { + val (client, _) = authedClient() + server.enqueue(json("{}")) + server.enqueue(json("{}")) + client.logHead() + client.logHead() + + next() // the mint + val a = next() + val b = next() + assertNotEquals(a.getHeader("X-FN-Nonce"), b.getHeader("X-FN-Nonce")) + assertNotEquals(a.getHeader("X-FN-Signature"), b.getHeader("X-FN-Signature")) + } + + @Test + fun `a 401 costs exactly one retry with a fresh token`() { + val (client, _) = authedClient() + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(json("""{"token":"tok-2","expires_in":300}""")) + server.enqueue(json("""{"size":4}""")) + + assertEquals(4, client.logHead().getInt("size")) + + next() // the first mint + val rejected = next() + val minted = next() + val retried = next() + assertEquals("Bearer tok-1", rejected.getHeader("Authorization")) + assertEquals("/account/token", minted.path) + assertEquals("Bearer tok-2", retried.getHeader("Authorization")) + } + + @Test + fun `a persistent 401 gives up rather than looping`() { + val (client, _) = authedClient() + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(json("""{"token":"tok-2","expires_in":300}""")) + server.enqueue(MockResponse().setResponseCode(401)) + + assertFailsWith { client.logHead() } + } + + /** + * A deployment with no token route is a supported configuration, not an + * error — and it must be asked exactly once. Asking on every request costs + * a round trip per call, forever, to learn the same thing. + */ + @Test + fun `a relay with no token endpoint falls back to Basic and is asked once`() { + val identity = DeviceIdentity.create() + val client = client() + server.enqueue(MockResponse().setResponseCode(404)) + client.useRelayAuth(identity) + + server.enqueue(json("{}")) + server.enqueue(json("{}")) + client.logHead() + client.logHead() + + assertEquals("/account/token", next().path) + repeat(2) { + val request = next() + assertTrue(request.getHeader("Authorization")!!.startsWith("Basic ")) + assertNull(request.getHeader("X-FN-Signature")) + } + assertEquals(3, server.requestCount) + } + + // -- Errors -------------------------------------------------------------- + + @Test + fun `status codes that mean something become the thing they mean`() { + server.enqueue(MockResponse().setResponseCode(429).setHeader("Retry-After", "30")) + val rateLimited = assertFailsWith { client().logHead() } + assertEquals(30, rateLimited.retryAfterSeconds) + + server.enqueue(MockResponse().setResponseCode(404)) + assertFailsWith { client().logHead() } + + server.enqueue(MockResponse().setResponseCode(500).setBody("boom")) + val http = assertFailsWith { client().logHead() } + assertEquals(500, http.status) + assertEquals("boom", http.body) + } + + @Test + fun `a 200 that is not JSON is a failure, not an empty result`() { + // Silently returning an empty object here would look like "the relay + // has no groups" rather than "something is very wrong". + server.enqueue(MockResponse().setResponseCode(200).setBody("gateway")) + assertFailsWith { client().listGroups() } + } + + // -- Bundles ------------------------------------------------------------- + + @Test + fun `fetched bundles parse into something a session can use`() { + val peer = DeviceIdentity.create() + val oneTime = peer.generateOneTimePreKeys(1).first() + val wire = peer.registrationWire() + .put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey))) + server.enqueue(json("""{"devices":[$wire]}""")) + + val bundles = client().fetchKeys(42) + assertEquals(1, bundles.size) + assertEquals(peer.deviceId, bundles[0].deviceId) + assertEquals(oneTime.id, bundles[0].oneTimePreKeyId) + + // The real proof that the parse is right: a session actually starts. + val session = io.flamenet.e2e.E2ESession.initiate(DeviceIdentity.create(), bundles[0]) + val envelope = session.encrypt("through a fetched bundle") + assertEquals("through a fetched bundle", + io.flamenet.e2e.E2ESession.respond(peer, envelope).decrypt(envelope)) + } + + // -- Helpers ------------------------------------------------------------- + + private fun verifyProof( + request: RecordedRequest, + identity: DeviceIdentity, + logicalPath: String, + body: ByteArray, + ): Boolean { + val digest = MessageDigest.getInstance("SHA-256").digest(body) + .joinToString("") { "%02x".format(it) } + val canonical = listOf( + request.method!!, + logicalPath, + request.getHeader("X-FN-Timestamp")!!, + request.getHeader("X-FN-Nonce")!!, + digest, + ).joinToString("\n").toByteArray(Charsets.UTF_8) + return Ed25519.verify( + identity.identitySigningPublic, + Wire.unb64(request.getHeader("X-FN-Signature")!!), + canonical, + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8917317..0fb6b89 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,5 +8,8 @@ rootProject.name = "flamenet-android" include(":engine") +// The relay transport: HTTP, auth, and the §8 routes. +include(":client") + // The cross-engine interop driver. Not shipped; see `engine/test/interop-kotlin.mjs`. include(":interop")