diff --git a/PARITY.md b/PARITY.md index 7ba7ccd..e6de01c 100644 --- a/PARITY.md +++ b/PARITY.md @@ -160,8 +160,8 @@ 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 | | 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 | -| Pin the relay's identity | ✅ | ⚪ | ios#19 | +| 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 — Android already pinned the key and drew the **wrong conclusion** from a change; see below | ⚠️ **The back-off is not a defence of the data.** It slows a person holding an unlocked phone. The vault and the ratchet keys are protected by the Android @@ -185,6 +185,33 @@ 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 observation just made, or trust one that had not been. +🔴 **The relay pin 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 signature +stopped verifying and the only conclusion available at that point was +`KeyLogForkException` — so a rotation somebody performed on purpose was reported +to the user as *the relay showing two different histories*. + +Those need different responses. A fork means nothing the relay says can be +trusted. A rotation means somebody ran a command, or somebody else is answering +on that address, and only a person can tell which. `RelayIdentityChangedException` +is deliberately **not** a `KeyLogForkException`, and `forkDetected` is not set — +an alarm people cannot act on is one they learn to dismiss, which would cost the +real fork detection its credibility too. + +🔴 **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 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 diff --git a/client/src/main/kotlin/io/flamenet/client/RelayIdentityPin.kt b/client/src/main/kotlin/io/flamenet/client/RelayIdentityPin.kt new file mode 100644 index 0000000..38229af --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/RelayIdentityPin.kt @@ -0,0 +1,112 @@ +package io.flamenet.client + +/** + * Which relay this is, beyond which certificate it presented (ios#19). + * + * 🔴 **TLS answers "is this host who DNS says it is". It does not answer "is + * this the same relay I have been talking to".** A mis-issued certificate, a + * hijacked DNS record, or a hostile party who obtains the domain all produce a + * server with a perfectly valid certificate for the right name — and every one + * of them is a different relay wearing the same address. + * + * The relay publishes an issuer public key in its descriptor. Pinning that on + * first contact turns "the certificate is valid" into "this is the same relay", + * which is a materially stronger statement and the one that matters. + * + * 🔴 **Android already pinned this key and drew the wrong conclusion from it.** + * `Trust.verifyHeadSignature` pins `issuerPublicKey` the first time a signed log + * head arrives, and if the key later changes the signature simply fails to + * verify — so the client raises `KeyLogForkException`, sets `forkDetected`, and + * tells somebody the relay's key log does not add up. + * + * A rotated relay key is not a forked log. They need different responses: a + * fork means the relay is showing two different histories and nothing it says + * can be trusted; a rotation means somebody ran a command, or somebody else is + * answering on that address. Reporting the second as the first is both alarming + * and unactionable, and an alarm people cannot act on is one they learn to + * dismiss — which costs the real fork detection its credibility too. + * + * ⚠️ **A relay that publishes no key is a weaker deployment, not an attack.** + * Somebody self-hosting an older build has nothing to pin, and refusing to talk + * to them would be a worse failure than the one this prevents. That case is + * distinguished from a key that CHANGED, which is not weakness. + * + * ⚠️ A change is surfaced, never silently accepted and never silently refused. + * The same shape as a contact's identity changing: stop, say so, and let a + * person decide — because a legitimate rotation exists and an app that cannot + * survive one is an app that gets uninstalled. + */ +object RelayIdentityPin { + + /** What a descriptor's key means against what was pinned before. */ + sealed interface Verdict { + /** Nothing pinned and a key published: pin it. */ + data class FirstSight(val offered: ByteArray) : Verdict { + override fun equals(other: Any?) = + other is FirstSight && offered.contentEquals(other.offered) + override fun hashCode() = offered.contentHashCode() + } + + /** The same relay as before. */ + data object Unchanged : Verdict + + /** This deployment publishes no key. Nothing to check. */ + data object NotPublished : Verdict + + /** + * 🔴 A different key than the one pinned. Either a deliberate rotation + * or a different server answering on this address, and nothing here can + * tell those apart — only a person can. + */ + data class Changed(val pinned: ByteArray, val offered: ByteArray) : Verdict { + override fun equals(other: Any?) = other is Changed && + pinned.contentEquals(other.pinned) && offered.contentEquals(other.offered) + override fun hashCode() = 31 * pinned.contentHashCode() + offered.contentHashCode() + } + } + + /** + * Compare what the relay just published against what is pinned. + * + * ⚠️ An empty or unparseable key counts as "not published" rather than as a + * change. A relay that answers with rubbish is broken, and treating broken + * as hostile would raise an alarm nobody can act on. + */ + fun check(offeredBase64: String?, pinned: ByteArray?): Verdict { + val offered = offeredBase64 + ?.takeIf { it.isNotEmpty() } + ?.let { runCatching { Wire.unb64(it) }.getOrNull() } + ?.takeIf { it.isNotEmpty() } + ?: return Verdict.NotPublished + if (pinned == null || pinned.isEmpty()) return Verdict.FirstSight(offered) + return if (offered.contentEquals(pinned)) Verdict.Unchanged + else Verdict.Changed(pinned = pinned, offered = offered) + } + + /** + * What to tell somebody when it changed. + * + * 🔴 Names the two possibilities and does not pretend to know which. An + * alarm that asserts an attack when a rotation is equally likely trains + * people to dismiss alarms. + */ + fun warning(host: String): String = + "The server at $host is presenting a different identity than the one " + + "this app recorded the first time it connected. That happens when the " + + "server's keys are rotated on purpose — and it also happens when " + + "something else is answering in its place. Nothing here can tell those " + + "apart. If you were not expecting a change, stop and check with " + + "whoever runs it before signing in again." +} + +/** + * The relay is not the one this app pinned. + * + * 🔴 **Deliberately NOT a [KeyLogForkException].** A fork means the relay is + * showing two different histories and nothing it says can be trusted. This means + * the server's identity key is not the one recorded — a rotation somebody + * performed, or somebody else's server. The remedies are different, so the + * exceptions are different. + */ +class RelayIdentityChangedException(val host: String) : + RelayException(RelayIdentityPin.warning(host)) diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt index 3d48183..ba4d977 100644 --- a/client/src/main/kotlin/io/flamenet/client/Trust.kt +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -612,21 +612,45 @@ class Trust( private fun verifyHeadSignature(head: Head, signatureB64: String) { if (signatureB64.isEmpty()) return val signature = runCatching { Wire.unb64(signatureB64) }.getOrNull() ?: return - var issuer = vault.keyLogIssuerPin() - if (issuer == null) { - val published = runCatching { - Wire.unb64(relay.relayDescriptor().optString("issuerPublicKey")) - }.getOrNull() - if (published == null || published.isEmpty()) return - vault.saveKeyLogIssuerPin(published) - issuer = published + + // ios#19 — decide what the relay's published identity MEANS before + // using it, rather than inferring it from a signature that failed. + // + // 🔴 This is the bug the pin previously hid. A rotated relay key made + // the signature stop verifying, and the only conclusion available at + // that point was "fork" — so a command somebody ran on purpose was + // reported as the relay showing two different histories. Different + // situations, different remedies, and an alarm nobody can act on is one + // people learn to dismiss, which costs real fork detection too. + val offered = runCatching { + relay.relayDescriptor().optString("issuerPublicKey") + }.getOrNull() + val issuer = when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) { + is RelayIdentityPin.Verdict.NotPublished -> + // Nothing published and nothing pinned: a weaker deployment, + // not an attack. Nothing to check the signature against. + vault.keyLogIssuerPin() ?: return + is RelayIdentityPin.Verdict.FirstSight -> { + vault.saveKeyLogIssuerPin(verdict.offered) + verdict.offered + } + is RelayIdentityPin.Verdict.Unchanged -> vault.keyLogIssuerPin() ?: return + is RelayIdentityPin.Verdict.Changed -> + // ⚠️ NOT a fork, and `forkDetected` is deliberately not set. + // The log may be perfectly consistent; it is the server's + // identity that is not the one recorded. + throw RelayIdentityChangedException(relayHostForPin()) } + if (!KeyLog.verifyHead(head.size, head.root, signature, issuer)) { forkDetected = true throw KeyLogForkException("log head signature does not verify under the pinned key") } } + /** The host to name in an identity-change warning. */ + private fun relayHostForPin(): String = relay.host + private fun consistent(from: Int, to: Int, oldRoot: String, newRoot: String): Boolean { val c = relay.logConsistency(from.toLong(), to.toLong()) return KeyLog.verifyConsistency( diff --git a/client/src/test/kotlin/io/flamenet/client/RelayIdentityPinTest.kt b/client/src/test/kotlin/io/flamenet/client/RelayIdentityPinTest.kt new file mode 100644 index 0000000..173c583 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/RelayIdentityPinTest.kt @@ -0,0 +1,167 @@ +package io.flamenet.client + +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Pinning the relay's identity (ios#19, Android half). + * + * 🔴 **The finding this closes is not a missing feature — it is a wrong + * conclusion.** Android already pinned `issuerPublicKey`, inside + * `Trust.verifyHeadSignature`. When the key later changed, the head signature + * stopped verifying and the only conclusion available at that point was + * `KeyLogForkException` — so a rotation somebody performed on purpose was + * reported to the user as the relay showing two different histories. + * + * Those need different responses. A fork means nothing the relay says can be + * trusted. A rotation means somebody ran a command, or somebody else is + * answering on that address, and a person has to decide 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 too. + */ +class RelayIdentityPinTest { + + private fun key(byte: Int) = ByteArray(32) { byte.toByte() } + private fun b64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + // ---- The four verdicts ---------------------------------------------- + + @Test + fun `a published key with nothing pinned is first sight`() { + val offered = key(1) + val verdict = RelayIdentityPin.check(b64(offered), pinned = null) + assertEquals(RelayIdentityPin.Verdict.FirstSight(offered), verdict) + } + + @Test + fun `the same key is unchanged`() { + assertEquals( + RelayIdentityPin.Verdict.Unchanged, + RelayIdentityPin.check(b64(key(1)), pinned = key(1)), + ) + } + + /** + * 🔴 The case the whole file exists for, and the one that used to be + * reported as a forked log. + */ + @Test + fun `a different key is a change, carrying both halves`() { + val verdict = RelayIdentityPin.check(b64(key(2)), pinned = key(1)) + val changed = assertIs(verdict) + assertTrue(changed.pinned.contentEquals(key(1))) + assertTrue(changed.offered.contentEquals(key(2))) + } + + /** + * ⚠️ **A relay that publishes no key is a weaker deployment, not an + * attack.** Somebody self-hosting an older build has nothing to pin, and + * refusing to talk to them would be a worse failure than the one this + * prevents. + */ + @Test + fun `a deployment that publishes nothing is not an attack`() { + assertEquals( + RelayIdentityPin.Verdict.NotPublished, + RelayIdentityPin.check(null, pinned = key(1)), + ) + assertEquals( + RelayIdentityPin.Verdict.NotPublished, + RelayIdentityPin.check("", pinned = key(1)), + ) + } + + /** + * ⚠️ **Broken is not hostile.** A relay answering with rubbish counts as + * "not published" rather than as a change — treating a malformed field as + * an attack would raise an alarm nobody can act on, on a deployment that is + * simply misconfigured. + */ + @Test + fun `an unparseable key reads as not published, not as a change`() { + assertEquals( + RelayIdentityPin.Verdict.NotPublished, + RelayIdentityPin.check("!!! not base64 !!!", pinned = key(1)), + ) + // Valid base64 of nothing is still nothing. + assertEquals( + RelayIdentityPin.Verdict.NotPublished, + RelayIdentityPin.check(b64(ByteArray(0)), pinned = key(1)), + ) + } + + /** An empty pin is no pin: first sight, not a change against zero bytes. */ + @Test + fun `an empty pin is treated as no pin`() { + val offered = key(3) + assertEquals( + RelayIdentityPin.Verdict.FirstSight(offered), + RelayIdentityPin.check(b64(offered), pinned = ByteArray(0)), + ) + } + + // ---- What somebody is told ------------------------------------------ + + /** + * 🔴 The warning names BOTH possibilities and asserts neither. An alarm that + * claims an attack when a rotation is equally likely trains people to + * dismiss alarms — and the next one may be real. + */ + @Test + fun `the warning does not pretend to know which it is`() { + val text = RelayIdentityPin.warning("relay.example.com") + assertTrue(text.contains("relay.example.com"), text) + assertTrue(text.contains("rotated on purpose"), text) + assertTrue(text.contains("something else is answering"), text) + assertTrue(text.contains("Nothing here can tell those apart"), text) + } + + /** + * 🔴 **The exception is not a fork exception, and that separation is the + * point of this change.** A caller catching `KeyLogForkException` to decide + * "the log is untrustworthy" must not be handed a rotation. + */ + @Test + fun `an identity change is not reported as a key-log fork`() { + // ⚠️ Held as the sealed base, deliberately. Typed as the concrete class + // the compiler folds `!is KeyLogForkException` to a constant and warns — + // which is the compiler proving the point statically, and also means the + // assertion would test nothing. Through the base type it is the runtime + // check a caller's `when` actually performs. + val thrown: RelayException = RelayIdentityChangedException("relay.example.com") + assertTrue( + thrown !is KeyLogForkException, + "a rotated relay key is being reported as a forked log", + ) + val identity = assertIs(thrown) + assertEquals("relay.example.com", identity.host) + assertTrue(identity.message!!.contains("rotated on purpose")) + } + + // ---- Shape ----------------------------------------------------------- + + /** + * ⚠️ `ByteArray` has reference equality by default, so the verdicts carry + * hand-written `equals`. Without them every comparison in this suite would + * be false and the tests would have been written around it. + */ + @Test + fun `verdicts compare by content, not by reference`() { + assertEquals( + RelayIdentityPin.Verdict.FirstSight(key(1)), + RelayIdentityPin.Verdict.FirstSight(key(1)), + ) + assertEquals( + RelayIdentityPin.Verdict.Changed(key(1), key(2)), + RelayIdentityPin.Verdict.Changed(key(1), key(2)), + ) + assertTrue( + RelayIdentityPin.Verdict.Changed(key(1), key(2)) != + RelayIdentityPin.Verdict.Changed(key(1), key(3)), + ) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt index dc5b87b..2b075a5 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt @@ -133,12 +133,17 @@ object AccountKdf { context: String, length: Int = 32, params: Params = Params.PBKDF2_V1, - ): ByteArray = Hkdf.derive( - ikm = master(passphrase, salt, params), - salt = ByteArray(0), - info = context.toByteArray(Charsets.UTF_8), - length = length, - ) + ): ByteArray = SecureBytes.withSecret(master(passphrase, salt, params)) { ikm -> + // 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), + info = context.toByteArray(Charsets.UTF_8), + length = length, + ) + } /** What gets sent to the account service. */ fun authSecret(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray = @@ -179,14 +184,19 @@ object AccountKdf { salt: ByteArray, params: Params = Params.PBKDF2_V1, ): Pair { - val ikm = master(passphrase, salt, params) - fun expand(context: String) = Hkdf.derive( - ikm = ikm, - salt = ByteArray(0), - info = context.toByteArray(Charsets.UTF_8), - length = 32, - ) - return expand(Context.ACCOUNT_AUTH) to expand(Context.ACCOUNT_IDENTITY) + return SecureBytes.withSecret(master(passphrase, salt, params)) { ikm -> + fun expand(context: String) = Hkdf.derive( + ikm = ikm, + salt = ByteArray(0), + info = context.toByteArray(Charsets.UTF_8), + length = 32, + ) + // ⚠️ 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) + } } /** diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SecureBytes.kt b/engine/src/main/kotlin/io/flamenet/e2e/SecureBytes.kt new file mode 100644 index 0000000..381e99d --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/SecureBytes.kt @@ -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 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() +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SecureBytesTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SecureBytesTest.kt new file mode 100644 index 0000000..4c89295 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/SecureBytesTest.kt @@ -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 { + 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 }) + } +}