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
@@ -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",
)
}
@@ -109,6 +109,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
*/
val remoteAddresses: List<String> = 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<String> = emptyList(),
/** Mailboxes this device holds a credential for and can actually read. */
val readableMailboxes: Set<String> = emptySet(),
/** Same question for the group list. See [buddiesLoaded]. */
val groupsLoaded: Boolean = false,
val groups: List<io.flamenet.client.RelayClient.Group> = 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)