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:
co-authored by
Claude Opus 4.8
parent
444ebf0862
commit
72314491fa
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user