Kotlin: the relay transport

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 15:37:59 -04:00
co-authored by Claude Opus 5
parent 8a3770f1d1
commit b1ba9cdc58
8 changed files with 933 additions and 5 deletions
@@ -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<String, String> {
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
}
}
@@ -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<DeviceIdentity.PreKey>): JSONObject =
postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys))
/** `POST /e2e/prekeys` — top the pool back up. */
fun uploadPreKeys(deviceId: String, preKeys: List<DeviceIdentity.PreKey>) {
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<PeerBundle> {
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<Addressed>): 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<Incoming> {
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<Long>): 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<Long>, remove: List<Long>): 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<String, String> = emptyMap()): HttpUrl =
base.newBuilder().apply {
addPathSegments(routePath(path))
query.forEach { (name, value) -> addQueryParameter(name, value) }
}.build()
private fun getJson(path: String, query: Map<String, String> = 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))
}
@@ -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)
}
@@ -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")
}
}
@@ -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<RelayClient, DeviceIdentity> {
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<RelayException.Unauthorized> { 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<RelayException.RateLimited> { client().logHead() }
assertEquals(30, rateLimited.retryAfterSeconds)
server.enqueue(MockResponse().setResponseCode(404))
assertFailsWith<RelayException.NotFound> { client().logHead() }
server.enqueue(MockResponse().setResponseCode(500).setBody("boom"))
val http = assertFailsWith<RelayException.Http> { 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("<html>gateway</html>"))
assertFailsWith<RelayException.Malformed> { 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,
)
}
}