Key-log state is per relay, and two bugs the new tests found

Writing tests for the multi-mailbox and key-log work turned up three
things, two of them in code committed earlier today.

**The "this relay serves a log" flag was global.** So was the head pin.
Signing in to a second relay that keeps no log would have been read as
the FIRST one going quiet — a fork warning against an honest deployment,
and every contact on it unreachable. The pin was worse: a second relay's
head checked against the first relay's root cannot reconcile, so the
same outcome arrived by a different route. Both are now keyed by relay
host. Pin reads fall back to the old global entry, so a phone already
holding one keeps it rather than starting trust-on-first-use again.

**A dead home relay hid every other mailbox.** `poll` read the
signed-in relay first and let its failure propagate, which is precisely
the situation several mailboxes exist to survive, defeated by an
unguarded call. But swallowing it outright broke a property that was
already tested and matters: a failed poll must not be reported as an
empty inbox, because "no new messages" and "could not reach the server"
are different things the app says different words for. Both hold now —
every mailbox is read, and the failure is raised only if NOTHING could
be read. A home that is down while a backup answers is a partial success
whose mail is returned rather than thrown away with the error.

**A relay is named in the record and dialled by URL, and those are two
questions.** The hostname is inside the signature so it can carry no
scheme and no port; how this device reaches that host is separate and
now stored beside it. That is the production shape as well as what lets
these paths be tested at all.

New: 12 tests for the address and home record (including that a relay
serving a shortened or lengthened list is caught by arithmetic), 7 for
multi-mailbox reading, 10 for sending to an address, 10 for adding and
dropping a mailbox, 5 for the control flag. A fixture that let a mock
relay forget a record it had stored is fixed too — it made every publish
after it reuse serial 1, which a real relay refuses as a rollback.

Verified against a live relay: 243 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 17:29:53 -04:00
co-authored by Claude Opus 4.8
parent 366f5cbeab
commit 088fd5f5a7
13 changed files with 1189 additions and 42 deletions
+56 -10
View File
@@ -521,13 +521,28 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
*/
data class KeyLogPin(val size: Int, val root: String)
fun loadKeyLogPin(): KeyLogPin? = readJson(KEYLOG_PIN)?.let {
KeyLogPin(it.getInt("size"), it.getString("root"))
/**
* ⚠️ **Per relay, like [keyLogServed].** A single pin meant that signing in
* to a second relay checked ITS head against the first relay's root — which
* cannot reconcile, so an honest relay was reported as forked and every
* contact on it became unreachable.
*
* Reads fall back to the pre-2026-08-30 global entry, so a device already
* holding a pin keeps it rather than starting trust-on-first-use again.
* Writes only ever go to the per-relay entry.
*/
fun loadKeyLogPin(relay: String? = null): KeyLogPin? {
if (relay != null) {
readJson(KEYLOG_PIN + "-" + relay)?.let {
return KeyLogPin(it.getInt("size"), it.getString("root"))
}
}
return readJson(KEYLOG_PIN)?.let { KeyLogPin(it.getInt("size"), it.getString("root")) }
}
fun saveKeyLogPin(pin: KeyLogPin) = writeJson(KEYLOG_PIN, JSONObject()
.put("size", pin.size)
.put("root", pin.root))
fun saveKeyLogPin(pin: KeyLogPin, relay: String? = null) =
writeJson(if (relay == null) KEYLOG_PIN else KEYLOG_PIN + "-" + relay,
JSONObject().put("size", pin.size).put("root", pin.root))
/**
* Whether this relay has ever answered the head route.
@@ -546,9 +561,17 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* from the pin would make a gossiped head start demanding a log route the
* relay may never have had.
*/
fun keyLogServed(): Boolean = readJson(KEYLOG_SERVED) != null
/**
* ⚠️ **Per relay.** A single global flag was the first version and was
* wrong: signing in to a second relay that keeps no log would have been
* read as the FIRST one going quiet, so an honest deployment without
* transparency became a fork warning and every contact on it became
* unreachable.
*/
fun keyLogServed(relay: String): Boolean = readJson(KEYLOG_SERVED + "-" + relay) != null
fun rememberKeyLogServed() = writeJson(KEYLOG_SERVED, JSONObject().put("seen", true))
fun rememberKeyLogServed(relay: String) =
writeJson(KEYLOG_SERVED + "-" + relay, JSONObject().put("seen", true))
// -- Inbox watermark -----------------------------------------------------
@@ -615,13 +638,35 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* 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)
data class ExtraHome(
/**
* The hostname, as the signed home record names it.
*
* ⚠️ This is the identity of the mailbox and what a sender resolves —
* it is inside the signature, so it cannot carry a scheme or a port.
*/
val relay: String,
val username: String,
val appPassword: String,
/**
* How THIS device reaches that relay, which is a separate question.
*
* Defaults to `https://<relay>`, which is the only correct answer in
* production and the only one the record can express. Held separately
* rather than rebuilt at every call site so there is one place that
* decides, and so a self-hosted or test deployment reachable some other
* way does not need the record to lie about its own name.
*/
val baseUrl: String = "https://$relay",
)
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"))
val relay = o.getString("relay")
ExtraHome(relay, o.getString("user"), o.getString("pw"),
o.optString("url").ifEmpty { "https://$relay" })
}
} ?: emptyList()
@@ -629,7 +674,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
val array = org.json.JSONArray()
homes.forEach {
array.put(JSONObject()
.put("relay", it.relay).put("user", it.username).put("pw", it.appPassword))
.put("relay", it.relay).put("user", it.username).put("pw", it.appPassword)
.put("url", it.baseUrl))
}
writeJson(EXTRA_HOMES, JSONObject().put("homes", array))
}