From 72314491fa7ed239553a8fe3229a1542ce1b9736 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 30 Aug 2026 16:06:11 -0400 Subject: [PATCH] Mailboxes at more than one relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your address already survives a relay disappearing — it comes from your passphrase, so signing in elsewhere produces the same one. Your mailbox does not. Mail sitting at a relay that goes away is gone with it, and a contact holding only that relay's name has nowhere to write. The relay has fanned out to a signed home record for a while and no client could publish one, so the feature existed on exactly one side of the wire. This is the other side, and both halves of it, because publishing alone would not have worked: a copy sitting in a mailbox nothing reads is storage, not redundancy. So the poll now reads every home, with a cursor per relay — envelope ids come from each relay's own database, and one shared cursor would let the first home's progress silently skip the second home's mail. Copies are collapsed by the id the sender stamps once per message. Without that the second copy reaches a ratchet that has already deleted its message key, so the symptom would not be a duplicate — it would be a dropped envelope and a message that looks lost. Adding a mailbox is several requests because homing somewhere means having an account there: sign in, register this device so there is somewhere to deliver, claim the same address so a sender can resolve it, then publish the record at EVERY home rather than only the new one. 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. The new relay accepting is what decides the whole operation: a record every old home took but the new one refused describes a mailbox that does not exist. Removal publishes the shorter list first and forgets the credential only if that worked. The reverse order leaves mail arriving at a mailbox this device no longer reads, which from the sender's side is indistinguishable from delivery. The relay being dropped is not asked to accept the new record — it is the one party with a reason to refuse. No one-time prekeys are published at a second home. They are only usable if this device kept the private halves, and publishing keys it would not offer back makes every session opened with one undecryptable, which is worse than offering none. The passphrase is asked for on the screen that needs it and cleared the moment the attempt ends. The account key seed lives in memory for the run and is never written down: it signs the mailbox record, which is the one thing a relay must never be able to do on the account's behalf. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/ChatScreens.kt | 123 +++++++++++ .../messenger/app/MessengerViewModel.kt | 114 ++++++++++ .../io/flamenet/client/Conversations.kt | 66 +++++- .../io/flamenet/client/MailboxHoming.kt | 198 ++++++++++++++++++ .../kotlin/io/flamenet/client/RelayClient.kt | 11 + .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 79 +++++++ 6 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 client/src/main/kotlin/io/flamenet/client/MailboxHoming.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 39fcce1..36019f7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -759,6 +759,9 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) } + SectionBand() + SettingsSection { MailboxesSection(model, phase) } + SectionBand() SettingsSection { // ⚠️ The row is the label; the field must not repeat it. "Display @@ -3225,3 +3228,123 @@ private fun ColumnScope.ConversationStatusLine( } } } + +/** + * The relays that hold mail for this account (§20.4). + * + * 🔴 **Your address survives a relay disappearing; your mailbox does not.** + * Mail sitting at a relay that goes away is gone with it, and a contact holding + * only that relay's name has nowhere to write. A second mailbox fixes both — + * and only if it is added first, because afterwards there is nothing left to + * publish a new record from and no way to say where you went. + * + * So the copy here does not say "backup" and stop. It says what is protected, + * what is not, and why it has to be done in advance. + */ +@Composable +fun ColumnScope.MailboxesSection( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { + var adding by remember { mutableStateOf(false) } + var host by remember { mutableStateOf("") } + var passphrase by remember { mutableStateOf("") } + + LaunchedEffect(phase.fnid) { if (phase.fnid != null) model.loadMailboxes() } + + SettingsRow( + label = "Mailboxes", + description = "Senders deliver a copy to every relay listed here, so losing one " + + "does not lose the message. Add one before you need it: if a relay " + + "disappears while it is your only mailbox your address still works, but " + + "nobody can find out where you moved to without you telling them.", + ) {} + + if (phase.mailboxes.isEmpty()) { + Text( + "Mail for you is held at ${phase.relay} only.", + fontSize = 12.sp, + color = Palette.SecondaryText, + modifier = Modifier.testTag("mailboxes-empty"), + ) + } else { + phase.mailboxes.forEach { relay -> + Row( + Modifier.fillMaxWidth().padding(vertical = Space.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + relay, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f).testTag("mailbox-$relay"), + ) + when { + relay == phase.relay -> + Text("this device", fontSize = 11.sp, color = Palette.SecondaryText) + // ⚠️ A mailbox in the published record that this phone holds + // no credential for. Mail arrives there and this device + // cannot read it — worth saying rather than drawing as an + // ordinary row. Another of your devices added it, or this + // one was reinstalled. + relay !in phase.readableMailboxes -> + Text("not readable here", fontSize = 11.sp, + color = MaterialTheme.colorScheme.error) + else -> KitChip( + text = "Remove", selected = false, + onClick = { model.removeMailbox(relay) }, + tag = "remove-mailbox-$relay") + } + } + } + } + + Spacer(Modifier.height(Space.sm)) + if (!adding) { + KitChip(text = "Add a mailbox", selected = false, + onClick = { adding = true }, tag = "add-mailbox") + return + } + + OutlinedTextField( + value = host, + onValueChange = { host = it }, + placeholder = { Text("relay.example.com", fontSize = 14.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("new-mailbox-host"), + ) + Spacer(Modifier.height(Space.xs)) + OutlinedTextField( + value = passphrase, + onValueChange = { passphrase = it }, + placeholder = { Text("Your passphrase", fontSize = 14.sp) }, + singleLine = true, + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().testTag("new-mailbox-passphrase"), + ) + Spacer(Modifier.height(Space.xs)) + Text( + "You need an account on that relay already. Sign up there with the same " + + "passphrase first — that is what gives you the same address, and it is " + + "why the passphrase is asked for here.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.xs)) + KitPrimaryButton( + text = "Add", + onClick = { + model.addMailbox(host, passphrase) + // 🔴 The passphrase does not outlive the attempt. It was collected + // for one sign-in at one relay; leaving it in a composable's state + // keeps a copy of the account's secret on screen for as long as the + // section is open. + passphrase = "" + host = "" + adding = false + }, + enabled = host.isNotBlank() && passphrase.isNotEmpty() && !phase.busy, + modifier = Modifier.fillMaxWidth(), + tag = "confirm-add-mailbox", + ) +} 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 9dc4fe7..fae0eff 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -109,6 +109,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { */ val remoteAddresses: List = emptyList(), + /** + * The relays that hold mail for this account (§20.4). + * + * 🔴 **Your address survives a relay disappearing; your mailbox + * does not.** Mail sitting at a relay that goes away is gone, and a + * contact holding only that relay's name has nowhere to write. A + * second home fixes both — and only if it was added first, because + * afterwards there is nothing left to publish a new record from. + * + * Empty means one mailbox, at the relay signed in to, which is the + * ordinary state and not a failure. + */ + val mailboxes: List = emptyList(), + /** Mailboxes this device holds a credential for and can actually read. */ + val readableMailboxes: Set = emptySet(), + /** Same question for the group list. See [buddiesLoaded]. */ val groupsLoaded: Boolean = false, val groups: List = emptyList(), @@ -533,6 +549,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // 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. + // 🔴 Held for this run only: it is what signs a mailbox record, and a + // relay must never be able to sign one on the account's behalf. + this.accountKeySeed = accountKeySeed session!!.conversations.myAddress = fnid?.let { ContactCard.canonical("$it@${config.host}") } @@ -703,6 +722,101 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + // -- Mailboxes (§20.4) --------------------------------------------------- + + /** + * The passphrase, held only while a mailbox is being added. + * + * 🔴 Not stored, and cleared the moment the attempt finishes. It is needed + * because signing in at another relay is a real sign-in there, and it is + * asked for on that screen rather than kept from the original one — + * a passphrase held in memory for the life of the app is a copy of the + * account's secret with no expiry. + */ + private fun homingFor(current: Session): io.flamenet.client.MailboxHoming? { + val relay = signedIn()?.relay ?: return null + return io.flamenet.client.MailboxHoming( + current.vault, current.identity, relay, current.relay) + } + + fun loadMailboxes() = viewModelScope.launch { + val current = session ?: return@launch + val fnid = signedIn()?.fnid ?: return@launch + val published = withContext(Dispatchers.IO) { + // ⚠️ Verified, never displayed as served. A relay handing back a + // shortened list is the exact failure multi-homing exists to + // survive, and showing it unchecked would make this screen a place + // that quietly confirms the attack. + runCatching { + current.relay.homes(fnid)?.let(io.flamenet.client.AccountIdentity::verifiedRelays) + }.getOrNull() + } + val readable = withContext(Dispatchers.IO) { + runCatching { homingFor(current)?.known() ?: emptyList() }.getOrDefault(emptyList()) + } + update { + it.copy( + mailboxes = published ?: emptyList(), + readableMailboxes = readable.toSet(), + ) + } + } + + fun addMailbox(relay: String, passphrase: String) = viewModelScope.launch { + val current = session ?: return@launch + val state = signedIn() ?: return@launch + val fnid = state.fnid + val seed = accountKeySeed + if (fnid == null || seed == null) { + // ⚠️ The account key exists only in the session that stretched the + // passphrase, so a restored one has none — and there is nothing to + // sign a mailbox record with. Said plainly rather than failing at + // the relay with something nobody can act on. + update { it.copy(error = + "Adding a mailbox needs the passphrase you signed in with. " + + "Sign out and back in, then try again.") } + return@launch + } + update { it.copy(busy = true, error = null) } + val outcome = withContext(Dispatchers.IO) { + runCatching { + homingFor(current)!!.add( + relay, state.handle, passphrase, seed, fnid, state.mailboxes) + } + } + update { it.copy(busy = false, error = outcome.exceptionOrNull()?.let(::describe)) } + loadMailboxes() + } + + fun removeMailbox(relay: String) = viewModelScope.launch { + val current = session ?: return@launch + val state = signedIn() ?: return@launch + val fnid = state.fnid + val seed = accountKeySeed + if (fnid == null || seed == null) { + update { it.copy(error = + "Changing your mailboxes needs the passphrase you signed in with. " + + "Sign out and back in, then try again.") } + return@launch + } + update { it.copy(busy = true, error = null) } + val outcome = withContext(Dispatchers.IO) { + runCatching { homingFor(current)!!.remove(relay, seed, fnid, state.mailboxes) } + } + update { it.copy(busy = false, error = outcome.exceptionOrNull()?.let(::describe)) } + loadMailboxes() + } + + /** + * The account key seed, for as long as this app is running. + * + * ⚠️ In memory only. It is derivable from the passphrase at any time, so + * persisting it would add a stealable copy of a secret without adding any + * capability — and it is what signs the mailbox record, which is the one + * thing a relay must never be able to do on the account's behalf. + */ + @Volatile private var accountKeySeed: ByteArray? = null + fun openGroup(group: io.flamenet.client.RelayClient.Group) { val current = session ?: return val thread = Vault.threadForGroup(group.id) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index da75ce4..3f4ca4e 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -795,10 +795,70 @@ class Conversations( // S4: keep auditing the log we depend on. Self-throttled (one head an // hour) and best-effort — it only acts on a proven fork. trust.verifyLogConsistencyIfDue() - val incoming = relay.pollInbox(identity.deviceId, vault.inboxWatermark, limit) 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. + // + // ⚠️ 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) } + } + return out + } + + /** + * Clients for the other mailboxes, rebuilt from the vault each poll. + * + * Rebuilt rather than cached because the list changes when a mailbox is + * added or dropped, and a cached one would go on polling a mailbox the user + * has just removed — or miss the one they have just added until relaunch. + */ + private fun extraHomes(): List> = + vault.extraHomes().map { home -> + home.relay to RelayClient( + "https://${home.relay}", home.username, home.appPassword + ).also { it.useRelayAuth(identity) } + } + + /** + * Read one mailbox. [home] is null for the relay this app signed in to. + * + * ⚠️ The watermark advances only for envelopes that were delivered or are + * permanently undeliverable, exactly as the single-mailbox path always did. + * A message whose session has not been established yet holds the cursor, so + * the next poll retries it rather than skipping it forever. + */ + private fun readInbox( + client: RelayClient, home: String?, limit: Int, into: MutableList, + ) { + val watermark = if (home == null) vault.inboxWatermark else vault.watermarkFor(home) + val incoming = client.pollInbox(identity.deviceId, watermark, limit) + val out = into for (message in incoming) { + // 🔴 **The same message arrives at every mailbox.** Collapsed by the + // id the sender stamped once per MESSAGE — the copies are byte + // identical, so without this the second one reaches a ratchet that + // has already deleted its message key and is dropped, which reads + // in the log as a lost message rather than as a duplicate. + // + // Marked BEFORE decrypting, not after: a mark written only on + // success would let two homes read in the same cycle both get past + // this line. + if (message.envelopeId.isNotEmpty()) { + if (vault.hasSeenEnvelope(message.envelopeId)) { + if (home == null) vault.inboxWatermark = message.id + else vault.setWatermarkFor(home, message.id) + continue + } + vault.rememberEnvelope(message.envelopeId) + } try { // 🔴 A sealed message arrives with NO sender the relay could // vouch for — the transport's from_user/from_device are absent @@ -973,9 +1033,9 @@ class Conversations( // lossy before was not the watermark — it was that only ONE // chain was ever tried. } - vault.inboxWatermark = message.id + if (home == null) vault.inboxWatermark = message.id + else vault.setWatermarkFor(home, message.id) } - return out } /** How many prekeys we have left with the relay, and a top-up when it is low. */ diff --git a/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt b/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt new file mode 100644 index 0000000..b4d0a35 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt @@ -0,0 +1,198 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault + +/** + * Keeping a mailbox at more than one relay (spec §20.4). + * + * ## What this buys, and what it does not + * + * Your **address** already survives a relay disappearing: it comes from your + * passphrase, so signing in elsewhere produces the same one. What does not + * survive is your **mailbox** — mail sitting at a relay that goes away is gone, + * and contacts holding only that relay's name have nowhere to write. + * + * A second home fixes both, and only in advance. Senders deliver a copy to + * every relay in a signed record, so losing one loses no message, and a contact + * whose delivery fails at the first home finds the second in the same record. + * **Added after a relay disappears, none of that helps**: there is nothing left + * to publish an updated record from and no way to tell anybody where you went. + * + * ## Why it is several requests + * + * Homing somewhere means having an account there. A relay will not take a + * mailbox record from a stranger, and a token minted by one relay is refused by + * every other because the audience is bound. So: get a credential at the new + * relay, register this device so there is a mailbox to deliver into, claim the + * same address so a sender can find it — and only then publish the record, at + * **every** home rather than only the new one. + * + * That last part is not tidiness. A home that does not know it is one still + * accepts mail, but a sender asking *it* for the record is handed a stale list + * and skips the others — so the redundancy quietly depends on which relay the + * sender happened to ask. + */ +class MailboxHoming( + private val vault: Vault, + private val identity: DeviceIdentity, + /** The relay this app signed in to. It is always a home and cannot be dropped. */ + private val primaryRelay: String, + private val primary: RelayClient, +) { + + class Refused(message: String) : Exception(message) + + companion object { + /** + * Normalise what somebody typed into a hostname, or return null. + * + * ⚠️ Refuses a URL rather than parsing one out. A relay is a hostname, + * and accepting `https://x/y` here would let a pasted string point this + * device at a path on somebody else's host — and then send it a signed + * record naming that host as a mailbox. + */ + fun hostname(typed: String): String? { + val host = typed.trim().lowercase() + if (host.isEmpty() || '.' !in host) return null + if ('/' in host || ':' in host || '@' in host || ' ' in host) return null + return host + } + } + + /** The relays this device holds a credential for, beyond the signed-in one. */ + fun known(): List = vault.extraHomes().map { it.relay } + + /** + * Sign in at [typed], register this device, claim the address, and publish + * the record everywhere. Returns the home list as it now stands. + * + * ⚠️ Nothing is written to the vault until the relay has actually accepted + * a registration. 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. + */ + fun add( + typed: String, + handle: String, + passphrase: String, + accountKeySeed: ByteArray, + fnid: String, + existingHomes: List, + ): List { + val host = hostname(typed) ?: throw Refused( + "That does not look like a relay's address. It is a hostname, like " + + "relay.example.com — no https:// and no path.") + if (host == primaryRelay || host in existingHomes) { + throw Refused("That relay already holds a mailbox for you.") + } + + val base = "https://$host" + val account = try { + RelayAccounts(base).signIn(handle, passphrase) + } catch (e: Exception) { + // ⚠️ Says which of the two things went wrong. "No account there" and + // "wrong passphrase" call for opposite actions, and one message + // covering both leaves somebody retyping a correct passphrase. + throw Refused( + "$host did not accept those details. Sign up there with the same " + + "passphrase first — that is what gives you the same address.") + } + + val client = RelayClient(base, account.username, account.appPassword) + client.useRelayAuth(identity) + + // A mailbox to deliver into. + // + // ⚠️ **No one-time prekeys here.** A one-time prekey is only usable if + // this device kept its private half, and those live in the identity + // archive keyed to no particular relay — publishing keys at a second + // home whose privates this device would not offer back makes every + // session opened with one undecryptable, which is worse than offering + // none. Without them a sender falls back to the signed prekey: it costs + // initial forward secrecy on the first message and nothing else. + try { + client.registerDevice(identity, emptyList()) + } catch (e: Exception) { + throw Refused("$host refused the registration: ${e.message}") + } + + // The same address here as everywhere. A sender resolving `fnid@host` + // asks that relay, and until the claim lands the answer is "no such + // address" — an unclaimed home is a mailbox nobody can address. + try { + AccountIdentity.claim(client, accountKeySeed, account.userId) + } catch (e: Exception) { + throw Refused("$host refused the address: ${e.message}") + } + + val homes = (existingHomes.ifEmpty { listOf(primaryRelay) }) + host + val clients = LinkedHashMap() + clients[primaryRelay] = primary + for (existing in vault.extraHomes()) { + clients[existing.relay] = RelayClient( + "https://${existing.relay}", existing.username, existing.appPassword + ).also { it.useRelayAuth(identity) } + } + clients[host] = client + + val serial = AccountIdentity.nextSerial(fnid, clients.values) + val failures = AccountIdentity.publishHomes( + accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary }) + + // 🔴 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. + failures[host]?.let { throw Refused("$host refused the mailbox: ${it.message}") } + + vault.saveExtraHomes( + vault.extraHomes() + Vault.ExtraHome(host, account.username, account.appPassword)) + return homes + } + + /** + * Stop keeping a mailbox at [relay]. + * + * ⚠️ **The shorter list is published first, and the local 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 — indistinguishable, from the sender's side, from delivery. + * + * 🔴 The relay being removed 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. Its copy simply goes + * stale, which costs nothing: the record is self-certifying and every other + * home serves the current one. + */ + fun remove( + relay: String, + accountKeySeed: ByteArray, + fnid: String, + currentHomes: List, + ) { + if (relay == primaryRelay) { + throw Refused( + "This is the relay you are signed in to, so it cannot be removed " + + "while you are using it.") + } + val remaining = currentHomes.filterNot { it == relay } + if (remaining.isEmpty()) throw Refused("An account needs at least one mailbox.") + + val clients = LinkedHashMap() + clients[primaryRelay] = primary + for (existing in vault.extraHomes()) { + if (existing.relay == relay) continue + clients[existing.relay] = RelayClient( + "https://${existing.relay}", existing.username, existing.appPassword + ).also { it.useRelayAuth(identity) } + } + + val serial = AccountIdentity.nextSerial(fnid, clients.values) + val failures = AccountIdentity.publishHomes( + accountKeySeed, fnid, serial, remaining.associateWith { clients[it] ?: primary }) + failures[primaryRelay]?.let { + throw Refused("Your own relay would not take the new list: ${it.message}") + } + vault.forgetExtraHome(relay) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index ca93d42..ecb43a8 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -206,6 +206,16 @@ class RelayClient( val fromDevice: String, val payload: String, val createdAt: Long = 0, + /** + * The same value in every copy of one message, so a device reading + * several mailboxes collapses them rather than showing the message once + * per home. + * + * ⚠️ Empty on a relay older than multi-homing, and with one mailbox + * there is nothing to collapse — so an empty value means "no reason to + * think this is a duplicate", not "this is not a duplicate". + */ + val envelopeId: String = "", ) /** @@ -237,6 +247,7 @@ class RelayClient( createdAt = runCatching { java.time.Instant.parse(m.optString("created_at")).toEpochMilli() }.getOrDefault(0), + envelopeId = m.optString("envelope_id"), ) } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index c0c1110..a92df86 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -55,6 +55,9 @@ 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 SEEN_ENVELOPES = "seen-envelopes" + private const val EXTRA_HOMES = "extra-homes" + private const val MAX_SEEN_ENVELOPES = 2000 private const val REMOTE_ADDRESS_PREFIX = "remote-addr-" private const val REMOTE_PIN_PREFIX = "remote-pin-" private const val REMOTE_ADDRESSES = "remote-addresses" @@ -560,6 +563,82 @@ class Vault(private val storage: Storage, private val key: SecretKey) { get() = readJson(WATERMARK)?.optLong("id", 0) ?: 0 set(value) = writeJson(WATERMARK, JSONObject().put("id", value)) + // -- Extra mailboxes (§20.4) --------------------------------------------- + + /** + * The inbox cursor for a relay other than the one this app signed in to. + * + * ⚠️ **Per relay, and it has to be.** Envelope ids come from each relay's + * own database, so id 40 at one home and id 40 at another are unrelated + * rows — one shared cursor would let the first home's progress silently + * skip the second home's mail. + */ + fun watermarkFor(relay: String): Long = + readJson(WATERMARK + "-" + relay)?.optLong("id", 0) ?: 0 + + fun setWatermarkFor(relay: String, value: Long) = + writeJson(WATERMARK + "-" + relay, JSONObject().put("id", value)) + + /** + * Whether this message has already been read from another mailbox. + * + * 🔴 **The point of several mailboxes is that a message goes to all of + * them.** Without this the person reads it once per home — and would not, + * in fact, because the ratchet deletes a message key once it is used, so + * the second copy fails to decrypt. The visible symptom is therefore not a + * duplicate but a dropped envelope and a message that looks lost. + * + * Bounded, because the key comes from whoever wrote to us. Oldest first: a + * forgotten id can at worst show a very old message twice, which beats a + * list anybody can grow without limit. + */ + fun hasSeenEnvelope(id: String): Boolean = id in seenEnvelopeIds() + + fun rememberEnvelope(id: String) { + val seen = seenEnvelopeIds().toMutableList() + if (id in seen) return + seen += id + while (seen.size > MAX_SEEN_ENVELOPES) seen.removeAt(0) + writeJson(SEEN_ENVELOPES, JSONObject().put("ids", org.json.JSONArray(seen))) + } + + private fun seenEnvelopeIds(): List = + readJson(SEEN_ENVELOPES)?.optJSONArray("ids")?.let { a -> + (0 until a.length()).map { a.getString(it) } + } ?: emptyList() + + /** + * A relay this account keeps a mailbox at, and how to sign in there. + * + * ⚠️ **A credential per relay, not one shared.** An app password is minted + * by the relay that issued it and refused everywhere else, so a single + * 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) + + 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")) + } + } ?: emptyList() + + fun saveExtraHomes(homes: List) { + val array = org.json.JSONArray() + homes.forEach { + array.put(JSONObject() + .put("relay", it.relay).put("user", it.username).put("pw", it.appPassword)) + } + writeJson(EXTRA_HOMES, JSONObject().put("homes", array)) + } + + fun forgetExtraHome(relay: String) { + saveExtraHomes(extraHomes().filterNot { it.relay == relay }) + storage.delete(WATERMARK + "-" + relay) + } + // -- Message history ----------------------------------------------------- /**