Zero key material after use (ios#39, Android half)

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>
This commit is contained in:
Paul Hitt
2026-09-04 13:43:39 -04:00
co-authored by Claude Opus 5
parent f362db0ee7
commit 4a587a7d4d
4 changed files with 256 additions and 15 deletions
@@ -0,0 +1,140 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Zeroing key material once it has been used (ios#39, Android half).
*
* 🔴 **The claim being tested is narrow, and the tests are shaped to keep it
* narrow.** What this buys is that a derived key is not left sitting in the
* heap after the one moment it was needed. It is not "the key is gone" — a
* compacting collector can have moved the array, Android has compressed swap,
* and there is no `memset_s` on the JVM. `SecureBytes` says all of that in
* prose; these assert only what is actually true.
*
* 🔴 **The most valuable test here is that the derived keys did not change.**
* Wiping an intermediate one line too early derives every account secret from
* thirty-two zero bytes — which produces a perfectly consistent, perfectly
* wrong identity, silently, on every device. That is a far worse failure than
* the leak this feature exists to narrow, so it is checked first.
*/
class SecureBytesTest {
private val passphrase = "a-long-enough-passphrase"
private val salt = ByteArray(16) { it.toByte() }
// ---- The failure that would matter most -----------------------------
/**
* 🔴 The pinned vectors already cover `authSecret` and `accountKeySeed`.
* This covers the shape wiping could break without moving a vector: that
* the two secrets from one stretch still match the two derived separately.
* If the wipe landed between the expansions, the second would be derived
* from zeroes and this diverges.
*/
@Test
fun `wiping the intermediate did not change what is derived`() {
val (auth, account) = AccountKdf.signInSecrets(passphrase, salt)
assertContentEquals(AccountKdf.authSecret(passphrase, salt), auth)
assertContentEquals(AccountKdf.accountKeySeed(passphrase, salt), account)
}
/**
* ⚠️ And neither is zeroes, which is what "derived from a wiped buffer"
* would look like. A test that only compared the two paths would pass if
* BOTH were wrong in the same way.
*/
@Test
fun `no derived secret is a run of zeroes`() {
val (auth, account) = AccountKdf.signInSecrets(passphrase, salt)
assertFalse(auth.all { it == 0.toByte() }, "the auth secret is all zeroes")
assertFalse(account.all { it == 0.toByte() }, "the account seed is all zeroes")
assertFalse(
AccountKdf.identityKey(passphrase, salt).all { it == 0.toByte() },
"the identity key is all zeroes",
)
}
/** Derivation stays deterministic across calls, wiping or not. */
@Test
fun `derivation is still deterministic`() {
assertContentEquals(
AccountKdf.identityKey(passphrase, salt),
AccountKdf.identityKey(passphrase, salt),
)
}
/** Different contexts still separate, so the wipe did not collapse them. */
@Test
fun `the contexts still produce different secrets`() {
val auth = AccountKdf.authSecret(passphrase, salt)
val identity = AccountKdf.identityKey(passphrase, salt)
val account = AccountKdf.accountKeySeed(passphrase, salt)
assertFalse(auth.contentEquals(identity))
assertFalse(auth.contentEquals(account))
assertFalse(identity.contentEquals(account))
}
// ---- The wiping itself ----------------------------------------------
@Test
fun `wipe zeroes the array it is given`() {
val secret = ByteArray(32) { 0x41 }
SecureBytes.wipe(secret)
assertTrue(secret.all { it == 0.toByte() })
}
@Test
fun `wiping nothing is not an error`() {
SecureBytes.wipe(null)
SecureBytes.wipe(ByteArray(0))
}
@Test
fun `withSecret wipes on the way out`() {
val secret = ByteArray(32) { 0x41 }
val seen = SecureBytes.withSecret(secret) { it.copyOf() }
assertTrue(seen.all { it == 0x41.toByte() }, "the body saw a wiped buffer")
assertTrue(secret.all { it == 0.toByte() }, "the buffer was not wiped")
}
/**
* 🔴 **The case the `finally` exists for.** A derivation that failed
* half-way is exactly where the material gets forgotten, so the wipe has to
* survive a throw — not sit at the end of a happy path.
*/
@Test
fun `withSecret wipes when the body throws`() {
val secret = ByteArray(32) { 0x41 }
assertFailsWith<IllegalStateException> {
SecureBytes.withSecret(secret) { error("derivation failed half-way") }
}
assertTrue(secret.all { it == 0.toByte() }, "a throw left the material behind")
}
/**
* ⚠️ **The honest limit, asserted rather than left to the prose.** A copy
* taken before the wipe is untouched by it. This is not a bug to fix — a
* `ByteArray` cannot reach copies of itself — and stating it as a passing
* test stops anybody reading `wipe` as stronger than it is.
*/
@Test
fun `a copy taken beforehand survives the wipe`() {
val secret = ByteArray(32) { 0x41 }
val copy = SecureBytes.copyOf(secret)
SecureBytes.wipe(secret)
assertTrue(secret.all { it == 0.toByte() })
assertTrue(copy.all { it == 0x41.toByte() },
"the copy was wiped too, which would mean these share a buffer")
}
@Test
fun `withSecret returns what the body returned`() {
assertEquals(7, SecureBytes.withSecret(ByteArray(4)) { 7 })
}
}