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 ----------------------------------------------------- /**