Mailboxes at more than one relay

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 16:06:11 -04:00
co-authored by Claude Opus 4.8
parent 444ebf0862
commit 72314491fa
6 changed files with 588 additions and 3 deletions
@@ -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<Received>()
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<Pair<String, RelayClient>> =
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<Received>,
) {
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. */
@@ -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<String> = 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<String>,
): List<String> {
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<String, RelayClient>()
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<String>,
) {
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<String, RelayClient>()
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)
}
}
@@ -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"),
)
}
}