From 91f58956cf05b836b48a667cc4739dff0df7bc0b Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 30 Aug 2026 15:40:28 -0400 Subject: [PATCH] Check every key the relay can prove, and reach other relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things this app could describe and not do. **Key transparency was a switch almost nobody found.** `requireKeyLog` was off by default, so the log was published, verified in tests, and checked for approximately no one. The reason it was opt-in — a relay predating §19 keeps no log, and refusing every peer there is worse than the attack — argues for skipping relays that CANNOT answer, never for skipping the ones that can. `enforceKeyLogIfServed` draws that line: ask the head route once, enforce fully if it answers, skip if it is absent. The switch survives as the stricter question underneath — refuse a relay that keeps no log at all — which is a real choice for somebody who knows their server publishes one. Two things make that safe rather than merely automatic. The head is asked for SEPARATELY, because `enforceKeyLog` reads three routes and a relay wanting to skip the check would otherwise only have to 404 the middle one. And a relay that has answered before is remembered, so it cannot publish a log until clients are talking and then stop — logs do not disappear, and that is a fork rather than an absence. The flag is its own vault entry rather than the pin, because the pin can arrive from gossip and would make a logless relay look dishonest. Both subject forms are now accepted for a leaf. A leaf names `fnid:` once an account has claimed an address and `local:` before, and the hash committed to whichever was true when it was written — so a contact who registered a device and then claimed an address has older leaves under one form and newer ones under the other. A verifier computing a single subject would refuse half the network on the day claiming turned on. Either candidate still requires the relay to have appended a leaf binding the exact key it served. **Federation was iOS-only.** The relay has spoken it for a while and so has iPhone; Android could neither send to an address nor read one that arrived. Now: the three remote routes, an always-sealed send that carries our address as the return path, and a receive side that files a federated message under that address rather than under `from_user`, which their relay never set. Filing by user id would drop every message from every other relay into thread 0 — or worse, into a local contact's, if their row numbers happened to collide. The address keyspace is kept apart from the local one throughout, and that is the load-bearing decision. A remote sender is pinned, bound and threaded by `fnid@relay`; they cannot write a local delivery key or avatar, cannot post into a group, and cannot ring a phone, because all four are addressed by a user id that means nothing across the boundary. Wired end to end rather than left as a capability: the add-contact field recognises an address and offers to message it — the QR scanner already prefilled that field and had its address looked up as a handle — and conversations on other relays get their own section, without which a reply is unreachable short of retyping 52 characters of base32. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/ChatScreens.kt | 117 ++++++-- .../messenger/app/MessengerViewModel.kt | 111 ++++++++ .../io/flamenet/messenger/app/Session.kt | 44 ++- .../io/flamenet/client/AccountIdentity.kt | 140 ++++++++++ .../io/flamenet/client/Conversations.kt | 253 ++++++++++++++++-- .../kotlin/io/flamenet/client/RelayClient.kt | 104 +++++++ .../main/kotlin/io/flamenet/client/Trust.kt | 133 +++++++-- .../io/flamenet/client/AutomaticKeyLogTest.kt | 192 +++++++++++++ .../flamenet/client/FederatedReceiveTest.kt | 253 ++++++++++++++++++ .../io/flamenet/client/GossipHeadSentTest.kt | 42 ++- .../kotlin/io/flamenet/e2e/ContactCard.kt | 106 ++++++++ .../src/main/kotlin/io/flamenet/e2e/FNID.kt | 39 +++ .../kotlin/io/flamenet/e2e/SealedSender.kt | 23 ++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 111 ++++++++ 14 files changed, 1601 insertions(+), 67 deletions(-) create mode 100644 client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt create mode 100644 client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt create mode 100644 client/src/test/kotlin/io/flamenet/client/FederatedReceiveTest.kt create mode 100644 engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 09995b9..8605863 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import io.flamenet.client.IdentityTrust +import io.flamenet.e2e.ContactCard import io.flamenet.e2e.SafetyNumber import io.flamenet.e2e.Vault import java.text.SimpleDateFormat @@ -159,6 +160,60 @@ fun ColumnScope.BuddyList( query.isBlank() || it.displayName.contains(query, true) || it.handle.contains(query, true) } + // 🔴 **Conversations on other relays get their own section**, because they + // are not contacts and cannot be made into any. A remote peer has no + // account here, so nothing adds them to a roster — and without a row, a + // reply that arrives sits in a thread with no way back to it short of + // retyping a 52-character derived key. + val remote = phase.remoteAddresses.reversed().filter { + query.isBlank() || it.contains(query, true) + } + if (remote.isNotEmpty()) { + SectionLabel("Other relays") + Column(modifier = Modifier.fillMaxWidth().testTag("remote-threads")) { + remote.forEach { address -> + val unread = Vault.threadForAddress(address) in phase.unread + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { model.openAddressChat(address) } + .padding(vertical = Kit.rowGap / 2) + .testTag("remote-$address"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Kit.gutter), + ) { + // ⚠️ No presence dot and no initials. We know nothing about + // this person beyond an address — their relay does not tell + // ours whether they are online, and initials taken off a + // base32 key would be two meaningless characters dressed up + // as a name. + Avatar(name = "", online = false, picture = null, model = model) + Column(modifier = Modifier.weight(1f)) { + Text( + address.substringAfter('@'), + fontSize = 16.sp, + fontWeight = if (unread) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // The address in full underneath, monospaced. An fnid + // is a derived key, not a name — one that is nearly + // right belongs to somebody else — so it is never + // abbreviated to something that merely looks familiar. + Text( + address.substringBefore('@'), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = Palette.SecondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + SectionLabel("Contacts") + } Column(modifier = Modifier.fillMaxWidth().testTag("buddies")) { shown.forEach { buddy -> val name = buddy.displayName.ifBlank { buddy.handle } @@ -773,11 +828,18 @@ fun ColumnScope.AccountScreen( Spacer(Modifier.height(Space.sm)) } + // 🔴 This switch used to be what turned checking ON, and it was off by + // default — so the protection shipped and almost nobody had it. + // Checking is now automatic on any server that keeps a log; what is + // left here is the narrower question of what to do where there is + // nothing to check against. SettingsRow( - label = "Require published keys", - description = "Refuse to send unless this server can prove the recipient's keys " - + "appear in the public log it publishes. Older servers keep no log and every " - + "message to them will fail.", + label = "Also refuse servers with no log", + description = "Your app already checks that a contact's keys appear in the public " + + "log this server publishes, on its own, before the first message. A server " + + "old enough to keep no log has nothing to show, and those chats go ahead " + + "unchecked — turn this on to refuse them instead. Only if you know your " + + "server publishes a log, or nobody on it will be reachable.", ) { Switch( checked = phase.requireKeyLog, @@ -2604,27 +2666,52 @@ fun ColumnScope.AddContactForm( return } + // 🔴 **A scanned code is an ADDRESS, and this form treated everything + // as a username.** The scanner already prefills here, so somebody who + // scanned a card from another relay got their address looked up as a + // handle on ours, was told no such person exists, and had no other way + // in. One field either way — what it is, is a fact about the text, not + // a mode the person has to pick. + val card = remember(username) { ContactCard.decode(username) } + OutlinedTextField( value = username, onValueChange = { username = it if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup() }, - label = { Text("Username") }, + label = { Text(if (card != null) "Address" else "Username or address") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("new-contact"), ) - // ⚠️ **Look up first, then add.** Adding straight from a typed string - // means a typo silently becomes a contact who is a stranger, and the - // first thing sent to them is a message meant for somebody else. - KitPrimaryButton( - text = "Look up", - onClick = { model.lookupMember(username) }, - enabled = username.isNotBlank() && !phase.busy, - modifier = Modifier.fillMaxWidth(), - tag = "lookup-contact", - ) + if (card != null) { + Text( + "That's an address on ${card.relay}. Messages go straight there — nothing " + + "to add to your contacts first, and you don't need an account on it.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("address-recognised"), + ) + KitPrimaryButton( + text = "Message them", + onClick = { model.openAddressChat(username); onDone() }, + enabled = !phase.busy, + modifier = Modifier.fillMaxWidth(), + tag = "message-address", + ) + } else { + // ⚠️ **Look up first, then add.** Adding straight from a typed + // string means a typo silently becomes a contact who is a stranger, + // and the first thing sent to them is a message meant for somebody + // else. + KitPrimaryButton( + text = "Look up", + onClick = { model.lookupMember(username) }, + enabled = username.isNotBlank() && !phase.busy, + modifier = Modifier.fillMaxWidth(), + tag = "lookup-contact", + ) + } phase.lookupMiss?.let { Text( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index e137ec7..9ed36b3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -11,6 +11,8 @@ import io.flamenet.client.RelayException import io.flamenet.e2e.AccountKdf import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Ed25519 +import io.flamenet.client.AccountIdentity +import io.flamenet.e2e.ContactCard import io.flamenet.e2e.FNID import io.flamenet.e2e.HistoryExport import io.flamenet.e2e.Vault @@ -95,6 +97,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { */ val snoozedThreads: Set = emptySet(), + /** + * Conversations with people on other relays, most recent last. + * + * 🔴 **Without this a federated conversation is write-only.** A + * remote peer is not in the roster — they have no account here and + * no user id — so a reply that arrives lands in a thread with + * nothing on any screen pointing at it. The address had to be + * retyped to get back in, which for a 52-character derived key is + * the same as unreachable. + */ + val remoteAddresses: List = emptyList(), + /** Same question for the group list. See [buddiesLoaded]. */ val groupsLoaded: Boolean = false, val groups: List = emptyList(), @@ -204,6 +218,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val lines: List, val peerId: Long? = null, val group: io.flamenet.client.RelayClient.Group? = null, + /** + * `fnid@relay` when this conversation is with somebody on another + * relay, null when it is not. + * + * ⚠️ A third case beside [peerId] and [group], not a variant of the + * first. A remote peer's user id belongs to their relay and means + * nothing here, so every path that dispatches on `peerId` — sending, + * calling, the safety number, the disappearing timer — has to see + * `null` and take the address branch instead of quietly using a row + * number that points at somebody else. + */ + val address: String? = null, /** * What we believe about the other side's identity keys (§19). * @@ -462,6 +488,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { relay.registerDevice(device, preKeys) vault.saveIdentity(device) + // 🔴 **Register the address, don't just derive it.** Deriving it makes + // it ours; claiming is what makes us *reachable* at it. A peer relay + // resolving `fnid@here` asks this relay, and until the claim lands the + // answer is "no such address" — so every account had an address it + // could show on a card and nobody could send to. + // + // ⚠️ `runCatching`, like the derivation above: a relay that is down, + // too old to have the route, or simply slow must not keep somebody out + // of their own messages. It is idempotent server-side and retried on + // the next sign-in, so losing one attempt costs one launch. + if (accountKeySeed != null) { + runCatching { AccountIdentity.claim(relay, accountKeySeed, account.userId) } + } + vault.saveCredential(Vault.Credential( relayBaseUrl = config.baseUrl, username = account.username, @@ -479,6 +519,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), ) + // 🔴 **Our return address, before the first federated send.** A relay + // that accepts a federated envelope records no sender, and our user id + // means nothing on it — so without this a message crosses the boundary, + // decrypts on their phone, and cannot be replied to. + // + // ⚠️ Through `ContactCard.canonical` rather than string-built: the + // recipient parses it with the same rules, and an address that would + // not survive the trip is better left unset than sealed. Half an + // address is worse than none. + session!!.conversations.myAddress = + fnid?.let { ContactCard.canonical("$it@${config.host}") } + val fetchedBuddies = runCatching { session!!.roster() } val fetchedGroups = runCatching { session!!.relay.listGroups() } @@ -502,6 +554,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // while sends were being refused, which is the same confusion in // the opposite direction. requireKeyLog = strictKeyLog, + remoteAddresses = session!!.remoteAddresses(), buddies = fetchedBuddies.getOrDefault(emptyList()), buddiesLoaded = fetchedBuddies.isSuccess, avatars = session?.avatars( @@ -610,6 +663,41 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + /** + * Open a conversation with somebody on another relay. + * + * ⚠️ **Nothing is fetched here, and nothing is verified.** A remote thread + * exists as soon as an address has been typed, exactly as a local one + * exists before the first message — the address is checked when a message + * is actually sent, where a refusal has something to say to the person who + * caused it. Reaching across a relay boundary to validate a paste would + * also tell that relay somebody was thinking about one of its users. + * + * A malformed address opens nothing rather than opening a thread that can + * never send. `ContactCard.decode` is the one place that decides. + */ + fun openAddressChat(text: String) { + val current = session ?: return + val card = ContactCard.decode(text) ?: run { + update { it.copy(error = "That is not an address. It looks like " + + "fnid:… followed by @ and the relay's hostname.") } + return + } + if (card.address == current.conversations.myAddress) { + update { it.copy(error = "That is your own address.") } + return + } + val thread = Vault.threadForAddress(card.address) + update { + it.copy( + openChat = Phase.Chat( + thread, card.address, current.history(thread), address = card.address), + unread = it.unread - thread, + error = null, + ) + } + } + fun openGroup(group: io.flamenet.client.RelayClient.Group) { val current = session ?: return val thread = Vault.threadForGroup(group.id) @@ -1836,7 +1924,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val result = withContext(Dispatchers.IO) { runCatching { val group = chat.group + val address = chat.address if (group != null) current.sendGroup(group, text) + // 🔴 Before the `peerId!!` below, because a remote thread has + // no peer id and that assertion would crash rather than send. + // + // ⚠️ No link preview. §6.12 fetches the card on the SENDER's + // device to keep the recipient off the URL, and doing that here + // would tell the site that somebody on this relay is writing to + // somebody on another one — a correlation the whole federated + // path exists to avoid. A plain URL is a perfectly good thing + // for a message to contain. + else if (address != null) { + val card = ContactCard.decode(address) + ?: error("this conversation's address is unreadable") + current.sendToAddress(card, text) + } // §6.12 — fetched HERE, on the sender's device, and carried // inside the ciphertext. The recipient never touches the URL: a // card each of them fetched would tell the site who read the @@ -2023,6 +2126,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // A thread that is already open is being read, // so it does not also become unread. unread = state.unread + threads - setOfNotNull(open?.threadId), + // ⚠️ Refreshed here, not only at sign-in. A + // first message from another relay creates a + // thread nothing else on any screen points at — + // its sender is not in the roster and never will + // be — so a list computed once at launch would + // leave the reply unreachable until the next + // one. + remoteAddresses = current.remoteAddresses(), ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index b7f080b..860281e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -4,6 +4,7 @@ import io.flamenet.client.Conversations import io.flamenet.client.MessengerApi import io.flamenet.client.RelayClient import io.flamenet.e2e.Attachment +import io.flamenet.e2e.ContactCard import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault @@ -305,6 +306,25 @@ class Session( return sent } + /** + * Send to somebody on another relay, and record it in their thread. + * + * ⚠️ **Not queued on failure, unlike [send].** The local outbox retries by + * user id against this relay; a remote address has neither. A federated + * send that fails is reported so the person can try again rather than + * silently held in a queue that will never drain. + */ + fun sendToAddress(card: ContactCard, text: String): Conversations.Sent { + val sent = conversations.sendToAddress(card, Content.text(text)) + record(Vault.threadForAddress(card.address), text, mine = true, + at = System.currentTimeMillis(), + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + return sent + } + + /** Addresses on other relays this device has exchanged messages with. */ + fun remoteAddresses(): List = conversations.remoteAddresses() + /** One call, as the list draws it. */ data class CallEntry( val peerId: Long, @@ -670,8 +690,14 @@ class Session( fun receive(): Set { val everything = conversations.poll() everything.forEach { message -> - message.content.call?.let { signal -> - onCallSignal?.invoke(message.fromUser, signal, message.at) + // ⚠️ Local calls only. Signalling is addressed by user id the + // whole way down — the answer, the ICE candidates, the hang-up — + // so a federated offer would ring and then have nowhere to reply + // to. Ignored rather than half-rung. + if (message.fromAddress == null) { + message.content.call?.let { signal -> + onCallSignal?.invoke(message.fromUser, signal, message.at) + } } } val arrived = everything.filter { it.content.isRenderable } @@ -686,7 +712,7 @@ class Session( // One that arrives already expired — posted while this device was // offline — is dropped rather than shown late. val ref = message.content.attachment - if (ref != null && ref.kind == Attachment.STORY) { + if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) { val story = Vault.Story( id = ref.id, authorId = message.fromUser, @@ -708,15 +734,25 @@ class Session( // member we REMOVED, whose 1:1 session outlives their membership — // post into a group they do not belong to. The roster is the // relay's; a group it does not list for us is not ours. - val gid = message.content.groupId + // ⚠️ A group id from another relay names a group on THEIR relay, + // which our roster check would look up here and either miss or, + // worse, match by coincidence. Groups do not federate yet, so a + // remote sender's group claim is ignored and the message files in + // their own thread. + val gid = if (message.fromAddress != null) null else message.content.groupId if (gid != null) { val known = groupRoster(gid) if (known == null || message.fromUser !in known) { continue } } + // 🔴 **A federated message files under its ADDRESS.** Their relay + // told ours nothing about who sent it, so `fromUser` is 0 — filing + // by it would drop every message from every other relay into one + // thread with no name on it, mixed together. val thread = gid ?.let(Vault::threadForGroup) + ?: message.fromAddress?.let(Vault::threadForAddress) ?: Vault.threadForUser(message.fromUser) record(thread, message.content.text, mine = false, attachment = message.content.attachment, diff --git a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt new file mode 100644 index 0000000..e87aef4 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt @@ -0,0 +1,140 @@ +package io.flamenet.client + +import io.flamenet.e2e.Ed25519 +import io.flamenet.e2e.FNID + +/** + * Turning a passphrase into an address, and telling relays where to deliver. + * + * ## Why this exists at all + * + * A relay's `user_id` is a row number it assigned itself. Register at one relay + * and everything about you — your name there, your devices, your mailbox — is + * that relay's to keep or to lose. This makes the *identity* the user's + * instead: an Ed25519 key stretched from the passphrase, whose public half is + * the address. No server issues it, so no server can take it away. + * + * The practical consequence is the one worth stating: sign in with the same + * passphrase at a relay that has never heard of you and you get **the same + * address**. Contacts who accepted you before still see the same person. A + * relay shutting down costs a mailbox, not an identity. + * + * ## Why it is here and not in the engine + * + * The engine holds no network. Deriving the key and building the bytes to sign + * are engine work and live in [FNID] and `AccountKdf`; presenting the result to + * a relay is client work and lives here, so the engine stays testable without + * one and this stays swappable without touching crypto. + */ +object AccountIdentity { + + /** The address for an account seed. Derived, never fetched. */ + fun address(seed: ByteArray): String = FNID.address(Ed25519.fromSeed(seed).publicKey) + + /** + * Claim the address at [relay], learning which audience to bind from the + * relay itself. + * + * ⚠️ **The audience is not the caller's to guess.** It is the string the + * relay checks the signature against, so a caller that inferred 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. + * Reading it from the descriptor means the only way to get it wrong is for + * the relay to lie about its own name, which costs it the claim rather than + * us the identity. + * + * Self-certifying: the signature is made with the very key being + * registered, so the relay confirms possession and forms no opinion about + * whose key it is — the property that lets a hostile relay be tolerated. + * + * Returns the address the relay recorded. + */ + fun claim(relay: RelayClient, seed: ByteArray, userId: Long): String { + val key = Ed25519.fromSeed(seed) + val pub = b64(key.publicKey) + val audience = relay.relayDescriptor().getString("audience") + val sig = b64(Ed25519.sign(seed, FNID.claimCanonical(audience, userId, pub))) + return relay.claimAccountIdentity(pub, sig).getString("fnid") + } + + /** + * Publish where this account keeps mailboxes, to every relay in the list. + * + * Every listed relay is told, not just the one being added: a home that + * does not know it is one will still accept mail, but a *sender* asking it + * for the record would be handed a stale list and would skip the others. + * Delivering the record everywhere is what keeps them from disagreeing. + * + * Takes a client **per home**, and that is the shape of the feature rather + * than an implementation detail: a relay will not accept a record from + * somebody with no account there, and a token minted by one relay is + * rejected by every other because the audience is bound. So homing + * somewhere means registering there first, and a map makes that a + * compile-time fact instead of a runtime surprise. + * + * Returns the relays that refused, with why. Reported rather than thrown on + * the first failure: one home being unreachable while the rest accept is a + * partial success the user can act on, and collapsing it into a single + * error would hide which. + */ + fun publishHomes( + seed: ByteArray, fnid: String, serial: Int, homes: Map + ): Map { + val relays = homes.keys.toList() + val sig = b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays))) + val failures = LinkedHashMap() + for ((host, client) in homes) { + runCatching { client.publishHomes(serial, relays, sig) } + .onFailure { failures[host] = it } + } + return failures + } + + /** + * The next serial to publish, read from what is already out there rather + * than from local state. + * + * A device that was 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. + */ + fun nextSerial(fnid: String, clients: Collection): Int { + var highest = 0 + for (c in clients) { + val record = runCatching { c.homes(fnid) }.getOrNull() ?: continue + highest = maxOf(highest, record.optInt("serial", 0)) + } + return highest + 1 + } + + /** + * Check a home record against the address it claims to be for. + * + * Needs nothing but the record, because the address *is* the public key — + * which is the point. A sender must not have to ask a relay whether that + * relay is telling the truth about where to deliver. + */ + fun verifyHomes(fnid: String, serial: Int, relays: List, signatureB64: String): Boolean { + val pub = FNID.decode(fnid) ?: return false + val sig = runCatching { unb64(signatureB64) }.getOrNull() ?: return false + return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays)) + } + + /** Relays named by a record, but only once it verifies. */ + fun verifiedRelays(record: org.json.JSONObject): List? { + val fnid = record.optString("fnid").ifEmpty { return null } + val serial = record.optInt("serial", -1) + if (serial < 0) return null + val array = record.optJSONArray("relays") ?: return null + val relays = (0 until array.length()).map { array.getString(it) } + val sig = record.optString("sig").ifEmpty { return null } + return if (verifyHomes(fnid, serial, relays, sig)) relays else null + } + + // The module's own helper, so this encodes identically to every other blob + // the transport sends. `android.util.Base64` is deliberately not reachable + // here: this module is plain JVM precisely so its tests run against a real + // relay with no emulator. + private fun b64(b: ByteArray): String = Wire.b64(b) + private fun unb64(s: String): ByteArray = Wire.unb64(s) +} diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index bff18cb..871ab24 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -1,6 +1,7 @@ package io.flamenet.client import io.flamenet.e2e.Attachment +import io.flamenet.e2e.ContactCard import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.E2ESession @@ -63,6 +64,22 @@ class Conversations( * Carried for call signalling — see [RelayClient.Incoming.createdAt]. */ val at: Long = 0, + /** + * `fnid@relay` when this came from another relay, null when it did not. + * + * 🔴 **This, not [fromUser], is who wrote a federated message.** Our + * relay records no sender for an envelope a peer deposited, and the + * `from_user` sealed inside it is a row number on a machine we have + * never heard of — user 12 there and user 12 here are unrelated people. + * Filing one of these by user id would drop every federated message + * into thread 0, or worse, into a local contact's thread. + * + * Bound to the sending device first-use-wins, exactly as [fromUser] is: + * the ratchet proves the device, the address is a claim from inside the + * seal, so a device that later claims a different address keeps the one + * it was first seen with. + */ + val fromAddress: String? = null, ) { val text: String get() = content.text } @@ -391,9 +408,9 @@ class Conversations( * interception, so this stops and asks rather than guessing. Accepting * is [Trust.acceptIdentityChange]. * - [KeyNotLoggedException] / [KeyLogForkException] — the relay cannot - * prove the key it served is in the log it publishes. Raised only when - * `trust.requireKeyLog` is on, because a relay predating the log has - * none and refusing everyone there is the worse failure. + * prove the key it served is in the log it publishes. Raised on any + * relay that keeps a log, with no setting to find first; a relay that + * keeps none is skipped unless `trust.requireKeyLog` says otherwise. * * ⚠️ A fork already detected by gossip fails the send outright, whatever * this particular bundle looks like. A relay caught serving two logs has @@ -413,11 +430,7 @@ class Conversations( ) { throw IdentityChangedException(toUserId) } - if (!trust.requireKeyLog) return - if (trust.forkDetected) { - throw KeyLogForkException("a fork was already detected on this relay") - } - theirs.forEach { trust.enforceKeyLog(it, toUserId) } + theirs.forEach { trust.enforceKeyLogIfServed(it, toUserId) } } private fun sendPlaintext(toUserId: Long, text: String): Sent { @@ -575,6 +588,171 @@ class Conversations( return Sent(delivered, failed, sealed = peerKey != null) } + // -- Federation (§20.5) -------------------------------------------------- + + /** + * Send to somebody on another relay. + * + * 🔴 **A parallel path, and [sendPlaintext] is untouched.** The local one + * is keyed on a row number this relay assigned; a remote peer has an + * address and a home. Folding the two together would mean editing the code + * where a mistake loses a message silently, on the path that already works. + * + * 🔴 **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 on the floor. Locally, sealing is an improvement; across a + * boundary it is the only thing that makes the message deliverable at all. + * + * ⚠️ **Our own address is required**, because the receiving relay records + * none. Without it the message decrypts on their device, appears, and has + * nowhere to reply to — which reads to the recipient as the app being + * broken. Refused up front rather than sent into that. + */ + fun sendToAddress(card: ContactCard, text: String): Sent { + val mine = myAddress + ?: return Sent(emptyList(), mapOf(card.address to + "this device has no address of its own yet, so a reply could not reach it")) + + // The directory, not the bundle route — this consumes nothing on the + // far side, and the far side's prekey pool belongs to somebody who + // cannot see why it is draining. + val directory = relay.remoteDevices(card.fnid, card.relay) + val stubs = (0 until directory.length()).map { + val o = directory.getJSONObject(it) + PeerBundle( + deviceId = o.optString("device_id"), + identityDH = runCatching { Wire.unb64(o.optString("ik_dh")) } + .getOrDefault(ByteArray(0)), + identitySigning = runCatching { Wire.unb64(o.optString("ik_sig")) } + .getOrDefault(ByteArray(0)), + signedPreKeyId = 0, + signedPreKey = ByteArray(0), + signedPreKeySignature = ByteArray(0), + oneTimePreKeyId = 0, + oneTimePreKey = null, + proto = o.optInt("proto", 1), + ) + } + if (stubs.isEmpty()) { + return Sent(emptyList(), mapOf(card.address to "that address has no devices")) + } + + // The same first-use gate as the local path, in the address keyspace. + // Trust here cannot borrow from the local one: a user id on their relay + // means nothing on ours, so a pin has to hang off the address. + if (!recordRemoteTrust(card.address, stubs.map { it.identitySigning })) { + throw IdentityChangedException(0L) + } + + // Full bundles only when something genuinely needs establishing. Same + // stub/full union as the local path and for the same reason: a stub + // cannot open a session, and a device silently dropping out of the + // fan-out is how somebody stops receiving on their tablet with nothing + // anywhere saying so. + val needsEstablish = stubs.any { existingSession(it.deviceId) == null } + val bundles = if (!needsEstablish) stubs else { + // ⚠️ Fetched ONCE into a local. This route pops a one-time prekey + // per device on the far relay, so calling it inside the loop would + // drain a stranger's pool at one request per device per send. + val served = relay.remoteBundles(card.fnid, card.relay) + val full = (0 until served.length()) + .mapNotNull { runCatching { PeerBundle.fromWire(served.getJSONObject(it)) } + .getOrNull() } + .associateBy { it.deviceId } + stubs.map { full[it.deviceId] ?: it } + } + + // ⚠️ One id per MESSAGE, not per device and not 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. + val envelopeId = java.util.UUID.randomUUID().toString() + val messages = mutableListOf() + val delivered = mutableListOf() + val failed = LinkedHashMap() + + for (bundle in bundles) { + try { + val session = sessionFor(bundle) + val envelope = session.encrypt(text) + // Saved before the request, as locally: a relay that accepts an + // envelope our ratchet has not advanced past leaves the peer + // holding a message neither side can recover. The reverse costs + // one unused message key. + vault.saveSession(bundle.deviceId, session) + val blob = SealedSender.seal( + envelope = envelope, + fromUser = myUserId ?: 0L, + fromDevice = identity.deviceId, + toDevice = bundle.deviceId, + recipientIKdh = bundle.identityDH, + fromAddress = mine, + ) + messages += RelayClient.RemoteMessage( + bundle.deviceId, blob.toPayload(), envelopeId) + delivered += bundle.deviceId + } catch (e: Exception) { + failed[bundle.deviceId] = e.message ?: e::class.simpleName.orEmpty() + } + } + if (messages.isEmpty()) return Sent(delivered, failed, sealed = false) + + val result = relay.sendRemote(card.fnid, card.relay, messages) + // 🔴 **`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. Zero accepted is a failed send whatever else the reply + // says. + val accepted = result.optInt("accepted", 0) + if (accepted <= 0) { + val unknown = result.optJSONArray("unknown")?.length() ?: 0 + failed[card.address] = if (unknown > 0) { + "${card.relay} does not know that address" + } else { + "${card.relay} accepted the request but took no copies" + } + return Sent(emptyList(), failed, sealed = true) + } + // 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. + result.optJSONArray("unreachable")?.let { list -> + if (list.length() > 0) { + failed[card.address] = "some of their mailboxes did not take it: " + + (0 until list.length()).joinToString(", ") { list.getString(it) } + } + } + vault.rememberRemoteAddress(card.address) + return Sent(delivered, failed, sealed = true) + } + + /** + * This device's own `fnid@relay`, sealed into every federated envelope as + * the return address. + * + * Set by the app once the account key has been derived, because the fnid + * comes off the passphrase and this layer deliberately never sees one. + */ + @Volatile var myAddress: String? = null + + /** Trust on first use, in the address keyspace. */ + private fun recordRemoteTrust(address: String, offered: List): Boolean { + val usable = offered.filter { it.isNotEmpty() } + val pinned = vault.pinnedAddressIdentities(address) + if (pinned.isEmpty()) { + if (usable.isEmpty()) return false + vault.pinAddressIdentities(address, usable) + return true + } + // Every offered key must already be pinned. A key never seen is either + // a new device of theirs or one their relay minted, and nothing on the + // wire tells those apart. + return usable.isNotEmpty() && usable.all { key -> pinned.any { it.contentEquals(key) } } + } + + /** Addresses this device has exchanged federated messages with. */ + fun remoteAddresses(): List = vault.remoteAddresses() + // -- Disappearing messages (§6.11) --------------------------------------- /** @@ -674,10 +852,29 @@ class Conversations( Wire.unb64(envelope.handshake!!.getString("ik_sig")) }.getOrNull() val claimant = opened?.claimedFromUser ?: message.fromUser - if (offeredIK == null || claimant <= 0L || - trust.record(claimant, listOf(offeredIK), trust.pinnedFnid(claimant)) - == IdentityTrust.CHANGED - ) { + // 🔴 **The gate applies in whichever keyspace the + // sender is in.** A first handshake from another relay + // carries a user id that means nothing here, so + // checking it against the local pins would either + // compare it to an unrelated contact's keys or fall + // through the `claimant <= 0` guard and refuse every + // federated first contact. The address keyspace has its + // own pin, set on our first send to them and on their + // first message to us, and it is the one that applies. + val addressClaim = opened?.claimedFromAddress + ?.let { ContactCard.canonical(it) } + val refused = when { + offeredIK == null -> true + addressClaim != null -> + !recordRemoteTrust( + vault.bindRemoteAddress(addressClaim, fromDevice), + listOf(offeredIK)) + claimant <= 0L -> true + else -> trust.record( + claimant, listOf(offeredIK), trust.pinnedFnid(claimant) + ) == IdentityTrust.CHANGED + } + if (refused) { // Refusing costs this message; establishing a session // on a planted key costs the whole conversation. throw IdentityChangedException(claimant) @@ -720,29 +917,51 @@ class Conversations( // renegotiation — a device claiming a different user now is // lying, and its message files under the user it is bound to. val claimed = opened?.claimedFromUser ?: message.fromUser - val fromUser = vault.bindDeviceOwner(claimed, fromDevice) - val senderProven = fromUser == claimed + // 🔴 **A federated sender is resolved in the ADDRESS keyspace, + // never the local one.** Their `from_user` is a row number on + // their relay; binding a device to it here would either file + // the message under a local contact who happens to share the + // number, or — where the relay recorded no sender at all — + // under user 0. Both are silent, and both put somebody else's + // words in a thread with the wrong name on it. + val fromAddress = opened?.claimedFromAddress + ?.let { ContactCard.canonical(it) } + ?.let { vault.bindRemoteAddress(it, fromDevice) } + val fromUser = if (fromAddress != null) 0L + else vault.bindDeviceOwner(claimed, fromDevice) + // Control traffic needs a sender the ratchet tied to an + // identity we already hold. A remote address is bound to a + // device and pinned on first use, which is the same standard — + // so a proven remote sender may write control envelopes into + // its own thread and nowhere else. + val senderProven = if (fromAddress != null) true else fromUser == claimed val content = Content.parse(plaintext) // Control traffic (delivery key, avatar) only from a proven // sender: poisoning another user's delivery key forces our next // send to them UNSEALED, which hands the relay the sender that // sealing exists to hide. - if (senderProven) consumeControl(fromUser, content) + // ⚠️ Local control writes are keyed by user id, so they are + // skipped entirely for a federated sender: `consumeControl(0L, + // ...)` would poison user 0's delivery key and avatar, which is + // every unattributed message on this device. + if (senderProven && fromAddress == null) consumeControl(fromUser, content) // 🔴 An UNSEALED message from a contact we have told our // delivery key means their copy is gone — a reinstall wiped // it. Sealing to us requires that key, and a peer who holds // it seals everything. Un-mark them so the next send re-tells // it; until then the relay sees their sender on every message // they write us. Worst case: one redundant control envelope. - if (!sealed && fromUser != 0L) { + if (!sealed && fromUser != 0L && fromAddress == null) { vault.loadDelivery()?.let { d -> if (fromUser in d.toldPeers) { vault.saveDelivery(d.copy(toldPeers = d.toldPeers - fromUser)) } } } + if (fromAddress != null) vault.rememberRemoteAddress(fromAddress) out += Received(message.id, fromUser, fromDevice, content, - sealed = sealed, at = message.createdAt) + sealed = sealed, at = message.createdAt, + fromAddress = fromAddress) } catch (_: Exception) { // Deliberately swallowed and skipped. One undecryptable message // is not a reason to stop reading the inbox. diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 577cfc3..0e9e903 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -371,6 +371,110 @@ class RelayClient( null } + // -- Federation (§20.5) -------------------------------------------------- + // + // These three go to OUR relay, not to the peer's. A client never speaks to + // a relay it has not signed in to: ours signs the server-to-server request + // with its own federation key, so pointing an app at somebody else's relay + // cannot forge traffic between two servers. + + /** + * The devices a remote address currently has. + * + * ⚠️ The directory, not the prekey route — this consumes nothing. Calling + * the bundle route once per message is what drained a local prekey pool + * already; the same mistake costs more here, because the pool being emptied + * belongs to somebody on another relay who cannot see why. + */ + fun remoteDevices(fnid: String, relay: String): JSONArray = + getJson("/e2e/remote/$relay/devices/$fnid").optJSONArray("devices") ?: JSONArray() + + /** + * Prekey bundles for a remote address. + * + * 🔴 **Pops a one-time prekey on the far side**, exactly as the local + * `/e2e/keys` route does. Ask only when there is genuinely a session to + * open, never to find out who somebody is. + */ + fun remoteBundles(fnid: String, relay: String): JSONArray = + getJson("/e2e/remote/$relay/bundles/$fnid").optJSONArray("devices") ?: JSONArray() + + /** + * Hand envelopes to our relay for delivery to [toFnid] at [toRelay]. + * + * The reply says how many copies were taken, which addresses the far relay + * did not recognise, and which of that account's mailboxes could not be + * reached — all three matter, because with several homes a send is + * genuinely partial and a client that cannot tell cannot decide between + * retrying and warning. + */ + fun sendRemote(toFnid: String, toRelay: String, messages: List): JSONObject { + val array = JSONArray() + for (m in messages) { + array.put(JSONObject() + .put("to_device", m.toDevice) + .put("payload", m.payload) + .put("envelope_id", m.envelopeId)) + } + return postJson("/e2e/send/remote", JSONObject() + .put("to_fnid", toFnid) + .put("to_relay", toRelay) + .put("messages", array)) + } + + /** + * One copy of one message, for one of the recipient's devices. + * + * ⚠️ [envelopeId] is 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. + */ + data class RemoteMessage( + val toDevice: String, + val payload: String, + val envelopeId: String, + ) + + // -- Multi-homing (§20.4) ------------------------------------------------ + + /** + * `POST /e2e/account/homes` — say which relays hold mailboxes for this + * account, so a sender delivers to all of them and losing one loses + * nothing. + * + * ⚠️ **The relay being written to must be in [relays].** Otherwise an + * account could point every sender elsewhere while still reading an inbox + * here, and mail addressed by the record would never reach the box the + * person actually opens. The relay refuses that rather than storing it. + * + * [serial] must exceed whatever is already published. It is not a version + * for humans to read — it is the only thing that stops a relay replaying an + * older, genuinely signed list that happens to name only itself. + */ + fun publishHomes(serial: Int, relays: List, signatureB64: String): JSONObject = + postJson("/e2e/account/homes", JSONObject() + .put("serial", serial) + .put("relays", JSONArray(relays)) + .put("sig", signatureB64)) + + /** + * `GET /e2e/account/homes/{fnid}` — where to deliver, with the signature. + * + * Unauthenticated, and hands back the proof alongside the list precisely so + * the caller can check it instead of trusting this relay's copy. A relay + * serving a truncated list is the exact failure multi-homing exists to + * survive, so the answer has to be verifiable by whoever receives it — see + * [AccountIdentity.verifyHomes]. + * + * Null for an account that has published nothing, which is the ordinary + * case and not an error: it means one mailbox, at the relay you asked. + */ + fun homes(fnid: String): JSONObject? = try { + getJson("/e2e/account/homes/$fnid") + } catch (_: RelayException.NotFound) { + null + } + // -- Transparency log (§19) --------------------------------------------- // // Unauthenticated on the relay, and no proof headers are sent: the value of diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt index 53df7a7..feffe7b 100644 --- a/client/src/main/kotlin/io/flamenet/client/Trust.kt +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -106,11 +106,13 @@ class IdentityChangedException(val userId: Long) : * so inclusion is only meaningful against a head this device already * pinned and can demand a consistency proof for. * - * ⚠️ Deliberately not automatic. [requireKeyLog] is off by default for the - * same reason the web engine's is: a relay predating the log has none, and - * refusing every peer on a deployment that cannot answer is a worse failure - * than the one being prevented. Pinning is always on — it costs nothing and - * needs no cooperation from the relay. + * 🔴 **Automatic wherever the relay can answer.** This used to be behind + * [requireKeyLog], off by default — so the protection shipped and almost + * nobody had it. The argument for opt-in (a relay predating the log has none, + * and refusing every peer there is worse than the attack) was an argument for + * skipping relays that *cannot* answer, never for skipping the ones that can. + * [enforceKeyLogIfServed] draws exactly that line. Pinning is always on — it + * costs nothing and needs no cooperation from the relay. */ class Trust( private val identity: DeviceIdentity, @@ -118,7 +120,19 @@ class Trust( private val relay: RelayClient, ) { - /** Refuse a peer whose served key is not provably in the relay's log. */ + /** + * Refuse a peer even when the relay keeps **no log at all**. + * + * ⚠️ Not the switch that turns checking on — checking is automatic, see + * [enforceKeyLogIfServed]. This is the narrower question underneath it: + * what to do about a relay that publishes no log, where there is nothing to + * check a key against. + * + * Off by default because the failure it prevents is rarer than the one it + * causes. A relay predating §19 answers nothing, so turning this on there + * makes every contact unreachable, and somebody locked out of their + * messages moves to something with no ratchet at all. + */ var requireKeyLog: Boolean = false /** @@ -328,8 +342,59 @@ class Trust( * contact has always been**, not under whatever subject the relay would * like to file it against today. */ - fun enforceKeyLog(bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId)) { - val head = head() + /** + * §19 enforcement, applied wherever the relay can answer. + * + * ## Why the head is asked for separately + * + * The obvious shape — call [enforceKeyLog] and read a 404 as "no log here" + * — hands the relay the switch. [enforceKeyLog] reads the head, then the + * device's leaves, then an inclusion proof, so a relay wanting to skip the + * check need only 404 the *middle* one to make a substituted key look like + * a deployment without transparency. The "is there a log" question is + * therefore asked once, of the head route alone, and every later 404 means + * what it has always meant: this key is not in the log. + * + * ## Why a relay cannot stop answering + * + * The pin is in the vault and outlives a restart. A relay that served a + * head before and 404s now has not lost its log — logs do not disappear — + * so that is a fork, not an absence. Without this the downgrade is one + * line: serve the log until a key is pinned, then stop. + */ + fun enforceKeyLogIfServed( + bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId) + ) { + val head = try { + head().also { vault.rememberKeyLogServed() } + } 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()) { + throw KeyLogForkException("this relay served a log before and now serves none") + } + if (requireKeyLog) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) + return + } + if (forkDetected) { + throw KeyLogForkException("a fork was already detected on this relay") + } + enforceKeyLog(bundle, userId, fnid, head) + } + + /** + * [servedHead] is passed in rather than fetched again, because the caller + * has already had to ask for one to learn whether this relay keeps a log — + * and two reads could straddle an append, leaving an inclusion proof + * checked against a root this device never pinned. + */ + fun enforceKeyLog( + bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId), + servedHead: Head? = null, + ) { + val head = servedHead ?: head() val pin = vault.loadKeyLogPin() if (pin != null) { @@ -353,24 +418,47 @@ class Trust( // `head.audience` rather than the host we dialled: the leaf was written // by whichever relay signed this head, and the qualified subject embeds // that relay's own name. - val want = Wire.b64( - KeyLog.leafHash( - KeyLog.canonicalLeaf( - kind = "publish", - subject = KeyLog.logSubject(fnid, head.audience, userId), - deviceId = bundle.deviceId, - ikSigB64 = Wire.b64(bundle.identitySigning), - ikDhB64 = Wire.b64(bundle.identityDH), + // + // 🔴 **Both subject forms are accepted, and the reason is a migration + // rather than a weakness.** A leaf names its subject as `fnid:` once + // that account has claimed an address and `local:/` + // before — and the leaf hash committed to whichever string was true + // when it was written. A contact who registered a device and then + // claimed an address therefore has older leaves under one form and + // newer ones under the other, so a verifier computing a single subject + // would refuse half the network on the day claiming turned on. + // + // This concedes nothing to the relay: either candidate still requires + // it to have appended a leaf binding the exact key it just served, in a + // tree it has already committed to and must prove inclusion against. + // What it gives up is the stronger property of checking the key is + // logged under *that person*, which needs the address pinned in the + // contact record. + val wants = setOf( + KeyLog.logSubject(fnid, head.audience, userId), + KeyLog.logSubject(null, head.audience, userId), + ).map { subject -> + Wire.b64( + KeyLog.leafHash( + KeyLog.canonicalLeaf( + kind = "publish", + subject = subject, + deviceId = bundle.deviceId, + ikSigB64 = Wire.b64(bundle.identitySigning), + ikDhB64 = Wire.b64(bundle.identityDH), + ) ) ) - ) + } val entries = relay.logDeviceEntries(bundle.deviceId).optJSONArray("entries") ?: throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) var index = -1 + var want = "" for (i in 0 until entries.length()) { - if (entries.getJSONObject(i).optString("leaf") == want) { - index = entries.getJSONObject(i).getInt("index"); break + val leaf = entries.getJSONObject(i).optString("leaf") + if (leaf in wants) { + index = entries.getJSONObject(i).getInt("index"); want = leaf; break } } if (index < 0) { @@ -466,7 +554,12 @@ class Trust( // -- Reading the relay's log --------------------------------------------- - private data class Head(val size: Int, val root: String, val audience: String) + /** + * A verified log head. Public because [enforceKeyLog] takes one that its + * caller has already fetched — the head is part of that contract rather + * than an internal detail. + */ + data class Head(val size: Int, val root: String, val audience: String) private fun head(): Head = relay.logHead().let { val head = Head(it.getInt("size"), it.getString("root"), it.optString("audience")) diff --git a/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt b/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt new file mode 100644 index 0000000..8e8cdaf --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt @@ -0,0 +1,192 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +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 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.assertTrue + +/** + * §19 enforcement with no setting to find first. + * + * 🔴 **This used to be opt-in and off, so the protection shipped and almost + * nobody had it.** The argument for a switch — a relay predating the log has + * none, and refusing every peer there is worse than the attack — was an + * argument for skipping relays that *cannot* answer, never for skipping the + * ones that can. These tests pin that distinction, because it is the whole + * feature: the relay decides which case it is in, and it must not be able to + * choose the lenient one after having already chosen the strict one. + */ +class AutomaticKeyLogTest { + + 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 identity: DeviceIdentity + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + vault = Vault(MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + identity = DeviceIdentity.create() + } + + @AfterTest fun stop() = server.shutdown() + + private fun trust() = Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b")) + + private fun json(body: String) = MockResponse() + .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + /** A peer whose keys are real, so nothing here passes on a malformed bundle. */ + private fun peer(): PeerBundle { + val device = DeviceIdentity.create() + return PeerBundle.fromWire(device.registrationWire()) + } + + /** + * A relay's log routes, answered by path. + * + * [head] null means the route is absent — a deployment from before §19. + * ⚠️ An unsigned head on purpose: `verifyHeadSignature` is silent when the + * field is empty, which is the documented behaviour for a relay that + * publishes no issuer key, and it keeps these tests about the presence + * question rather than about signing. + */ + private fun relayServing(head: String?, entries: String = """{"entries":[]}""") { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "/e2e/log/head" in path -> + head?.let(::json) ?: MockResponse().setResponseCode(404) + "/e2e/log/device/" in path -> json(entries) + else -> MockResponse().setResponseCode(404) + } + } + } + } + + private val aHead = """{"size":4,"root":"${Wire.b64(ByteArray(32) { 3 })}",""" + + """"audience":"relay.example.com"}""" + + // MARK: - A relay that keeps no log + + /** + * The case the old switch existed for. A relay from before §19 answers + * nothing, and refusing every contact on it would push people to something + * with no ratchet at all — a far worse outcome than the substitution being + * guarded against. + */ + @Test + fun `a relay with no log at all does not block the send`() { + relayServing(head = null) + trust().enforceKeyLogIfServed(peer(), userId = 7) + } + + /** Somebody who knows their relay publishes one can still close the gap. */ + @Test + fun `strict mode refuses a relay with no log`() { + relayServing(head = null) + val trust = trust().also { it.requireKeyLog = true } + assertFailsWith { trust.enforceKeyLogIfServed(peer(), userId = 7) } + } + + // MARK: - A relay that keeps one + + /** + * 🔴 **The behaviour change.** Before this, a relay serving a log and a key + * that is not in it was accepted without a murmur unless the user had found + * a switch in Privacy settings. Now the absent leaf is the answer. + */ + @Test + fun `a key that is not in the log is refused, with no setting turned on`() { + relayServing(head = aHead) + val trust = trust() + assertEquals(false, trust.requireKeyLog, "this must hold with the switch OFF") + assertFailsWith { trust.enforceKeyLogIfServed(peer(), userId = 7) } + } + + /** + * ⚠️ Entries that exist but do not match are still a refusal, and the flag + * says which case it was — "we could not compute your subject" and "this + * key is not published" call for different things being said to the user. + */ + @Test + fun `entries that match nothing are refused and say so`() { + relayServing(head = aHead, + entries = """{"entries":[{"index":0,"leaf":"${Wire.b64(ByteArray(32))}"}]}""") + val failure = assertFailsWith { + trust().enforceKeyLogIfServed(peer(), userId = 7) + } + assertTrue(failure.hadEntries, "the device is in the log, this key is not") + } + + // MARK: - The downgrade + + /** + * 🔴 **The attack automatic enforcement would otherwise invite.** Skipping + * relays that keep no log means a relay can opt out of being checked — so + * publish a log until clients are talking, then stop answering, and every + * client silently goes back to trusting whatever key it is handed. Logs do + * not disappear; a relay that answered before and 404s now is not a + * deployment without transparency, it is one that changed its mind. + */ + @Test + fun `a relay that served a log and then stops is a fork, not an absence`() { + relayServing(head = aHead) + val trust = trust() + // 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()) + + relayServing(head = null) + assertFailsWith { + trust().enforceKeyLogIfServed(peer(), userId = 7) + } + } + + /** + * ⚠️ **A bad day is not an absence.** A timeout, a 500 or a 429 must not + * read as "this relay keeps no log", or enforcement becomes skippable by + * anybody able to make one request fail — including the relay itself, which + * would only have to be unreliable rather than dishonest. + */ + @Test + fun `a server error is not treated as a relay without a log`() { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(500) + } + assertFailsWith { trust().enforceKeyLogIfServed(peer(), userId = 7) } + } + + /** + * 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 + * start demanding a route the relay may never have had, and lock the user + * out of a deployment that was never dishonest. + */ + @Test + fun `a pin arriving from gossip does not make a logless relay look forked`() { + vault.saveKeyLogPin(Vault.KeyLogPin(9, Wire.b64(ByteArray(32) { 5 }))) + relayServing(head = null) + trust().enforceKeyLogIfServed(peer(), userId = 7) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/FederatedReceiveTest.kt b/client/src/test/kotlin/io/flamenet/client/FederatedReceiveTest.kt new file mode 100644 index 0000000..b1b17be --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/FederatedReceiveTest.kt @@ -0,0 +1,253 @@ +package io.flamenet.client + +import io.flamenet.e2e.ContactCard +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.E2ESession +import io.flamenet.e2e.PeerBundle +import io.flamenet.e2e.SealedSender +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +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.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Messages that crossed a relay boundary. + * + * 🔴 **The relay records no sender for one of these**, deliberately: it was not + * told who wrote it and must not invent an attribution. So `from_user` arrives + * as 0 and the only thing naming the sender is the address sealed inside the + * blob. Every assertion here is about what happens to a message with no + * trustworthy sender on the transport — because the failure mode is not a + * crash, it is every federated message from everybody silently piling into one + * nameless thread, or worse, into a local contact's. + * + * Real ciphertext throughout: a real peer identity, a real ratchet, a real + * seal. Only the relay's HTTP is a mock. + */ +class FederatedReceiveTest { + + 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 + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + vault = Vault(MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + me = DeviceIdentity.create() + } + + @AfterTest fun stop() = server.shutdown() + + private fun conversations() = + Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b")) + + private fun json(body: String) = MockResponse() + .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + 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 envelope as a federated deposit looks on our inbox: no sender at all. + * + * ⚠️ `from_user: 0` and an empty `from_device` are not laziness in the + * fixture — they are exactly what `federationDeposit` writes, and a test + * that filled them in would be testing the local path with extra steps. + */ + private fun deposited(id: Long, payload: String) = + json("""{"messages":[{"id":$id,"from_user":0,"from_device":"","payload":"$payload"}]}""") + + /** A peer on another relay, writing to us for the first time. */ + private fun remotePeer(address: String, text: String): Pair { + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + val blob = SealedSender.seal( + envelope = session.encrypt(text), + fromUser = 12, // their row number, meaningless here + fromDevice = peer.deviceId, + toDevice = me.deviceId, + recipientIKdh = me.identityDHPublic, + fromAddress = address, + ) + return peer to blob.toPayload() + } + + private val theirAddress = + "fnid:0123456789abcdefghjkmnpqrstvwxyz0123456789abcdefghjk@peer.example.com" + + // MARK: - Attribution + + @Test + fun `a federated message is attributed to its address, not to a user id`() { + val (peer, payload) = remotePeer(theirAddress, "hello from over there") + server.enqueue(deposited(1, payload)) + + val received = conversations().poll().single() + + assertEquals("hello from over there", received.text) + assertEquals(theirAddress, received.fromAddress) + assertEquals(peer.deviceId, received.fromDevice) + // 🔴 The point of the whole test. Their `from_user` of 12 is a row + // number on their relay; adopting it here would file this under + // whoever user 12 happens to be on ours. + assertEquals(0L, received.fromUser) + } + + /** + * 🔴 The claim inside a seal is only a claim: anyone holding this device's + * published `IK_dh` can write any address they like there. The ratchet + * proves the DEVICE, so the two are bound first-use-wins — and a device + * that later claims somebody else's address keeps the one it was seen with. + * + * Without this, an established thread can be renamed out from under the + * person reading it, which is the whole value of a stable address undone. + */ + @Test + fun `a device that changes its claimed address keeps the first one`() { + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + + fun sealed(address: String, text: String) = SealedSender.seal( + envelope = session.encrypt(text), + fromUser = 12, fromDevice = peer.deviceId, + toDevice = me.deviceId, recipientIKdh = me.identityDHPublic, + fromAddress = address, + ).toPayload() + + val conversations = conversations() + server.enqueue(deposited(1, sealed(theirAddress, "first"))) + assertEquals(theirAddress, conversations.poll().single().fromAddress) + + val impostor = "fnid:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz@peer.example.com" + server.enqueue(deposited(2, sealed(impostor, "second"))) + val second = conversations.poll().single() + + assertEquals("second", second.text, "the message is genuine and still delivered") + assertEquals(theirAddress, second.fromAddress, + "a device keeps the address it was first bound to") + } + + /** An ordinary local message is untouched by any of this. */ + @Test + fun `a local message still has no address and keeps its user id`() { + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + server.enqueue(json("""{"messages":[{"id":1,"from_user":7,""" + + """"from_device":"${peer.deviceId}","payload":"${ + session.encrypt("ordinary").toPayload()}"}]}""")) + + val received = conversations().poll().single() + + assertNull(received.fromAddress) + assertEquals(7L, received.fromUser) + } + + // MARK: - The address list + + @Test + fun `an address that has written to us becomes reachable again`() { + val (_, payload) = remotePeer(theirAddress, "hello") + server.enqueue(deposited(1, payload)) + val conversations = conversations() + + assertTrue(conversations.remoteAddresses().isEmpty(), "nothing before the first message") + conversations.poll() + + // 🔴 Without this the conversation is write-only: the sender is in no + // roster and never can be, so with no row anywhere the only way back + // into the thread is retyping 52 characters of base32. + assertEquals(listOf(theirAddress), conversations.remoteAddresses()) + } + + /** Two people on other relays are two threads, not one. */ + @Test + fun `two remote senders do not share a thread`() { + val other = "fnid:11111111111111111111111111111111111111111111111111@third.example.com" + val (_, first) = remotePeer(theirAddress, "from the first") + val (_, second) = remotePeer(other, "from the second") + val conversations = conversations() + + server.enqueue(deposited(1, first)) + server.enqueue(deposited(2, second)) + val a = conversations.poll().single() + val b = conversations.poll().single() + + assertNotEquals(a.fromAddress, b.fromAddress) + assertEquals(setOf(theirAddress, other), conversations.remoteAddresses().toSet()) + } + + // MARK: - What an address is allowed to be + + /** + * ⚠️ A relay is a hostname, never a URL. An address is parsed from a seal + * written by somebody nobody authenticated, so accepting `https://x/y` here + * would let a stranger point this client at a path on a host of their + * choosing — and would do it from inside a message. + */ + @Test + fun `an address carrying a URL is not an address`() { + assertNull(ContactCard.decode("fnid:abc@https://evil.example.com/steal")) + assertNull(ContactCard.decode("fnid:abc@evil.example.com:8443")) + assertNull(ContactCard.decode("fnid:abc@evil.example.com/path")) + assertNull(ContactCard.decode("fnid:abc@localhost"), "a relay is a hostname with a dot") + assertNull(ContactCard.decode("no-at-sign.example.com")) + assertNull(ContactCard.decode("fnid:abc@a@b.example.com")) + } + + /** + * Two spellings of one address would open two threads for one person, so + * everything that files a message goes through the same normaliser. + */ + @Test + fun `an address is canonicalised the same way wherever it comes from`() { + val typed = ContactCard.canonical(" FLAMENET:fnid:abc@Peer.Example.COM ") + assertEquals("fnid:abc@peer.example.com", typed) + assertEquals(typed, ContactCard.canonical("fnid:abc@peer.example.com")) + } + + /** + * A federated message cannot write control state keyed by user id. + * + * 🔴 `consumeControl(0L, ...)` would poison user 0's delivery key and + * avatar — and user 0 is the id every unattributed message carries, so one + * stranger on another relay could break sealing for everybody. + */ + @Test + fun `a remote sender cannot set a local delivery key`() { + val peer = DeviceIdentity.create() + val session = E2ESession.initiate(peer, myBundle()) + val control = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 9 }) + val blob = SealedSender.seal( + envelope = session.encrypt(control), + fromUser = 0, fromDevice = peer.deviceId, + toDevice = me.deviceId, recipientIKdh = me.identityDHPublic, + fromAddress = theirAddress, + ) + server.enqueue(deposited(1, blob.toPayload())) + + conversations().poll() + + assertNull(vault.loadDelivery()?.peers?.get(0L), + "a federated sender must not write into the local keyspace") + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt b/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt index fe10766..323fc4c 100644 --- a/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt @@ -66,6 +66,32 @@ class GossipHeadSentTest { .setHeader("Content-Type", "application/json") .setBody(body) + /** + * A relay that keeps no transparency log. + * + * ⚠️ Not incidental setup. Sending now asks for a head on its own, because + * key-log checking is automatic on any relay that serves one — so a queue + * of bodies matched purely by ORDER would hand the head request the device + * listing and fail on a parse error miles from the cause. Answering by path + * says what this fixture is: a relay from before §19, where gossip is the + * only part of transparency in play. + */ + private fun noKeyLog() { + server.dispatcher = object : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + // `contains`, not `startsWith`: every route is under the + // REST mount, so the recorded path begins with that prefix + // rather than with `/e2e`. + "/e2e/log/" in path -> MockResponse().setResponseCode(404) + path.contains("/e2e/messages") -> json("""{"ok":true}""") + else -> json("""{"devices":[${bobWire()}]}""") + } + } + } + } + /** Alice, with a head already pinned — nothing is gossiped without one. */ private fun alice(pinned: Boolean): Conversations { val identity = DeviceIdentity.create() @@ -77,12 +103,7 @@ class GossipHeadSentTest { return Conversations(identity, vault, relay) } - /** The device lookup, then the key fetch, then the accepted send. */ - private fun enqueueSendExchange() { - server.enqueue(json("""{"devices":[${bobWire()}]}""")) - server.enqueue(json("""{"devices":[${bobWire()}]}""")) - server.enqueue(json("""{"ok":true}""")) - } + /** Every payload POSTed to `/e2e/messages`, as the relay would see them. */ private fun postedPayloads(): List { @@ -102,7 +123,7 @@ class GossipHeadSentTest { @Test fun `a send carries our log head to the contact`() { - enqueueSendExchange() + noKeyLog() val sent = alice(pinned = true).send(toUserId = 42, text = "an ordinary message") assertTrue(sent.failed.isEmpty(), "the send itself should succeed: ${sent.failed}") @@ -121,7 +142,7 @@ class GossipHeadSentTest { */ @Test fun `the head is encrypted, never posted in the clear`() { - enqueueSendExchange() + noKeyLog() alice(pinned = true).send(toUserId = 42, text = "an ordinary message") val payloads = postedPayloads() @@ -140,8 +161,7 @@ class GossipHeadSentTest { /** Once per contact per session — not attached to every message. */ @Test fun `the head is not repeated on later messages`() { - enqueueSendExchange() - enqueueSendExchange() + noKeyLog() val conversations = alice(pinned = true) conversations.send(toUserId = 42, text = "first") conversations.send(toUserId = 42, text = "second") @@ -153,7 +173,7 @@ class GossipHeadSentTest { /** Nothing to gossip before a head has ever been verified and pinned. */ @Test fun `a client with no pinned head sends only the message`() { - enqueueSendExchange() + noKeyLog() alice(pinned = false).send(toUserId = 42, text = "an ordinary message") assertEquals(1, postedPayloads().size, diff --git a/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt b/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt new file mode 100644 index 0000000..b13652e --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt @@ -0,0 +1,106 @@ +package io.flamenet.e2e + +/** + * Everything somebody needs to reach you on another relay. + * + * `flamenet:@` — an account address and the relay that homes it. + * + * ⚠️ **The delivery key is deliberately not part of this**, and the iOS version + * of this file carried one on a wrong premise worth recording. Three things + * were conflated: sealing an envelope uses the recipient DEVICE's `ik_dh`, + * which the remote directory returns; the delivery key is a separate + * anti-abuse capability the LOCAL relay checks before accepting unattributed + * mail; and the federated send carries no delivery key at all. Putting it in + * the address published a secret-shaped thing for nothing and doubled the + * length of every card. + * + * 🔴 What is true, and drives the send path: a relay that accepts a federated + * envelope stores it with **no sender** — it was not told who sent it and must + * not invent an attribution — so a message crossing a boundary must be sealed + * or it arrives unattributable and is dropped. Sealing is optional locally and + * mandatory here. + */ +data class ContactCard( + /** The account address, derived from the passphrase. */ + val fnid: String, + /** + * The relay that homes it. Not authoritative — homes can change — but it is + * where to look first. + */ + val relay: String, + /** + * ⚠️ Kept only so an older card still parses. Nothing reads it and nothing + * should start to: see the note above. + */ + val deliveryKey: String? = null, +) { + + /** + * `fnid@relay` — the address alone, no scheme and no query. + * + * 🔴 **The key every remote conversation is filed under**, on both sides: + * what a federated envelope seals as its return address, and what an + * inbound one is threaded by. [encoded] is for a human to scan or paste and + * may carry more; this is the identity and has to stay byte-stable, so it + * is built from the already-normalised fields rather than from input text. + */ + val address: String get() = "$fnid@$relay" + + /** + * `flamenet:@` — a URL scheme rather than bare text, so a QR + * reader hands it straight to the app and a card pasted into a chat is + * obviously an address rather than a word somebody typed. + */ + val encoded: String get() = "flamenet:$address" + + companion object { + + /** + * The stable form of an address string, or null if it is not one. + * + * ⚠️ Goes through [decode] rather than lowercasing in place, because + * the same refusals — no `/`, no `:`, a relay shaped like a hostname — + * have to apply to an address arriving inside a sealed envelope, which + * came from a peer nobody authenticated. Two spellings of one address + * would otherwise open two threads for one person. + */ + fun canonical(address: String): String? = decode(address)?.address + + /** + * Parse a card, from a QR scan or a paste. + * + * ⚠️ Tolerant of a bare `fnid@relay` with no scheme, because that is + * what somebody retypes off a screen. Everything else is refused rather + * than guessed at — an address that is nearly right addresses somebody + * else. + */ + fun decode(text: String): ContactCard? { + var s = text.trim() + if (s.lowercase().startsWith("flamenet:")) s = s.substring("flamenet:".length) + + var key: String? = null + val q = s.indexOf('?') + if (q >= 0) { + val query = s.substring(q + 1) + s = s.substring(0, q) + for (pair in query.split("&")) { + if (pair.startsWith("k=")) key = pair.substring(2).ifEmpty { null } + } + } + + val at = s.indexOf('@') + if (at < 0) return null + val fnid = s.substring(0, at).trim() + val relay = s.substring(at + 1).trim().lowercase() + if (fnid.isEmpty() || relay.isEmpty() || '.' !in relay) return null + // A second `@` means this is not one address. Refused rather than + // split on the first, which would silently address the wrong host. + if ('@' in relay) return null + // ⚠️ A relay is a hostname, never a URL. Accepting "https://x/y" + // here would let a card point the client at a path on some other + // host of the card writer's choosing. + if ('/' in relay || ':' in relay) return null + return ContactCard(fnid, relay, key) + } + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt b/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt index d6abbcd..e2bdcae 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt @@ -62,4 +62,43 @@ object FNID { */ fun claimCanonical(audience: String, userId: Long, accountKeyB64: String): ByteArray = "fnacct-v1|$audience|$userId|$accountKeyB64".toByteArray(Charsets.UTF_8) + + /** + * The 32 raw bytes behind an `fnid:` address, or null if it is not one. + * + * The address *is* the public key, which is what lets a sender check a + * home record without asking anybody whether the record is true. Decoding + * is therefore not a convenience — it is the whole verification path. + */ + fun decode(address: String): ByteArray? { + val body = address.removePrefix("fnid:") + if (body.length != ENCODED_LENGTH || body == address) return null + var buffer = 0 + var bits = 0 + val out = ByteArray(32) + var written = 0 + for (c in body) { + val v = ALPHABET.indexOf(c) + if (v < 0) return null + buffer = (buffer shl 5) or v + bits += 5 + if (bits >= 8) { + bits -= 8 + if (written == out.size) return null + out[written++] = ((buffer shr bits) and 0xff).toByte() + } + } + return if (written == out.size) out else null + } + + /** + * What a client signs to say which relays hold its mailboxes. + * + * 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. + */ + fun homesCanonical(fnid: String, serial: Int, relays: List): ByteArray = + "fnhomes-v1|$fnid|$serial|${relays.joinToString(",")}".toByteArray(Charsets.UTF_8) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt index 5761681..fc2c475 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt @@ -61,6 +61,21 @@ object SealedSender { data class Opened( val claimedFromUser: Long, val claimedFromDevice: String, + /** + * The sender's `fnid@relay`, when the message crossed a relay boundary. + * + * 🔴 **Without this a federated message is unanswerable.** The + * receiving relay records no sender for an envelope a peer deposited, + * and the sender's `from_user` is a row number on a machine we have + * never heard of — so the text decrypts, appears, and has nowhere to + * reply to. Null for an ordinary local message, where the relay's own + * routing already says who wrote it. + * + * `claimed`, like the two fields above it, and for the same reason: it + * came out of the seal, so it is what the sender asserted, not what + * anybody verified. + */ + val claimedFromAddress: String? = null, val envelope: Envelope, ) @@ -91,6 +106,7 @@ object SealedSender { fromDevice: String, toDevice: String, recipientIKdh: ByteArray, + fromAddress: String? = null, ): Blob { val eph = X25519.generate() val ephPub = X25519.rawFromPublicKey(eph.public) @@ -99,6 +115,7 @@ object SealedSender { val inner = JSONObject() .put("from_user", fromUser) .put("from_device", fromDevice) + .also { if (fromAddress != null) it.put("from_addr", fromAddress) } .put("envelope", envelope.toJson()) .toString() .toByteArray(Charsets.UTF_8) @@ -123,6 +140,12 @@ object SealedSender { return Opened( claimedFromUser = inner.optLong("from_user"), claimedFromDevice = inner.optString("from_device"), + // ⚠️ Omitted entirely for a local seal rather than written as null, + // matching iOS — a build that predates the field must ignore one + // that carries it, and one that has it must accept a blob without. + // The seal version deliberately stays at 1: nothing about how the + // blob opens changed. + claimedFromAddress = inner.optString("from_addr").ifEmpty { null }, envelope = Envelope.fromJson(inner.getJSONObject("envelope")), ) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 6223c50..c0c1110 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -55,6 +55,20 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val HISTORY_PREFIX = "history/" private const val TRUST_PREFIX = "trust/" private const val KEYLOG_PIN = "keylog-pin" + private const val REMOTE_ADDRESS_PREFIX = "remote-addr-" + private const val REMOTE_PIN_PREFIX = "remote-pin-" + private const val REMOTE_ADDRESSES = "remote-addresses" + + /** + * A ceiling on remembered addresses. + * + * ⚠️ The key is chosen by whoever writes to us, so an unbounded list is + * storage anybody on the federation can grow. Oldest goes first, which + * costs at most a thread heading — the messages themselves are keyed by + * the binding, not by this list. + */ + private const val MAX_REMOTE_ADDRESSES = 500 + private const val KEYLOG_SERVED = "keylog-served" private const val DELIVERY_KEY = "delivery-key" private const val STORIES = "stories" private const val AVATARS = "avatars" @@ -75,6 +89,17 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun threadForUser(userId: Long) = "user:$userId" fun threadForGroup(groupId: String) = "group:$groupId" + /** + * A conversation with somebody on another relay. + * + * ⚠️ A THIRD prefix rather than reusing `user:`. A remote peer's user + * id belongs to their relay, so folding them into the local keyspace + * would collide two unrelated people the moment two relays happened to + * assign the same row number — silently, into a thread already showing + * somebody else's name. + */ + fun threadForAddress(address: String) = "addr:$address" + private val random = SecureRandom() } @@ -501,6 +526,27 @@ class Vault(private val storage: Storage, private val key: SecretKey) { .put("size", pin.size) .put("root", pin.root)) + /** + * Whether this relay has ever answered the head route. + * + * 🔴 **This is what stops a relay switching transparency off.** Key-log + * checking is automatic wherever a relay serves a log, which means a relay + * that serves none is skipped — so without a memory, the way out is one + * line of configuration: publish a log until clients are talking, then stop + * answering, and every client silently goes back to trusting whatever key + * it is handed. + * + * Recorded separately from [loadKeyLogPin] rather than inferred from it, + * because the two facts are different. A pin can arrive from anywhere — the + * gossip path writes one from a head a *contact* sent — while this says + * only that this device asked this relay for a head and got one. Inferring + * 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 + + fun rememberKeyLogServed() = writeJson(KEYLOG_SERVED, JSONObject().put("seen", true)) + // -- Inbox watermark ----------------------------------------------------- /** @@ -752,6 +798,71 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** Forget a binding — used when an identity change is accepted. */ fun clearDeviceOwner(deviceId: String) = storage.delete(DEVICE_OWNER_PREFIX + deviceId) + // -- The address keyspace (§20.5) ---------------------------------------- + // + // A federated peer has no user id here. Their relay assigned them one and + // it means nothing on ours — user 12 there and user 12 here are unrelated + // people — so everything below is keyed by `fnid@relay` instead. Kept apart + // from the local keyspace on purpose: collapsing the two would let a claim + // about an address decide who a LOCAL user id belongs to, which is the one + // crossing a federation boundary must never be able to make. + + /** + * Bind a device to the address it first claimed, first use wins. + * + * ⚠️ Call only once the inner envelope has decrypted. The address is a + * claim from inside the seal and anyone holding this device's published + * `IK_dh` can write what they like there; what the ratchet proves is the + * DEVICE. Binding the two means a device that later claims a different + * address keeps the one it was first seen with, so an established thread + * cannot be renamed out from under the person reading it. + * + * Returns the address in force, which may not be the one claimed. + */ + fun bindRemoteAddress(address: String, deviceId: String): String { + remoteAddressFor(deviceId)?.let { return it } + if (address.isEmpty()) return address + writeJson(REMOTE_ADDRESS_PREFIX + deviceId, JSONObject().put("addr", address)) + return address + } + + fun remoteAddressFor(deviceId: String): String? = + readJson(REMOTE_ADDRESS_PREFIX + deviceId)?.optString("addr")?.ifEmpty { null } + + /** + * The identity keys pinned for an address, trust on first use. + * + * The same rule as the local pin and in the other keyspace: every key + * offered must already be known, because a key never seen before is either + * a new device of theirs or one their relay minted, and nothing on the wire + * distinguishes those. + */ + fun pinnedAddressIdentities(address: String): List { + val stored = readJson(REMOTE_PIN_PREFIX + address) ?: return emptyList() + val keys = stored.optJSONArray("keys") ?: return emptyList() + return (0 until keys.length()).mapNotNull { + runCatching { Bytes.unb64(keys.getString(it)) }.getOrNull() + } + } + + fun pinAddressIdentities(address: String, keys: List) { + val array = org.json.JSONArray() + keys.forEach { array.put(Bytes.b64(it)) } + writeJson(REMOTE_PIN_PREFIX + address, JSONObject().put("keys", array)) + } + + /** Addresses this device has exchanged messages with, most recent last. */ + fun remoteAddresses(): List = + readJson(REMOTE_ADDRESSES)?.optJSONArray("addrs")?.let { a -> + (0 until a.length()).map { a.getString(it) } + } ?: emptyList() + + fun rememberRemoteAddress(address: String) { + val current = remoteAddresses().filter { it != address } + address + writeJson(REMOTE_ADDRESSES, JSONObject().put( + "addrs", org.json.JSONArray(current.takeLast(MAX_REMOTE_ADDRESSES)))) + } + // -- Contact list (P1) --------------------------------------------------- /**