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:
co-authored by
Claude Opus 5
parent
f362db0ee7
commit
4a587a7d4d
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user