🔴 This was not a missing feature on Android. It was a wrong conclusion.
`Trust.verifyHeadSignature` already pinned `issuerPublicKey` the first time a
signed log head arrived. When the key later changed, the head signature simply
stopped verifying — and the only conclusion available at that point was
`KeyLogForkException`. So a relay key rotation somebody performed on purpose
was reported to the user as the relay showing two different histories, with
`forkDetected` set.
Those need different responses. A fork means nothing the relay says can be
trusted and the client should stop. A rotation means somebody ran a command, or
somebody else is answering on that address, and only a person can tell which.
Reporting the second as the first is alarming and unactionable — and an alarm
people cannot act on is one they learn to dismiss, which costs the real fork
detection its credibility as well.
`RelayIdentityPin` now decides what the published key MEANS before it is used,
with the same four verdicts as iOS, and a change raises
`RelayIdentityChangedException` — deliberately NOT a `KeyLogForkException`, and
without setting `forkDetected`, because the log may be perfectly consistent.
⚠️ A relay that publishes no key is a weaker deployment, not an attack, and an
unparseable key is broken rather than hostile. Both read as "not published"
rather than as a change: refusing to talk to an older self-hosted build would
be a worse failure than the one this prevents.
⚠️ The warning names both possibilities and asserts neither, because nothing in
the client can tell a rotation from an impostor.
Two things found while writing the tests. `RelayClient.host` already existed, so
the one I added was a duplicate declaration — removed rather than renamed. And
the compiler folded `changed !is KeyLogForkException` to a constant when the
value was typed concretely, which is the compiler proving the separation
statically and also an assertion testing nothing; it is now held as the sealed
base, which is the check a caller's `when` actually performs.
9 unit tests. App builds, every JVM suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stretched master key in AccountKdf is an intermediate nobody needs after
the HKDF expansion, and it is the most valuable thing in that file: every other
account secret is derived from it. It now lives inside
`SecureBytes.withSecret`, which wipes it however the block leaves — including
out of a throw, because a derivation that failed half-way is exactly where
material gets forgotten.
🔴 The Android guarantee is WEAKER than the iOS one, and PARITY.md says 🟡
rather than rounding it up to ✅. iOS has `memset_s`, specified not to be
optimised away, and does not page application memory to disk. Neither holds on
the JVM: a compacting collector can relocate a ByteArray and leave the bytes at
the old address untouched, Android has had compressed swap on by default for
years, and `Arrays.fill` is only "not eliminated in practice" rather than
guaranteed. There is no way to pin a Java array, so that is a ceiling rather
than a gap to close later.
So the claim is written narrowly: a derived key is not left sitting in the heap
after use. Not "the key is gone". Spending the value of this feature on a
stronger claim than it supports would be the worst possible trade.
⚠️ `signInSecrets` does BOTH expansions inside the block. Wiping between them
would derive the account seed from thirty-two zero bytes — a perfectly
consistent, perfectly wrong identity, on every device, silently. That is a far
worse failure than the leak this narrows, and `signInSecrets` had no pinned
vector of its own, so a test compares the pair-path against the individual
derivations. Proven: moving the wipe one line earlier turns it red.
⚠️ The limit is asserted rather than left to prose — a copy taken before a wipe
survives it, and that is a passing test so nobody reads `wipe` as stronger than
it is.
10 unit tests. App builds, every JVM suite green including the pinned KDF
vectors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>