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")