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
@@ -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<String> =
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<ExtraHome> =
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<ExtraHome>) {
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 -----------------------------------------------------
/**