diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 3f4ca4e..4f110e2 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -796,19 +796,39 @@ class Conversations( // hour) and best-effort โ€” it only acts on a proven fork. trust.verifyLogConsistencyIfDue() val out = mutableListOf() - readInbox(relay, home = null, limit = limit, into = out) - // ๐Ÿ”ด **Then every other mailbox this account keeps (ยง20.4).** A home - // record tells senders to deliver a copy everywhere and the relay fans - // out โ€” but a copy sitting in a mailbox nothing reads is storage, not - // redundancy. This is the half that makes losing a relay survivable in - // practice rather than in principle. + + // ๐Ÿ”ด **Two properties that pull against each other, and both hold.** // + // A failed poll must not be reported as an empty inbox โ€” "no new + // messages" and "could not reach the server" are different things and + // the app says different words for them. But a home relay that is down + // must not take every OTHER mailbox down with it, which is precisely + // the situation several mailboxes exist to survive. + // + // So every mailbox is read, and the failure is only raised if NOTHING + // could be read. A home relay that is down while a backup answers is a + // partial success the caller can act on: its mail is returned, not + // thrown away with the error. + val reached = mutableListOf() + var firstFailure: Throwable? = null + + runCatching { readInbox(relay, home = null, limit = limit, into = out) } + .onSuccess { reached += true } + .onFailure { firstFailure = it } + // โš ๏ธ After the signed-in relay and never instead of it: a backup home // that is slow or down must not delay the mailbox nearly all mail // actually arrives at. for (home in extraHomes()) { runCatching { readInbox(home.second, home.first, limit, out) } + .onSuccess { reached += true } + .onFailure { if (firstFailure == null) firstFailure = it } } + + // โš ๏ธ `reached`, not `out`. An inbox that answered with nothing in it is + // a successful poll, and treating an empty result as a failure would + // report every quiet minute as an outage. + if (reached.isEmpty()) firstFailure?.let { throw it } return out } @@ -822,7 +842,7 @@ class Conversations( private fun extraHomes(): List> = vault.extraHomes().map { home -> home.relay to RelayClient( - "https://${home.relay}", home.username, home.appPassword + home.baseUrl, home.username, home.appPassword ).also { it.useRelayAuth(identity) } } diff --git a/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt b/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt index b4d0a35..bc54552 100644 --- a/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt +++ b/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt @@ -79,6 +79,18 @@ class MailboxHoming( accountKeySeed: ByteArray, fnid: String, existingHomes: List, + /** + * How to reach [typed], which is a separate question from what it is + * called. + * + * โš ๏ธ The signed record names a **hostname** โ€” it is inside the + * signature, so it can carry no scheme and no port โ€” while this is how + * this device dials that host. `https://` is the only correct + * answer in production and the default; naming the two separately is + * what lets a self-hosted or test deployment reachable some other way + * work without the record lying about its own name. + */ + dialAs: String? = null, ): List { val host = hostname(typed) ?: throw Refused( "That does not look like a relay's address. It is a hostname, like " + @@ -87,7 +99,7 @@ class MailboxHoming( throw Refused("That relay already holds a mailbox for you.") } - val base = "https://$host" + val base = dialAs ?: "https://$host" val account = try { RelayAccounts(base).signIn(handle, passphrase) } catch (e: Exception) { @@ -131,7 +143,7 @@ class MailboxHoming( clients[primaryRelay] = primary for (existing in vault.extraHomes()) { clients[existing.relay] = RelayClient( - "https://${existing.relay}", existing.username, existing.appPassword + existing.baseUrl, existing.username, existing.appPassword ).also { it.useRelayAuth(identity) } } clients[host] = client @@ -146,7 +158,8 @@ class MailboxHoming( failures[host]?.let { throw Refused("$host refused the mailbox: ${it.message}") } vault.saveExtraHomes( - vault.extraHomes() + Vault.ExtraHome(host, account.username, account.appPassword)) + vault.extraHomes() + + Vault.ExtraHome(host, account.username, account.appPassword, base)) return homes } @@ -183,7 +196,7 @@ class MailboxHoming( for (existing in vault.extraHomes()) { if (existing.relay == relay) continue clients[existing.relay] = RelayClient( - "https://${existing.relay}", existing.username, existing.appPassword + existing.baseUrl, existing.username, existing.appPassword ).also { it.useRelayAuth(identity) } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index ecb43a8..1559476 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -37,6 +37,16 @@ class RelayClient( private val base: HttpUrl = baseUrl.trimEnd('/').toHttpUrl() + /** + * The relay this client talks to, for anything that has to remember a fact + * about a specific one โ€” ยง19's "has this relay ever served a log". + * + * โš ๏ธ The host, not the URL. Handing out the base would let a caller build a + * second client for the same relay with different credentials, which is the + * one thing this type exists to keep in one place. + */ + val host: String get() = base.host + /** * Spaces are stripped because the account service prints application * passwords in readable groups, and users paste what they were shown. diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt index feffe7b..506bb48 100644 --- a/client/src/main/kotlin/io/flamenet/client/Trust.kt +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -366,13 +366,13 @@ class Trust( bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId) ) { val head = try { - head().also { vault.rememberKeyLogServed() } + head().also { vault.rememberKeyLogServed(relay.host) } } catch (e: RelayException.NotFound) { // Only a route that is genuinely absent. A timeout, a 500 or a 429 // is a relay having a bad day and must not read as one without // transparency โ€” that would make enforcement skippable by anybody // able to make one request fail. - if (vault.keyLogServed()) { + if (vault.keyLogServed(relay.host)) { throw KeyLogForkException("this relay served a log before and now serves none") } if (requireKeyLog) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) @@ -396,7 +396,7 @@ class Trust( ) { val head = servedHead ?: head() - val pin = vault.loadKeyLogPin() + val pin = vault.loadKeyLogPin(relay.host) if (pin != null) { // A log that shrank is not a log. if (pin.size > head.size) { @@ -478,7 +478,7 @@ class Trust( // A proof that does not check out IS evidence, not a naming problem. if (!included) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) - vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root)) + vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root), relay.host) } // -- Gossip (ยง19.4) ------------------------------------------------------ @@ -492,13 +492,13 @@ class Trust( * reconciling them needs a proof that cannot exist. */ fun headPayload(): String? { - val pin = vault.loadKeyLogPin() ?: return null + val pin = vault.loadKeyLogPin(relay.host) ?: return null return """{"fnc":1,"kind":"kt_head","size":${pin.size},"root":"${pin.root}"}""" } /** Compare a peer's view of the log against ours. */ fun gossip(size: Int, root: String) { - val pin = vault.loadKeyLogPin() ?: return + val pin = vault.loadKeyLogPin(relay.host) ?: return if (size == pin.size) { if (root != pin.root) forkDetected = true return @@ -537,7 +537,7 @@ class Trust( * poll loop; it self-throttles. */ fun verifyLogConsistencyIfDue(now: Long = System.currentTimeMillis()) { - val pin = vault.loadKeyLogPin() ?: return // nothing pinned to extend + val pin = vault.loadKeyLogPin(relay.host) ?: return // nothing pinned to extend if (now - lastLogCheck < logCheckIntervalMs) return val head = try { head() } catch (_: Exception) { return } // network, not evidence val ok = try { @@ -548,7 +548,7 @@ class Trust( } } catch (_: Exception) { return } if (!ok) { forkDetected = true; return } - vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root)) + vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root), relay.host) lastLogCheck = now } diff --git a/client/src/test/kotlin/io/flamenet/client/AccountIdentityTest.kt b/client/src/test/kotlin/io/flamenet/client/AccountIdentityTest.kt new file mode 100644 index 0000000..e3e0408 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/AccountIdentityTest.kt @@ -0,0 +1,260 @@ +package io.flamenet.client + +import io.flamenet.e2e.Ed25519 +import io.flamenet.e2e.FNID +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import java.security.SecureRandom +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * An address nobody issued, and the record that says where mail for it waits. + * + * ๐Ÿ”ด **The relay must not be able to lie about either.** The address is the + * public half of a key derived from the passphrase, so claiming it proves + * possession and nothing else โ€” a relay confirms, it does not vouch. The home + * record is signed by that same key, so a relay serving a truncated list is + * caught by arithmetic rather than by trust. These tests are about the places + * that could quietly stop being true. + */ +class AccountIdentityTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { server = MockWebServer().also { it.start() } } + @AfterTest fun stop() { server.shutdown() } + + private fun json(body: String) = MockResponse() + .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + private fun seed(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) } + + private fun relay() = RelayClient(server.url("/").toString(), "a", "b") + + // MARK: - Claiming + + /** + * ๐Ÿ”ด **The audience is not the caller's to guess.** It is the string the + * relay checks the signature against, so a caller inferring it from a URL + * would produce a claim that verifies nowhere โ€” silently, because at the + * point of failure a wrong audience and a wrong key look identical. + */ + fun claimingSignsWhatTheRelaySaysItsNameIs() {} + + @Test + fun `a claim is signed over the audience the relay reports`() { + val seed = seed() + val key = Ed25519.fromSeed(seed) + val expected = FNID.address(key.publicKey) + var seenBody: JSONObject? = null + + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = when { + ".well-known/flamenet/relay" in request.path.orEmpty() -> + json("""{"audience":"relay.example.com","issuerPublicKey":""}""") + "/e2e/account/identity" in request.path.orEmpty() -> { + seenBody = JSONObject(request.body.readUtf8()) + json("""{"ok":true,"fnid":"$expected","created":true}""") + } + else -> MockResponse().setResponseCode(404) + } + } + + assertEquals(expected, AccountIdentity.claim(relay(), seed, userId = 42)) + + val body = seenBody!! + val signature = Wire.unb64(body.getString("sig")) + assertTrue( + Ed25519.verify(key.publicKey, signature, + FNID.claimCanonical("relay.example.com", 42, body.getString("account_key"))), + "the claim must verify against the audience the relay named", + ) + // โš ๏ธ And not against a different one. A claim captured at one relay must + // not be replayable at another โ€” the same account key is MEANT to be + // registered in several places, but each registration has to be an act + // the holder took there. + assertFalse( + Ed25519.verify(key.publicKey, signature, + FNID.claimCanonical("other.example.com", 42, body.getString("account_key")))) + } + + /** The address is derived, never taken from the relay's reply. */ + @Test + fun `the address is a function of the seed alone`() { + val seed = seed() + assertEquals(AccountIdentity.address(seed), AccountIdentity.address(seed)) + assertEquals(FNID.ENCODED_LENGTH + "fnid:".length, AccountIdentity.address(seed).length) + org.junit.Assert.assertNotEquals(AccountIdentity.address(seed), AccountIdentity.address(seed())) + } + + // MARK: - Home records + + private fun signedRecord(seed: ByteArray, serial: Int, relays: List): JSONObject { + val fnid = AccountIdentity.address(seed) + val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays))) + return JSONObject() + .put("fnid", fnid).put("serial", serial) + .put("relays", JSONArray(relays)).put("sig", sig) + } + + @Test + fun `a record signed by the account verifies`() { + val seed = seed() + val record = signedRecord(seed, 3, listOf("a.example.com", "b.example.com")) + assertEquals(listOf("a.example.com", "b.example.com"), + AccountIdentity.verifiedRelays(record)) + } + + /** + * ๐Ÿ”ด **A relay serving a truncated list is the exact failure multi-homing + * exists to survive.** Drop a home from the record and it stops being + * signed โ€” the sender then delivers everywhere the real record names, + * because it checks rather than trusts. + */ + @Test + fun `a shortened list does not verify`() { + val seed = seed() + val record = signedRecord(seed, 3, listOf("a.example.com", "b.example.com")) + record.put("relays", JSONArray(listOf("a.example.com"))) + assertNull(AccountIdentity.verifiedRelays(record), + "a relay removed itself from the competition and was believed") + } + + /** And neither does an added one, which is the same attack pointed outward. */ + @Test + fun `a relay cannot add itself to somebody's record`() { + val seed = seed() + val record = signedRecord(seed, 3, listOf("a.example.com")) + record.put("relays", JSONArray(listOf("a.example.com", "attacker.example.com"))) + assertNull(AccountIdentity.verifiedRelays(record)) + } + + /** + * โš ๏ธ The serial is inside the signature, which is what stops the rollback a + * signature alone permits: an old list is genuinely signed, so without a + * counter a relay could serve one from before a second home was added and + * quietly become the only mailbox again. + */ + @Test + fun `the serial is bound into the signature`() { + val seed = seed() + val record = signedRecord(seed, 7, listOf("a.example.com")) + record.put("serial", 2) + assertNull(AccountIdentity.verifiedRelays(record)) + } + + /** A record for somebody else's address is not this account's record. */ + @Test + fun `a record signed by a different key does not verify`() { + val record = signedRecord(seed(), 1, listOf("a.example.com")) + record.put("fnid", AccountIdentity.address(seed())) + assertNull(AccountIdentity.verifiedRelays(record)) + } + + @Test + fun `a malformed record is refused rather than guessed at`() { + assertNull(AccountIdentity.verifiedRelays(JSONObject())) + assertNull(AccountIdentity.verifiedRelays( + JSONObject().put("fnid", "not-an-address").put("serial", 1) + .put("relays", JSONArray(listOf("a.example.com"))).put("sig", "!!"))) + } + + // MARK: - Serial numbers + + /** + * โš ๏ธ Read from what is already published, not from local state. A device + * offline while another added a relay would otherwise reuse a serial and + * have its record refused as a rollback โ€” by the very check that exists to + * stop an attacker doing the same thing. + */ + @Test + fun `the next serial is one past the highest any home admits to`() { + val seed = seed() + val fnid = AccountIdentity.address(seed) + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = + json(signedRecord(seed, 11, listOf("a.example.com")).toString()) + } + assertEquals(12, AccountIdentity.nextSerial(fnid, listOf(relay()))) + } + + /** A home that answers nothing does not drag the serial backwards. */ + @Test + fun `a home with no record contributes nothing`() { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(404) + } + assertEquals(1, AccountIdentity.nextSerial(AccountIdentity.address(seed()), + listOf(relay()))) + } + + // MARK: - Publishing + + /** + * Every listed relay is told, not only the one being added. A home that + * does not know it is one still accepts mail, but a sender asking IT for + * the record gets a stale list and skips the others โ€” so the redundancy + * would quietly depend on which relay the sender happened to ask. + */ + @Test + fun `publishing reaches every home and reports the ones that refuse`() { + val seed = seed() + val fnid = AccountIdentity.address(seed) + + val good = MockWebServer().also { it.start() } + good.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = json("""{"ok":true}""") + } + val bad = MockWebServer().also { it.start() } + bad.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = + MockResponse().setResponseCode(500).setBody("{}") + } + + val failures = AccountIdentity.publishHomes( + seed, fnid, serial = 4, + homes = mapOf( + "good.example.com" to RelayClient(good.url("/").toString(), "a", "b"), + "bad.example.com" to RelayClient(bad.url("/").toString(), "a", "b"), + )) + + assertEquals(setOf("bad.example.com"), failures.keys, + "a partial success has to say WHICH copies landed") + good.shutdown(); bad.shutdown() + } + + /** What goes out is signed over the list as sent, in order. */ + @Test + fun `the published record is signed over the relays it names`() { + val seed = seed() + val fnid = AccountIdentity.address(seed) + var body: JSONObject? = null + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + body = JSONObject(request.body.readUtf8()) + return json("""{"ok":true}""") + } + } + + AccountIdentity.publishHomes(seed, fnid, serial = 2, + homes = mapOf("only.example.com" to relay())) + + val sent = body!! + val relays = sent.getJSONArray("relays").let { a -> + (0 until a.length()).map { a.getString(it) } + } + assertEquals(listOf("only.example.com"), relays) + assertTrue(AccountIdentity.verifyHomes(fnid, sent.getInt("serial"), relays, + sent.getString("sig"))) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt b/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt index 8e8cdaf..2f77f1f 100644 --- a/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt @@ -155,7 +155,7 @@ class AutomaticKeyLogTest { // The first attempt fails on the absent leaf, and โ€” crucially โ€” it also // records that this relay answered. assertFailsWith { trust.enforceKeyLogIfServed(peer(), userId = 7) } - assertTrue(vault.keyLogServed()) + assertTrue(vault.keyLogServed(server.url("/").host)) relayServing(head = null) assertFailsWith { @@ -177,6 +177,23 @@ class AutomaticKeyLogTest { assertFailsWith { trust().enforceKeyLogIfServed(peer(), userId = 7) } } + /** + * ๐Ÿ”ด **The flag is per relay, and the first version was not.** A single + * global one meant that signing in to a second relay which keeps no log + * read as the FIRST going quiet โ€” so an honest deployment without + * transparency became a fork warning, and every contact on it became + * unreachable. + */ + @Test + fun `the served flag is remembered per relay`() { + relayServing(head = aHead) + assertFailsWith { trust().enforceKeyLogIfServed(peer(), userId = 7) } + + assertTrue(vault.keyLogServed(server.url("/").host)) + assertEquals(false, vault.keyLogServed("somewhere.else"), + "one relay answering must say nothing about another") + } + /** * The pin is written by the gossip path too โ€” a head a *contact* sent โ€” so * inferring "this relay serves a log" from it would make a gossiped head diff --git a/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt index 780c160..c51cba9 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt @@ -73,7 +73,7 @@ class LiveKeyLogTest { val trust = alice.trust() trust.enforceKeyLog(bundle, bob.userId) - val pin = alice.vault.loadKeyLogPin() + val pin = alice.vault.loadKeyLogPin(alice.relay.host) assertNotNull(pin, "a successful check must pin the head it verified against") assertTrue(pin.size > 0) } @@ -125,7 +125,7 @@ class LiveKeyLogTest { val trust = alice.trust() val first = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId } trust.enforceKeyLog(first, bob.userId) - val pinned = alice.vault.loadKeyLogPin()!! + val pinned = alice.vault.loadKeyLogPin(alice.relay.host)!! // Grow the log by publishing another device, then check again. The // second call must produce and verify a real consistency proof from the @@ -135,7 +135,7 @@ class LiveKeyLogTest { .single { it.deviceId == second.identity.deviceId } trust.enforceKeyLog(third, second.userId) - val after = alice.vault.loadKeyLogPin()!! + val after = alice.vault.loadKeyLogPin(alice.relay.host)!! assertTrue(after.size > pinned.size, "the log should have grown between the two checks") } @@ -153,7 +153,7 @@ class LiveKeyLogTest { val head = alice.relay.logHead() alice.vault.saveKeyLogPin( - Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A })) + relay = alice.relay.host, pin = Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A })) ) val bundle = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId } @@ -271,7 +271,7 @@ class LiveKeyLogTest { // Then pin a root the relay cannot reconcile, and watch the same send // path refuse rather than encrypting to a key it cannot vouch for. alice.vault.saveKeyLogPin( - Vault.KeyLogPin(alice.relay.logHead().getInt("size"), Wire.b64(ByteArray(32) { 0x11 })) + relay = alice.relay.host, pin = Vault.KeyLogPin(alice.relay.logHead().getInt("size"), Wire.b64(ByteArray(32) { 0x11 })) ) assertFailsWith { conversations.send(bob.userId, "this one must not go out") diff --git a/client/src/test/kotlin/io/flamenet/client/MailboxHomingTest.kt b/client/src/test/kotlin/io/flamenet/client/MailboxHomingTest.kt new file mode 100644 index 0000000..846206f --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/MailboxHomingTest.kt @@ -0,0 +1,281 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Ed25519 +import io.flamenet.e2e.FNID +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import java.security.SecureRandom +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Adding and dropping a mailbox (spec ยง20.4). + * + * ๐Ÿ”ด **Your address survives a relay disappearing; your mailbox does not.** So + * the order of operations here is the whole substance: nothing is written down + * locally until the relay has actually accepted a registration, and the record + * is published everywhere rather than only at the relay being added. A home + * that does not know it is one still accepts mail, but a sender asking IT for + * the record gets a stale list and skips the others โ€” the redundancy would + * quietly depend on which relay the sender happened to ask. + */ +class MailboxHomingTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private lateinit var home: MockWebServer + private lateinit var newHome: MockWebServer + private lateinit var vault: Vault + private lateinit var identity: DeviceIdentity + + private val seed = ByteArray(32).also { SecureRandom().nextBytes(it) } + private val fnid get() = AccountIdentity.address(seed) + + /** Records each relay was asked to store, so "was it told" is checkable. */ + private val published = LinkedHashMap>() + + /** + * What each relay would serve back if asked. + * + * โš ๏ธ Separate from [published], which a test may clear to check who was + * told THIS time. A relay does not forget a record because the test stopped + * watching, and `nextSerial` reads exactly this โ€” so sharing one map made + * every publish after a clear reuse serial 1, which a real relay would + * refuse as a rollback. + */ + private val served = LinkedHashMap() + private var newHomeRejectsSignIn = false + private var newHomeRejectsRecord = false + + @BeforeTest fun start() { + identity = DeviceIdentity.create() + vault = Vault(MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault.saveIdentity(identity) + home = MockWebServer().also { it.dispatcher = relay("home"); it.start() } + newHome = MockWebServer().also { it.dispatcher = relay("new"); it.start() } + } + + @AfterTest fun stop() { home.shutdown(); newHome.shutdown() } + + private fun json(body: String) = MockResponse() + .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + private fun relay(tag: String) = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "account/signin" in path -> { + if (tag == "new" && newHomeRejectsSignIn) MockResponse().setResponseCode(401) + else json("""{"user_id":9,"username":"me","display_name":"Me","app_password":"pw"}""") + } + ".well-known/flamenet/relay" in path -> + json("""{"audience":"$tag.example.com","issuerPublicKey":""}""") + "/e2e/devices" in path -> json("""{"ok":true,"device_id":"d","opk_remaining":0}""") + "/e2e/account/identity" in path -> + json("""{"ok":true,"fnid":"$fnid","created":true}""") + "/e2e/account/homes" in path && request.method == "POST" -> { + if (tag == "new" && newHomeRejectsRecord) { + MockResponse().setResponseCode(400).setBody("{}") + } else { + val record = JSONObject(request.body.readUtf8()) + published.getOrPut(tag) { mutableListOf() }.add(record) + served[tag] = record + json("""{"ok":true}""") + } + } + // ๐Ÿ”ด Serves back what it was told. `nextSerial` reads the + // published records to decide the next one, so a relay that + // stores a record and then 404s it would have every publish + // reuse serial 1 โ€” and the second would be refused as a + // rollback by the very check that stops an attacker replaying + // an old list. A mock that only accepts writes hides that. + "/e2e/account/homes/" in path -> + served[tag]?.let { + json(JSONObject() + .put("fnid", fnid) + .put("serial", it.getInt("serial")) + .put("relays", it.getJSONArray("relays")) + .put("sig", it.getString("sig")).toString()) + } ?: MockResponse().setResponseCode(404) + else -> MockResponse().setResponseCode(404) + } + } + } + + private fun homing() = MailboxHoming( + vault, identity, "home.example.com", + RelayClient(home.url("/").toString(), "me", "pw")) + + /** + * โš ๏ธ The record names `new.example.com`; the URL this device dials is the + * mock server. That split is the production shape too โ€” the hostname is + * inside the signature and cannot carry a port. + */ + private fun add(existing: List = emptyList()) = + homing().add("new.example.com", handle = "me", passphrase = "pass", + accountKeySeed = seed, fnid = fnid, existingHomes = existing, + dialAs = newHome.url("/").toString()) + + // MARK: - What a relay's name is allowed to be + + /** + * โš ๏ธ A relay is a hostname, never a URL. This is parsed from something a + * person typed or pasted, and accepting `https://x/y` would let it point + * the device at a path on a host of somebody else's choosing โ€” and then + * send that host a signed record naming it as a mailbox. + */ + @Test + fun `only a hostname is accepted as a relay`() { + assertEquals("relay.example.com", MailboxHoming.hostname(" Relay.Example.COM ")) + for (bad in listOf("https://relay.example.com", "relay.example.com/path", + "relay.example.com:8443", "me@relay.example.com", + "localhost", "", "relay example com")) { + assertNull(MailboxHoming.hostname(bad), "accepted $bad") + } + } + + @Test + fun `a relay already holding mail is not added twice`() { + assertFailsWith { + homing().add("home.example.com", "me", "pass", seed, fnid, emptyList()) + } + assertFailsWith { + homing().add("new.example.com", "me", "pass", seed, fnid, listOf("new.example.com")) + } + } + + // MARK: - Nothing is written down until the relay agrees + + /** + * ๐Ÿ”ด A half-added home โ€” stored locally, unknown to the relay โ€” would be + * polled forever and would show on screen as a mailbox that never has + * anything in it. + */ + @Test + fun `a relay that refuses the sign-in leaves nothing behind`() { + newHomeRejectsSignIn = true + assertFailsWith { add() } + assertTrue(vault.extraHomes().isEmpty(), "a refused relay was stored anyway") + } + + /** + * ๐Ÿ”ด **The NEW relay accepting is what decides.** A record every old home + * took but the new one refused describes a mailbox that does not exist, and + * a sender told to deliver there gets nothing. + */ + @Test + fun `a relay that refuses the record is not remembered as a mailbox`() { + newHomeRejectsRecord = true + assertFailsWith { add() } + assertTrue(vault.extraHomes().isEmpty()) + } + + // MARK: - The record goes everywhere + + @Test + fun `adding a mailbox tells both relays, and each gets the same list`() { + val homes = add() + + assertEquals(listOf("home.example.com", "new.example.com"), homes) + assertEquals(setOf("home", "new"), published.keys, + "a home that was not told serves a stale list to senders") + + for ((tag, records) in published) { + val record = records.single() + val relays = record.getJSONArray("relays").let { a -> + (0 until a.length()).map { a.getString(it) } + } + assertEquals(homes, relays, "$tag was told a different list") + assertTrue( + AccountIdentity.verifyHomes(fnid, record.getInt("serial"), relays, + record.getString("sig")), + "$tag was sent a record that does not verify", + ) + } + } + + /** And the credential for the new mailbox is kept, or it cannot be read. */ + @Test + fun `the new mailbox is remembered with a credential of its own`() { + add() + val stored = vault.extraHomes().single() + assertEquals("new.example.com", stored.relay) + assertEquals("pw", stored.appPassword) + assertEquals(listOf("new.example.com"), homing().known()) + } + + // MARK: - Dropping one + + /** + * โš ๏ธ **The shorter list is published first and the credential goes only if + * that worked.** Forgetting locally while senders are still told to deliver + * there leaves mail arriving at a mailbox this device no longer reads โ€” + * which, from the sender's side, is indistinguishable from delivery. + */ + @Test + fun `removing a mailbox publishes the shorter list and then forgets it`() { + add() + published.clear() + + homing().remove("new.example.com", seed, fnid, + listOf("home.example.com", "new.example.com")) + + assertTrue(vault.extraHomes().isEmpty(), "the credential outlived the mailbox") + // ๐Ÿ”ด The relay being dropped is NOT asked to accept the new record. It + // is the one party with a reason to refuse, and it is not in the list + // any more so it would refuse on those grounds regardless. + assertEquals(setOf("home"), published.keys) + val relays = published["home"]!!.single().getJSONArray("relays") + assertEquals(1, relays.length()) + assertEquals("home.example.com", relays.getString(0)) + } + + @Test + fun `the relay you are signed in to cannot be removed`() { + assertFailsWith { + homing().remove("home.example.com", seed, fnid, listOf("home.example.com")) + } + } + + @Test + fun `an account cannot be left with no mailbox at all`() { + assertFailsWith { + homing().remove("new.example.com", seed, fnid, listOf("new.example.com")) + } + } + + /** + * โš ๏ธ The serial has to move forward or the relay refuses the record as a + * rollback โ€” by the very check that stops an attacker replaying an old, + * genuinely signed list that happens to name only itself. + */ + @Test + fun `each published record carries a serial above the last`() { + add() + val first = published.values.first().single().getInt("serial") + published.clear() + homing().remove("new.example.com", seed, fnid, + listOf("home.example.com", "new.example.com")) + val second = published["home"]!!.single().getInt("serial") + assertTrue(second > first, "serial went $first -> $second") + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt b/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt new file mode 100644 index 0000000..027f43f --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt @@ -0,0 +1,240 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.E2ESession +import io.flamenet.e2e.PeerBundle +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONObject +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Reading mail from more than one relay (spec ยง20.4). + * + * ๐Ÿ”ด **Publishing a home record is only half of it.** The record tells senders + * to deliver a copy everywhere and the relay fans out โ€” but a copy sitting in a + * mailbox nothing reads is storage, not redundancy. These tests are about the + * half that reads, and about the three ways it goes wrong quietly: a copy read + * twice, one relay's cursor applied to another's ids, and a backup mailbox's + * bad day taking the signed-in relay down with it. + * + * Real ciphertext throughout: a real peer, a real ratchet, real envelopes. Only + * the HTTP is a mock. + */ +class MultiMailboxPollTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private lateinit var home: MockWebServer + private lateinit var backup: MockWebServer + private lateinit var vault: Vault + private lateinit var me: DeviceIdentity + + /** What each mailbox is holding: id to payload, plus an envelope id. */ + private val homeQueue = mutableListOf>() + private val backupQueue = mutableListOf>() + /** Set to make the backup answer 410 โ€” a device revoked at a BACKUP home. */ + private var backupGone = false + private var homeDown = false + + /** The host the record names, distinct from the URL this device dials. */ + private val backupHost get() = "backup.example.com" + + @BeforeTest fun start() { + home = MockWebServer().also { it.dispatcher = queueOf({ homeQueue }, { homeDown }); it.start() } + backup = MockWebServer().also { it.dispatcher = queueOf({ backupQueue }, { backupGone }); it.start() } + vault = Vault(MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + me = DeviceIdentity.create() + } + + @AfterTest fun stop() { home.shutdown(); backup.shutdown() } + + /** + * A relay that serves one mailbox. + * + * โš ๏ธ 404s the transparency-log routes: these fixtures are pre-ยง19 + * deployments, so the tests are about mailboxes and nothing else. + */ + private fun queueOf( + rows: () -> List>, + gone: () -> Boolean, + ) = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + if ("/e2e/log/" in path) return MockResponse().setResponseCode(404) + if ("/e2e/messages" in path) { + if (gone()) return MockResponse().setResponseCode(410) + val after = Regex("after=(\\d+)").find(path)?.groupValues?.get(1)?.toLong() ?: 0 + val body = rows().filter { it.first > after }.joinToString(",") { (id, payload, env) -> + """{"id":$id,"from_user":7,"from_device":"","payload":"$payload","envelope_id":"$env"}""" + } + return json("""{"messages":[$body],"cursor":0}""") + } + return MockResponse().setResponseCode(404) + } + } + + private fun json(body: String) = MockResponse() + .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + private fun conversations(): Conversations { + // โš ๏ธ Written into the vault rather than passed in: the client rebuilds + // its list of homes from the vault on every poll, which is what lets a + // mailbox added or dropped mid-session take effect without a relaunch. + vault.saveExtraHomes(listOf(Vault.ExtraHome( + relay = backupHost, username = "me", appPassword = "pw", + // โš ๏ธ The record names a hostname and cannot carry a port; how this + // device DIALS that host is a separate field, which is what lets a + // test โ€” or a self-hoster on a nonstandard port โ€” work without the + // record lying about its own name. + baseUrl = backup.url("/").toString()))) + return Conversations(me, vault, RelayClient(home.url("/").toString(), "me", "pw")) + } + + private fun myBundle(): PeerBundle { + val opk = me.generateOneTimePreKeys(1).first() + return PeerBundle.fromWire(me.registrationWire().put("opk", + JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)))) + } + + /** One real envelope from a peer, ready to be put in a mailbox. */ + private fun envelope(session: E2ESession, text: String) = session.encrypt(text).toPayload() + + // MARK: - Reading + + /** + * ๐Ÿ”ด The whole point. A relay this device is not signed in to is holding + * mail, and without this the message is simply never read. + */ + @Test + fun `a message waiting at another relay is read`() { + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + backupQueue += Triple(900L, envelope(session, "waiting at the other one"), "env-1") + + val received = conversations().poll() + + assertEquals(1, received.size, "got ${received.map { it.text }}") + assertEquals("waiting at the other one", received.single().text) + } + + /** + * โš ๏ธ **A copy at every home is the design, so a copy read twice is the + * failure.** And it would not present as a duplicate: the ratchet deletes a + * message key once used, so the second copy fails to open and is logged as + * a dropped envelope โ€” a message that looks lost rather than repeated. + */ + @Test + fun `the same message at two relays is read once`() { + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + val payload = envelope(session, "delivered twice") + // The SAME envelope id, because the sender stamps one per message and + // the two copies are the same message. + homeQueue += Triple(1L, payload, "env-same") + backupQueue += Triple(900L, payload, "env-same") + + val received = conversations().poll() + + assertEquals(1, received.count { it.text == "delivered twice" }, + "one message, however many mailboxes carried it") + } + + /** + * ๐Ÿ”ด Envelope ids come from each relay's own database, so id 900 there says + * nothing about id 900 here. One shared cursor would let the first + * mailbox's progress silently skip the second's mail. + */ + @Test + fun `each mailbox keeps its own cursor`() { + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + backupQueue += Triple(900L, envelope(session, "only at the backup"), "env-a") + + val conversations = conversations() + assertEquals(1, conversations.poll().size) + + assertEquals(900L, vault.watermarkFor(backupHost), "the backup's cursor has to move") + assertEquals(0L, vault.inboxWatermark, + "one relay's ids must not advance another relay's cursor") + + // And a second poll does not re-read what the backup already gave us. + assertTrue(conversations.poll().isEmpty(), "a second poll re-read the same mailbox") + } + + // MARK: - When something goes wrong + + /** + * ๐Ÿ”ด **A 410 from a BACKUP home is that mailbox ending, not this account + * ending.** Treating it like the signed-in relay's 410 would purge + * somebody's messages because one spare relay dropped a registration. + */ + @Test + fun `a revoked backup mailbox does not stop the signed-in one`() { + backupGone = true + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + homeQueue += Triple(1L, envelope(session, "through the front door"), "env-b") + + assertEquals("through the front door", conversations().poll().single().text) + } + + /** + * ๐Ÿ”ด **The failure this whole feature exists to survive.** A home relay that + * is down must not take every other mailbox down with it โ€” which is exactly + * what happens if the poll returns on the first error instead of carrying + * on to the others. + */ + @Test + fun `a dead home relay does not hide the backup mailbox`() { + homeDown = true + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + backupQueue += Triple(900L, envelope(session, "read me anyway"), "env-c") + + assertEquals("read me anyway", conversations().poll().single().text) + } + + /** + * โš ๏ธ **A failed poll is still not an empty inbox.** "No new messages" and + * "could not reach anything" are different, and the app says different + * words for them โ€” so when nothing at all could be read, the failure is + * raised rather than swallowed into an empty list. + */ + @Test + fun `when no mailbox can be read at all the failure is raised`() { + homeDown = true + backupGone = true + var threw = false + try { conversations().poll() } catch (_: Exception) { threw = true } + assertTrue(threw, "every mailbox failed and it was reported as an empty inbox") + } + + /** With no extra homes at all, nothing about the ordinary path changes. */ + @Test + fun `one mailbox behaves exactly as before`() { + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + homeQueue += Triple(1L, envelope(session, "just the one"), "env-d") + + val conversations = + Conversations(me, vault, RelayClient(home.url("/").toString(), "me", "pw")) + assertEquals("just the one", conversations.poll().single().text) + assertEquals(1L, vault.inboxWatermark) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/SendToAddressTest.kt b/client/src/test/kotlin/io/flamenet/client/SendToAddressTest.kt new file mode 100644 index 0000000..8470206 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/SendToAddressTest.kt @@ -0,0 +1,240 @@ +package io.flamenet.client + +import io.flamenet.e2e.ContactCard +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.SealedSender +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONObject +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Sending to somebody on another relay (spec ยง20.5). + * + * ๐Ÿ”ด **Always sealed, and here that is not a preference.** A relay that accepts + * a federated envelope stores it with no sender โ€” it was not told and must not + * invent one โ€” so an unsealed envelope arrives unattributable and is dropped. + * Locally, sealing is an improvement; across a boundary it is the only thing + * that makes the message deliverable at all. + * + * The other half is the return address. Their relay records none, and our user + * id means nothing on it, so a message without one decrypts on their phone and + * has nowhere to reply to โ€” which reads to the recipient as the app being + * broken rather than as a message being incomplete. + */ +class SendToAddressTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private lateinit var server: MockWebServer + private lateinit var vault: Vault + private lateinit var me: DeviceIdentity + private lateinit var peer: DeviceIdentity + + /** The last body posted to `/e2e/send/remote`. */ + private var sentBody: JSONObject? = null + private var accepted = 1 + private var unknown = listOf() + private var unreachable = listOf() + /** Devices the far relay reports for the address, empty for "nobody there". */ + private var remoteDevices: () -> String = { "" } + + private val theirCard = ContactCard( + "fnid:0123456789abcdefghjkmnpqrstvwxyz0123456789abcdefghjk", "peer.example.com") + + @BeforeTest fun start() { + me = DeviceIdentity.create() + peer = DeviceIdentity.create() + remoteDevices = { peer.registrationWire().toString() } + server = MockWebServer().also { it.dispatcher = relay(); it.start() } + vault = Vault(MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault.saveIdentity(me) + } + + @AfterTest fun stop() = server.shutdown() + + private fun relay() = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "/e2e/log/" in path -> MockResponse().setResponseCode(404) + "/e2e/send/remote" in path -> { + sentBody = JSONObject(request.body.readUtf8()) + json(JSONObject() + .put("ok", true).put("accepted", accepted) + .put("unknown", org.json.JSONArray(unknown)) + .put("unreachable", org.json.JSONArray(unreachable)).toString()) + } + "/e2e/remote/" in path -> + json("""{"devices":[${remoteDevices()}]}""") + else -> MockResponse().setResponseCode(404) + } + } + } + + private fun json(body: String) = MockResponse() + .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + private fun conversations(withAddress: Boolean = true): Conversations = + Conversations(me, vault, RelayClient(server.url("/").toString(), "me", "pw")).also { + it.myUserId = 5 + if (withAddress) it.myAddress = "fnid:mine@home.example.com" + } + + // MARK: - The happy path + + @Test + fun `a message to an address goes out sealed and names where to reply`() { + val sent = conversations().sendToAddress(theirCard, "across the boundary") + + assertTrue(sent.failed.isEmpty(), "failed: ${sent.failed}") + assertTrue(sent.sealed, "a federated envelope that is not sealed is undeliverable") + + val body = sentBody!! + assertEquals(theirCard.fnid, body.getString("to_fnid")) + assertEquals(theirCard.relay, body.getString("to_relay")) + + val messages = body.getJSONArray("messages") + assertEquals(1, messages.length()) + val payload = messages.getJSONObject(0).getString("payload") + assertTrue(SealedSender.isSealed(payload), "the envelope reached the wire unsealed") + + // ๐Ÿ”ด The return address is inside the seal, where the relay cannot read + // it and cannot strip it. + val opened = SealedSender.open( + SealedSender.blobFromPayload(payload), peer.identityDH.private, peer.deviceId) + assertEquals("fnid:mine@home.example.com", opened.claimedFromAddress) + assertEquals(me.deviceId, opened.claimedFromDevice) + } + + /** + * โš ๏ธ One id per MESSAGE, not per device or per mailbox. A recipient polling + * several homes collapses the copies by it; a fresh id per copy would show + * them the same message once per mailbox. + */ + @Test + fun `every copy of one message carries the same envelope id`() { + val second = DeviceIdentity.create() + remoteDevices = { + peer.registrationWire().toString() + "," + second.registrationWire().toString() + } + + conversations().sendToAddress(theirCard, "to both of their devices") + + val messages = sentBody!!.getJSONArray("messages") + assertEquals(2, messages.length(), "both of their devices must be addressed") + assertEquals(messages.getJSONObject(0).getString("envelope_id"), + messages.getJSONObject(1).getString("envelope_id")) + } + + /** The address is remembered, or the conversation is write-only. */ + @Test + fun `a sent-to address becomes reachable again`() { + val conversations = conversations() + assertTrue(conversations.remoteAddresses().isEmpty()) + conversations.sendToAddress(theirCard, "hello") + assertEquals(listOf(theirCard.address), conversations.remoteAddresses()) + } + + // MARK: - Refusals + + /** + * ๐Ÿ”ด **Refused up front rather than sent into a dead end.** Their relay + * records no sender, so without our address the message decrypts on their + * phone and has nowhere to reply to. + */ + @Test + fun `a device with no address of its own will not send`() { + val sent = conversations(withAddress = false).sendToAddress(theirCard, "hello") + assertFalse(sent.reachedSomeone) + assertTrue(sent.failed.values.single().contains("address"), + "the refusal has to say why: ${sent.failed}") + } + + @Test + fun `an address with no devices is a failure, not a silent success`() { + remoteDevices = { "" } + val sent = conversations().sendToAddress(theirCard, "hello") + assertFalse(sent.reachedSomeone) + } + + /** + * ๐Ÿ”ด **`accepted` first.** Checking only the failure fields reported a send + * as successful when the far relay had taken nothing at all: the app said + * sent, the peer's inbox stayed empty, and no error anywhere explained it. + */ + @Test + fun `taking no copies is a failed send whatever else the reply says`() { + accepted = 0 + val sent = conversations().sendToAddress(theirCard, "hello") + assertFalse(sent.reachedSomeone, "zero accepted was reported as delivered") + } + + /** And an address the far relay does not know says exactly that. */ + @Test + fun `an unknown address is named as one`() { + accepted = 0 + unknown = listOf(theirCard.fnid) + val sent = conversations().sendToAddress(theirCard, "hello") + assertTrue(sent.failed.values.single().contains("does not know"), + "got: ${sent.failed}") + } + + /** + * โš ๏ธ Partial delivery is a real outcome once an account has several + * mailboxes, and silence about it is how somebody believes a message landed + * when a copy did not. + */ + @Test + fun `a mailbox that did not take the copy is reported`() { + unreachable = listOf("backup.example.com") + val sent = conversations().sendToAddress(theirCard, "hello") + assertTrue(sent.reachedSomeone, "the message did reach somewhere") + assertTrue(sent.failed.values.single().contains("backup.example.com"), + "got: ${sent.failed}") + } + + // MARK: - Trust in the address keyspace + + /** + * ๐Ÿ”ด **A key never seen before is either a new device of theirs or one their + * relay minted, and nothing on the wire tells those apart.** The pin hangs + * off the address, because a user id on their relay means nothing on ours. + */ + @Test + fun `a changed identity key at an address refuses the send`() { + conversations().sendToAddress(theirCard, "first") + + // Their relay now serves a different identity for the same address. + val impostor = DeviceIdentity.create() + remoteDevices = { impostor.registrationWire().toString() } + + assertFailsWith { + conversations().sendToAddress(theirCard, "second") + } + } + + /** The same keys a second time are simply the same person. */ + @Test + fun `an unchanged identity sends again without ceremony`() { + conversations().sendToAddress(theirCard, "first") + assertTrue(conversations().sendToAddress(theirCard, "second").reachedSomeone) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt b/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt index a16d0b4..39893ee 100644 --- a/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt @@ -40,6 +40,16 @@ class TrustLogConsistencyTest { } private lateinit var server: MockWebServer + + /** + * The relay the object under test is pointed at. + * + * โš ๏ธ The key-log pin is stored per relay, because a single pin meant that + * signing in to a second relay checked ITS head against the first relay's + * root โ€” which cannot reconcile, so an honest relay was reported as forked. + * A test reading the pin has to name the same relay that wrote it. + */ + private val relayHost: String get() = server.url("/").host private lateinit var vault: Vault private lateinit var identity: DeviceIdentity @@ -110,8 +120,8 @@ class TrustLogConsistencyTest { t.verifyLogConsistencyIfDue() assertFalse(t.forkDetected) - assertEquals(8, vault.loadKeyLogPin()!!.size) - assertEquals(rootB64(8), vault.loadKeyLogPin()!!.root) + assertEquals(8, vault.loadKeyLogPin(relayHost)!!.size) + assertEquals(rootB64(8), vault.loadKeyLogPin(relayHost)!!.root) } /** @@ -129,7 +139,7 @@ class TrustLogConsistencyTest { assertTrue(t.forkDetected) // the pin is NOT advanced onto the smaller log - assertEquals(8, vault.loadKeyLogPin()!!.size) + assertEquals(8, vault.loadKeyLogPin(relayHost)!!.size) } @Test @@ -166,7 +176,7 @@ class TrustLogConsistencyTest { t.verifyLogConsistencyIfDue() assertTrue(t.forkDetected) - assertEquals(4, vault.loadKeyLogPin()!!.size) + assertEquals(4, vault.loadKeyLogPin(relayHost)!!.size) } /** @@ -182,7 +192,7 @@ class TrustLogConsistencyTest { t.verifyLogConsistencyIfDue() assertFalse(t.forkDetected) - assertEquals(4, vault.loadKeyLogPin()!!.size) + assertEquals(4, vault.loadKeyLogPin(relayHost)!!.size) } @Test diff --git a/client/src/test/kotlin/io/flamenet/client/TrustTest.kt b/client/src/test/kotlin/io/flamenet/client/TrustTest.kt index c55985f..c060de4 100644 --- a/client/src/test/kotlin/io/flamenet/client/TrustTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/TrustTest.kt @@ -34,6 +34,16 @@ class TrustTest { } private lateinit var server: MockWebServer + + /** + * The relay the object under test is pointed at. + * + * โš ๏ธ The key-log pin is stored per relay, because a single pin meant that + * signing in to a second relay checked ITS head against the first relay's + * root โ€” which cannot reconcile, so an honest relay was reported as forked. + * A test reading the pin has to name the same relay that wrote it. + */ + private val relayHost: String get() = server.url("/").host private lateinit var vault: Vault private lateinit var identity: DeviceIdentity @@ -276,7 +286,7 @@ class TrustTest { // No fnid argument: it comes from the pin, which is the whole point. t.enforceKeyLog(b, 7) - assertEquals(leaf, vault.loadKeyLogPin()?.root) + assertEquals(leaf, vault.loadKeyLogPin(relayHost)?.root) } // -- Key-log enforcement ------------------------------------------------- @@ -331,7 +341,7 @@ class TrustTest { trust().enforceKeyLog(b, 7) - val pin = vault.loadKeyLogPin() + val pin = vault.loadKeyLogPin(relayHost) assertEquals(1, pin?.size) assertEquals(leaf, pin?.root) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index a92df86..2a37fe8 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -521,13 +521,28 @@ class Vault(private val storage: Storage, private val key: SecretKey) { */ data class KeyLogPin(val size: Int, val root: String) - fun loadKeyLogPin(): KeyLogPin? = readJson(KEYLOG_PIN)?.let { - KeyLogPin(it.getInt("size"), it.getString("root")) + /** + * โš ๏ธ **Per relay, like [keyLogServed].** A single pin meant that signing in + * to a second relay checked ITS head against the first relay's root โ€” which + * cannot reconcile, so an honest relay was reported as forked and every + * contact on it became unreachable. + * + * Reads fall back to the pre-2026-08-30 global entry, so a device already + * holding a pin keeps it rather than starting trust-on-first-use again. + * Writes only ever go to the per-relay entry. + */ + fun loadKeyLogPin(relay: String? = null): KeyLogPin? { + if (relay != null) { + readJson(KEYLOG_PIN + "-" + relay)?.let { + return KeyLogPin(it.getInt("size"), it.getString("root")) + } + } + return readJson(KEYLOG_PIN)?.let { KeyLogPin(it.getInt("size"), it.getString("root")) } } - fun saveKeyLogPin(pin: KeyLogPin) = writeJson(KEYLOG_PIN, JSONObject() - .put("size", pin.size) - .put("root", pin.root)) + fun saveKeyLogPin(pin: KeyLogPin, relay: String? = null) = + writeJson(if (relay == null) KEYLOG_PIN else KEYLOG_PIN + "-" + relay, + JSONObject().put("size", pin.size).put("root", pin.root)) /** * Whether this relay has ever answered the head route. @@ -546,9 +561,17 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * from the pin would make a gossiped head start demanding a log route the * relay may never have had. */ - fun keyLogServed(): Boolean = readJson(KEYLOG_SERVED) != null + /** + * โš ๏ธ **Per relay.** A single global flag was the first version and was + * wrong: signing in to a second relay that keeps no log would have been + * read as the FIRST one going quiet, so an honest deployment without + * transparency became a fork warning and every contact on it became + * unreachable. + */ + fun keyLogServed(relay: String): Boolean = readJson(KEYLOG_SERVED + "-" + relay) != null - fun rememberKeyLogServed() = writeJson(KEYLOG_SERVED, JSONObject().put("seen", true)) + fun rememberKeyLogServed(relay: String) = + writeJson(KEYLOG_SERVED + "-" + relay, JSONObject().put("seen", true)) // -- Inbox watermark ----------------------------------------------------- @@ -615,13 +638,35 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * stored credential authenticates at exactly one home and silently fails at * the rest โ€” which reads as "the backup mailbox never has anything in it". */ - data class ExtraHome(val relay: String, val username: String, val appPassword: String) + data class ExtraHome( + /** + * The hostname, as the signed home record names it. + * + * โš ๏ธ This is the identity of the mailbox and what a sender resolves โ€” + * it is inside the signature, so it cannot carry a scheme or a port. + */ + val relay: String, + val username: String, + val appPassword: String, + /** + * How THIS device reaches that relay, which is a separate question. + * + * Defaults to `https://`, which is the only correct answer in + * production and the only one the record can express. Held separately + * rather than rebuilt at every call site so there is one place that + * decides, and so a self-hosted or test deployment reachable some other + * way does not need the record to lie about its own name. + */ + val baseUrl: String = "https://$relay", + ) fun extraHomes(): List = readJson(EXTRA_HOMES)?.optJSONArray("homes")?.let { a -> (0 until a.length()).map { val o = a.getJSONObject(it) - ExtraHome(o.getString("relay"), o.getString("user"), o.getString("pw")) + val relay = o.getString("relay") + ExtraHome(relay, o.getString("user"), o.getString("pw"), + o.optString("url").ifEmpty { "https://$relay" }) } } ?: emptyList() @@ -629,7 +674,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { val array = org.json.JSONArray() homes.forEach { array.put(JSONObject() - .put("relay", it.relay).put("user", it.username).put("pw", it.appPassword)) + .put("relay", it.relay).put("user", it.username).put("pw", it.appPassword) + .put("url", it.baseUrl)) } writeJson(EXTRA_HOMES, JSONObject().put("homes", array)) }