A contact who reinstalled could never be messaged again

🔴 **The chat header said "keys unchanged since you met" while every send was
refused with "identity keys changed".** Found by driving two real apps: the
iOS side relaunched with -FNMSGResetState, which is a genuinely new device with
new keys, and Android correctly refused to send to it. Correct — and then
there was no way out.

`Trust.state()` could return UNKNOWN, VERIFIED or PINNED and nothing else.
CHANGED was computed inside `record()` while a send was in flight and thrown
away, because `Vault.Trust` had nowhere to remember it. So:

- the header kept reporting the OLD pinned keys as unchanged,
- the "Accept new keys" button, which only renders for CHANGED, never appeared,
- `acceptIdentityChange()` was unreachable from the UI,
- and the contact stayed unmessageable for ever, with the app insisting
  nothing was wrong.

That is precisely what a tester does on day one: reinstall, and discover their
friend can no longer reach them.

`Vault.Trust` now carries `changed`, `record()` persists the detection without
adopting the offered keys and drops the verified tick (whatever was compared in
person was compared against the old key), `state()` reports it, and accepting
clears it. Keys that match again also clear it, so a transient wrong answer
does not strand somebody behind a banner for ever.

Verified live on the emulator: the banner appears with its full text, Accept
new keys is reachable, and the header returns to "keys unchanged" afterwards.
Four tests in TrustTest; three of them fail against the old `state()`.

---

🔴 **An expired session told the user their passphrase was wrong.** Hit
immediately afterwards: a send failed with "That handle or passphrase isn't
right", ten hours after a successful sign-in, because AccountsClient mapped
EVERY 401 and 403 to BadCredentials. The natural reading is that the password
stopped working or was changed by someone else, and the natural response is to
change a password that was never wrong.

Only `sessions` and `accounts` actually present a passphrase, so only those can
report one as wrong. Everything else now raises SessionExpired, which the app
states as what it is. Two tests cover both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-26 07:09:08 -04:00
co-authored by Claude Opus 5
parent 3c66d3e9bd
commit cda64d8c2e
12 changed files with 257 additions and 6 deletions
@@ -219,6 +219,23 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* purpose, so the client cannot tell them apart either.
*/
val fnid: String? = null,
/**
* A key change was detected and has NOT been accepted yet.
*
* 🔴 **Without this the detection is thrown away.** `record()` computes
* CHANGED while a send is in flight and returns it; nothing persisted
* it, so `state()` could only ever answer UNKNOWN, VERIFIED or PINNED.
* The chat header therefore went on saying "keys unchanged since you
* met" while every send failed with "identity keys changed", and the
* "Accept new keys" button — which only renders for CHANGED — could
* never appear. A contact who reinstalled became permanently
* unmessageable, with the UI insisting nothing was wrong.
*
* ⚠️ The pinned `identities` stay as they were. This flag records that
* something ELSE was offered, not that we believe it.
*/
val changed: Boolean = false,
)
fun loadTrust(userId: Long): Trust? = readJson(trustName(userId))?.let { o ->
@@ -227,13 +244,15 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
identities = (0 until a.length()).map { a.getString(it) },
verified = o.optBoolean("verified", false),
fnid = o.optString("fnid").ifEmpty { null },
changed = o.optBoolean("changed", false),
)
}
fun saveTrust(userId: Long, trust: Trust) = writeJson(trustName(userId), JSONObject()
.put("identities", org.json.JSONArray(trust.identities))
.put("verified", trust.verified)
.put("fnid", trust.fnid ?: JSONObject.NULL))
.put("fnid", trust.fnid ?: JSONObject.NULL)
.put("changed", trust.changed))
fun forgetTrust(userId: Long) = storage.delete(trustName(userId))