Fix: a contact with no address was pinned as the string "null"

The same platform difference as the vault index, in shipped code, with a
worse consequence.

🔴 `saveTrust` wrote `"fnid": null` explicitly when a contact's global
address was not yet known. Android's bundled `org.json` reads a JSON null
back as the four-character string `"null"` — a non-null value that is not
an address. What that caused on a phone:

- `Trust.pin` compares a newly offered fnid against the pinned one and
  treats a difference as a different person. `"null" != "fnid:…"`, so the
  FIRST TIME a real address arrived the contact was marked **CHANGED** —
  the "security keys changed" warning, which is supposed to mean an
  attack, firing on an ordinary first sighting. The vault's own comment
  about minting a fresh device warns about exactly this shape of false
  alarm.
- `rememberIdentity` returns early once `fnid != null`, so the real
  address was never resolved and the record stayed wrong permanently.
- `pinnedFnid` fed `"null"` into key-log verification, which recomputes a
  leaf against the address a contact was accepted under — every check
  against the wrong subject.

Two halves, because records written the old way are already on phones:
the writer now omits the key entirely (an absent key reads as `""` on both
platforms, which is what the reader always expected), and the reader
rejects the literal `"null"` so existing records repair themselves.

⚠️ The test that matters is the one feeding the reader the literal string.
On the JVM a real JSON null reads back as `""` whether the key is omitted
or not, so the plain round-trip passes with the bug intact — which is why
this survived every suite. Confirmed by restoring the old reader and
watching it go red.

Found by the sweep flagged in android!54. `JSONObject.NULL` is written in
exactly two places in this app, and both were wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 16:10:47 -04:00
co-authored by Claude Opus 5
parent 436ce240c7
commit 2d05bbe7c4
2 changed files with 91 additions and 2 deletions
@@ -315,4 +315,66 @@ class VaultTest {
Vault(storage, secret).saveIdentity(identity)
assertNotEquals(Bytes.b64(first), Bytes.b64(storage.records.getValue("identity")))
}
// ---- Trust records and the platform's JSON ---------------------------
/**
* 🔴 **A contact with no known address must come back with NO address.**
*
* `saveTrust` used to write `"fnid": null` explicitly, and Android's
* bundled `org.json` reads a JSON null back as the four-character string
* `"null"` — a non-null value that is not an address. `Trust.pin` compares a
* newly offered fnid against the pinned one and treats a difference as a
* different person, so the first time a real address arrived the contact was
* marked **CHANGED**: the "security keys changed" warning, which is meant to
* mean an attack, fired on an ordinary first sighting.
*
* ⚠️ The JVM's `org.json` returns `""` there, so this passed on a laptop and
* failed on a phone. The two tests below cover both halves — what is
* written now, and what has to be tolerated because it is already on
* devices in the field.
*/
@Test
fun `a trust record with no address writes no address at all`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveTrust(7, Vault.Trust(listOf("aaa"), verified = false, fnid = null))
assertNull(Vault(storage, secret).loadTrust(7)?.fnid,
"an absent address came back as a value")
}
/**
* ⚠️ **This is the test that reproduces the device**, and the one above is
* not. On the JVM a real JSON null reads back as `""` whether the writer
* omits the key or not, so the round trip above passes with the bug intact.
* Handing the reader the literal string is what Android actually does, so
* writing it here is what makes a laptop run tell the truth.
*/
@Test
fun `a record already holding the literal string null reads as no address`() {
// 🔴 Records written the old way exist on phones already. The reader has
// to repair them, or those contacts stay permanently mis-addressed —
// `rememberIdentity` returns early once `fnid != null`, so nothing would
// ever ask again.
val storage = MapStorage()
val secret = key()
val vault = Vault(storage, secret)
vault.saveTrust(7, Vault.Trust(listOf("aaa"), verified = false, fnid = "null"))
assertNull(Vault(storage, secret).loadTrust(7)?.fnid,
"the literal string \"null\" was accepted as somebody's global address")
}
@Test
fun `a real address still round-trips`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveTrust(
7, Vault.Trust(listOf("aaa"), verified = true, fnid = "fnid:abc123"))
val back = Vault(storage, secret).loadTrust(7)
assertEquals("fnid:abc123", back?.fnid)
assertTrue(back!!.verified)
}
}