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
@@ -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 -----------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user