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
+14 -1
View File
@@ -160,7 +160,7 @@ being the one that leaks.
|---|:--:|:--:|---| |---|:--:|:--:|---|
| Back off on repeated app-lock attempts | ✅ | ✅ | ios#41 — 3 free attempts, then 30s doubling to a 5-minute ceiling | | Back off on repeated app-lock attempts | ✅ | ✅ | ios#41 — 3 free attempts, then 30s doubling to a 5-minute ceiling |
| Handle a wrong clock | ✅ | ✅ | ios#45 — skew learned free from the `Date` header; expiry takes the LATER of device and relay | | Handle a wrong clock | ✅ | ✅ | ios#45 — skew learned free from the `Date` header; expiry takes the LATER of device and relay |
| Zero key material after use | ✅ | ⚪ | ios#39 | | Zero key material after use | ✅ | 🟡 | ios#39 — done, but the JVM guarantee is **weaker than iOS**; see below |
| Pin the relay's identity | ✅ | ⚪ | ios#19 | | Pin the relay's identity | ✅ | ⚪ | ios#19 |
⚠️ **The back-off is not a defence of the data.** It slows a person holding an ⚠️ **The back-off is not a defence of the data.** It slows a person holding an
@@ -185,6 +185,19 @@ the UI thread, so the two fields are `@Volatile` and updated together under a
lock. A torn read of "skew from now, timestamp from an hour ago" would age out an lock. A torn read of "skew from now, timestamp from an hour ago" would age out an
observation just made, or trust one that had not been. observation just made, or trust one that had not been.
🔴 **Zeroing is where Android cannot match iOS, and the row says 🟡 for that
reason rather than being rounded up to ✅.** iOS has `memset_s`, specified not to
be optimised away, and iOS does not page application memory to disk. Neither
holds here: 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 merely "not eliminated in practice"
rather than guaranteed. There is no way to pin a Java array, so this is a
ceiling on what any implementation here can offer — not a gap to close later.
The claim Android supports is therefore narrower and is written that way in
`SecureBytes`: **a derived key is not left sitting in the heap after use.** Not
"the key is gone".
🔴 One divergence from iOS, and it is a bug the Swift version cannot have. iOS 🔴 One divergence from iOS, and it is a bug the Swift version cannot have. iOS
computes `30 * pow(2, steps)` in a `Double`, which saturates to infinity and computes `30 * pow(2, steps)` in a `Double`, which saturates to infinity and
still caps correctly. Kotlin shifting a `Long` does not: `30L shl 60` wraps still caps correctly. Kotlin shifting a `Long` does not: `30L shl 60` wraps
@@ -133,12 +133,17 @@ object AccountKdf {
context: String, context: String,
length: Int = 32, length: Int = 32,
params: Params = Params.PBKDF2_V1, params: Params = Params.PBKDF2_V1,
): ByteArray = Hkdf.derive( ): ByteArray = SecureBytes.withSecret(master(passphrase, salt, params)) { ikm ->
ikm = master(passphrase, salt, params), // ios#39 — the stretched master is an intermediate nobody needs after
// the expansion, and it is the most valuable thing in this file: every
// other secret is derived from it. Wiped however this leaves.
Hkdf.derive(
ikm = ikm,
salt = ByteArray(0), salt = ByteArray(0),
info = context.toByteArray(Charsets.UTF_8), info = context.toByteArray(Charsets.UTF_8),
length = length, length = length,
) )
}
/** What gets sent to the account service. */ /** What gets sent to the account service. */
fun authSecret(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray = fun authSecret(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray =
@@ -179,14 +184,19 @@ object AccountKdf {
salt: ByteArray, salt: ByteArray,
params: Params = Params.PBKDF2_V1, params: Params = Params.PBKDF2_V1,
): Pair<ByteArray, ByteArray> { ): Pair<ByteArray, ByteArray> {
val ikm = master(passphrase, salt, params) return SecureBytes.withSecret(master(passphrase, salt, params)) { ikm ->
fun expand(context: String) = Hkdf.derive( fun expand(context: String) = Hkdf.derive(
ikm = ikm, ikm = ikm,
salt = ByteArray(0), salt = ByteArray(0),
info = context.toByteArray(Charsets.UTF_8), info = context.toByteArray(Charsets.UTF_8),
length = 32, length = 32,
) )
return expand(Context.ACCOUNT_AUTH) to expand(Context.ACCOUNT_IDENTITY) // ⚠️ BOTH expansions happen inside the block. Returning the pair and
// wiping afterwards would be the same thing; expanding after the
// wipe would derive two keys from thirty-two zero bytes, which is
// the failure mode this shape makes impossible to write.
expand(Context.ACCOUNT_AUTH) to expand(Context.ACCOUNT_IDENTITY)
}
} }
/** /**
@@ -0,0 +1,78 @@
package io.flamenet.e2e
import java.util.Arrays
/**
* Zeroing key material once it has been used (ios#39).
*
* 🔴 **This narrows a window. It does not remove the material, and claiming
* otherwise would spend the whole value of the feature on a lie.** What it buys
* is that a derived key does not sit in the process for the lifetime of the app
* after the one moment it was needed — which matters for a heap dump, a crash
* report, or a debugger attached later. It buys nothing against somebody
* reading memory *while* the secret is in use.
*
* 🔴 **The JVM guarantee is weaker than the iOS one, and the difference is not
* a detail.** iOS has `memset_s`, which is specified not to be optimised away,
* and iOS does not page application memory to disk. Neither holds here:
*
* - **A compacting garbage collector moves objects.** A `ByteArray` can be
* relocated between allocation and wiping, and the bytes at the old address
* are not cleared by the move. Zeroing reaches the array you are holding; it
* cannot reach a copy the runtime made on its own. There is no way to pin a
* Java array, so this is a ceiling on what any implementation here can offer.
* - **Android has compressed swap.** zram has been on by default for years, so
* a page holding key material can reach a backing store — unlike iOS, where
* the platform does not page application memory out at all.
* - **There is no `memset_s`.** `Arrays.fill` on an array that escapes the
* method is not eliminated in practice, because the JIT cannot prove the
* write is dead. "Not in practice" is a weaker statement than the C standard
* makes, and it is the honest one.
*
* ⚠️ **A Kotlin `String` cannot be zeroed**, same as Swift and for the same
* reasons: immutable, freely copied, and possibly interned. A passphrase that
* arrived as a `String` is beyond reach. The right response is to turn it into
* bytes early, wipe those, and not pretend about the original — which is why
* `AccountKdf.master` already calls `PBEKeySpec.clearPassword()` rather than
* trying to do anything about the `String` it was handed.
*
* So the claim this type supports, precisely: **a derived key is not left
* sitting in the heap after use.** Not "the key is gone".
*/
object SecureBytes {
/**
* Zero this array in place.
*
* ⚠️ Reaches THIS array only. Anything that copied it beforehand keeps an
* untouched copy, and the runtime may have made copies nobody asked for.
*/
fun wipe(bytes: ByteArray?) {
if (bytes == null || bytes.isEmpty()) return
Arrays.fill(bytes, 0)
}
/**
* Run [body] with key material and wipe it however [body] leaves.
*
* ⚠️ Wipes on the way out of a throw as well. A derivation that failed
* half-way is exactly the case where the material is most likely to be
* forgotten — which is why this is `try/finally` rather than a wipe at the
* end of a happy path.
*/
inline fun <T> withSecret(secret: ByteArray, body: (ByteArray) -> T): T =
try {
body(secret)
} finally {
wipe(secret)
}
/**
* A copy of [source], for callers that genuinely need one.
*
* 🔴 The copy is ordinary memory and this object stops being responsible
* for it. Every use of this is a decision to leave a second copy behind,
* and it is spelled out rather than implicit for that reason.
*/
fun copyOf(source: ByteArray): ByteArray = source.copyOf()
}
@@ -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 })
}
}