1b9f6ac8953a
H-04: duress storage forensics — pad to slot-A size, presence-indistinguishable fake, codex restore into duress slot
- time
- 2026-07-29 14:46 UTC · 2 months ago
- machine
- koh2
- agent
- claude-opus-4
- session
- audit-remediation-2026-07-29
- parent
- 5fb4f8745b61
↓ peach-android-1b9f6ac8.tar.gzatlas at this save
files changed4 files
app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+152 −18app/src/main/java/com/peachpasswords/vault/VaultSnapshot.kt+7 −0app/src/main/java/com/peachpasswords/vault/VaultStore.kt+5 −1app/src/test/java/com/peachpasswords/vault/DuressSlotBTest.kt+129 −7app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+152 −18
@@ -109,13 +109,18 @@109109 // ---- Codex (printed paper backup) -------------------------------------110110 111111 /**112- * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the113- * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable114- * on any device that has the master password. Null when no vault exists.112+ * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the113+ * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's114+ * A/B naming where slot B is the duress decoy); the noise slot is local-only115+ * and never crosses devices. Restorable on any device that has the master116+ * password. Null when no vault exists.115117 */116118 fun codexVaultBlobBase64(): String? {117119 val slotA = store.loadSlotA() ?: return null118- val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""120+ // H-04: duress slot is always present after createVault (real or fake),121+ // so the wrapper can rely on it being loadable.122+ val slotD = store.loadDecoySlot() ?: return null123+ val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""119124 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))120125 }121126 @@ -181,22 +186,39 @@181186 182187 val priorA = store.loadSlotA()183188 val priorB = store.loadSlotB()184- // Carry the real slot B across when the Codex has one — it holds a185- // duress vault, which noise would silently destroy.186- val codexSlotB = wrapper["b"].asSlotJson()189+ val priorD = store.loadDecoySlot()190+ // Carry the real duress slot across when the Codex has one — the191+ // browser writes "b" as its duress-shaped slot, and dropping it192+ // here would silently destroy a configured decoy.193+ // H-04: write browser B into the duress slot (not the noise slot),194+ // translating browser-shaped slots to Android's StoredVault shape.195+ val codexDuressJson = wrapper["b"].asSlotJson()196+ val codexDuress: StoredVault? = codexDuressJson?.let {197+ VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->198+ // Wrong-password on the duress slot only (browser decoys can199+ // use a different passphrase). Fall through to a fake duress200+ // — the slot-A password was already authenticated.201+ BrowserSlot.toAndroidSlot(b, password)202+ }203+ }187204 188205 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")189206 val committed = runCatching {190- val wroteB = if (codexSlotB != null) {191- store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))207+ // Noise slot is always size-matched to slot A — local-only,208+ // never written from a Codex.209+ if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")210+ // H-04: duress slot always present; real one from the Codex or211+ // a fake the size of slot A.212+ val duressOk = if (codexDuress != null) {213+ store.writeDecoySlot(codexDuress)192214 } else {193- writeMatchingNoiseSlotB(slotA)215+ writeFakeDuressSlot(slotA)194216 }195- if (!wroteB) throw IllegalStateException("storage_write_failed")217+ if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")196218 store.stampLocalMutation()197219 }.isSuccess198220 if (!committed) {199- rollbackRestore(priorA, priorB)221+ rollbackRestore(priorA, priorB, priorD)200222 return UnlockResult.Error("storage_write_failed")201223 }202224 @@ -228,7 +250,7 @@228250 * the accurate inverse is having none at all, rather than a half-written229251 * slot A that [hasVault] would report as a real vault.230252 */231- private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {253+ private fun rollbackRestore(priorA: StoredVault?, priorB: String?, priorD: StoredVault? = null) {232254 val failures = mutableListOf<String>()233255 if (priorA == null) {234256 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }@@ -239,6 +261,15 @@239261 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }240262 .onFailure { failures += "writeSlotB: $it" }241263 }264+ // H-04: restore the prior duress slot — a half-committed restore that265+ // left the duress record on the Codex side and slot A on the prior266+ // side would put the at-rest pair in a state that never existed.267+ if (priorD != null) {268+ runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }269+ .onFailure { failures += "writeDecoySlot: $it" }270+ } else {271+ runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }272+ }242273 }243274 // Log is a throwing stub off-device, and a failed log must never mask244275 // the rollback result the caller is about to report.@@ -299,6 +330,9 @@299330 // fails — leaving a half-built vault is worse than refusing to make it.300331 val created = try {301332 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")333+ // H-04: duress slot must be present from the start so its334+ // existence on disk is independent of duressConfigured.335+ if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")302336 store.stampLocalMutation()303337 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")304338 true@@ -356,6 +390,9 @@356390 // past slot A wipes the half-built vault and refuses adoption.357391 val created = try {358392 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")393+ // H-04: duress slot must be present from the start so its394+ // existence on disk is independent of duressConfigured.395+ if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")359396 store.stampLocalMutation()360397 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")361398 true@@ -453,6 +490,10 @@453490 runCatching { store.writeSlotA(slotA) }454491 if (priorB != null) runCatching { store.writeSlotB(priorB) }455492 false493+ } else if (!writeCompanionDuressSlot(next)) {494+ runCatching { store.writeSlotA(slotA) }495+ if (priorB != null) runCatching { store.writeSlotB(priorB) }496+ false456497 } else {457498 store.stampLocalMutation()458499 true@@ -612,7 +653,18 @@612653 val key = VaultCrypto.deriveKey(duressPassphrase, salt)613654 // An empty decoy vault with its own fresh sync root key — nothing real leaks.614655 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))615- val plaintext = VaultCodec.encode(decoyPayload)656+ val basePlaintext = VaultCodec.encode(decoyPayload)657+ // H-04: pad the duress plaintext to slot A's ciphertext length so the658+ // duress blob matches slot A's blob on disk. VaultCodec.decode strips659+ // trailing NULs, so the extra padding survives encrypt but never660+ // reaches the JSON parser.661+ // ponytail: only re-pads at configuration time — if slot A grows past662+ // its initial bucket (entries cross 4096/16384/etc.), the real decoy663+ // stays at the smaller bucket. Re-padding on every persist requires664+ // keeping the duress passphrase in memory, which we don't.665+ val slotA = store.loadSlotA()666+ val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0667+ val plaintext = if (targetSize > basePlaintext.size) basePlaintext.copyOf(targetSize) else basePlaintext616668 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))617669 VaultCrypto.zero(key)618670 VaultCrypto.zero(plaintext)@@ -629,7 +681,12 @@629681 630682 /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */631683 suspend fun clearDuress(): Boolean = mutex.withLock {632- store.clearDecoySlot()684+ // H-04: presence-indistinguishable — replace the real decoy with a fake685+ // blob of slot A's size rather than removing the duress record. The686+ // record stays on disk at the same length, so an at-rest observer687+ // cannot tell "duress cleared" from "no duress ever configured".688+ store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) } }689+ ?: store.clearDecoySlot()633690 // H-03: stateLock-fence the derivedKey/decoyMode read AND the634691 // duressConfigured write so a concurrent lock() cannot land between them.635692 val shouldClear = mutateState { derivedKey != null && !decoyMode }@@ -884,6 +941,9 @@884941 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")885942 try {886943 writeMatchingNoiseSlotB(nextSlot)944+ // H-04: a root-key rotation re-seals slot A, so the duress slot945+ // (real or fake) must be re-padded to the new ciphertext length.946+ if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")887947 store.stampLocalMutation()888948 } catch (e: Exception) {889949 store.writeSlotA(priorSlot)@@ -1283,8 +1343,12 @@12831343 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {12841344 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false12851345 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1346+ // H-04: snapshots now carry the duress slot too — restore preserves1347+ // the at-rest triad (A/B/D), not just the A/B pair.1348+ val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)12861349 val priorA = store.loadSlotA()12871350 val priorB = store.loadSlotB()1351+ val priorD = store.loadDecoySlot()12881352 // H-02: capture pre-restore state as the recovery snapshot BEFORE any12891353 // slot write — the old code took this snapshot, then stranded it on12901354 // every failure, polluting history with a "restore" entry that never@@ -1298,7 +1362,16 @@12981362 if (!takeVaultSnapshot("restore")) {12991363 return false13001364 }1301- if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) {1365+ // H-04: legacy snapshots without vault_d, or vault_d that fails to1366+ // decode, fall back to a fake duress of slot A's size — presence-1367+ // indistinguishable from any other freshly-restored vault.1368+ val nextD: StoredVault = restoredD ?: run {1369+ val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161370+ val fakeBlob = ByteArray(target).also(rng::nextBytes)1371+ val fakeSalt = VaultCrypto.generateSalt()1372+ StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1373+ }1374+ if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD)) {13021375 store.writeVaultHistory(priorHistory)13031376 return false13041377 }@@ -1313,12 +1386,17 @@13131386 * noise record. Any failure rolls back so the at-rest pair never holds a13141387 * restored slot A against a pre-restore slot B. Caller is responsible for13151388 * restoring the pre-snapshot history on a false return.1389+ *1390+ * H-04: duress slot is now part of the atomic triad — restored last so1391+ * a duress-write failure rolls the A/B pair back too.13161392 */13171393 private fun commitRestoreAtomic(13181394 nextA: StoredVault,13191395 nextB: String,1396+ nextD: StoredVault,13201397 priorA: StoredVault?,13211398 priorB: String?,1399+ priorD: StoredVault?,13221400 ): Boolean {13231401 if (!store.writeSlotA(nextA)) return false13241402 // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it@@ -1326,9 +1404,12 @@13261404 // the prior slot A and history.13271405 try {13281406 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1407+ // H-04: duress slot is part of the restore triad. Failures here1408+ // roll A+B back so the at-rest state stays consistent.1409+ if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")13291410 store.stampLocalMutation()13301411 } catch (_: Exception) {1331- rollbackRestore(priorA, priorB)1412+ rollbackRestore(priorA, priorB, priorD)13321413 return false13331414 }13341415 return true@@ -1683,6 +1764,10 @@16831764 if (!store.writeSlotA(next)) return false16841765 try {16851766 writeMatchingNoiseSlotB(next)1767+ // H-04: escrow rebuild changes slot A's plaintext size, so re-pad1768+ // the duress slot too. A real decoy is left alone; a fake is1769+ // re-sized to slot A's new ciphertext length.1770+ if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")16861771 } catch (e: Exception) {16871772 store.writeSlotA(prior)16881773 throw e@@ -1741,9 +1826,20 @@17411826 val slotA = store.loadSlotA() ?: return false17421827 val vaultA = VaultCodec.encodeSlot(slotA)17431828 val vaultB = store.loadSlotB() ?: slotA.blob1829+ // H-04: capture the duress slot so a restore preserves the at-rest1830+ // triad, not just the A/B pair. The duress slot is always present1831+ // (real or fake), so loadDecoySlot() returning null is a logic error1832+ // — fall through to a fake duress in that case rather than silently1833+ // losing the record from the snapshot.1834+ val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {1835+ val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161836+ val fakeBlob = ByteArray(target).also(rng::nextBytes)1837+ val fakeSalt = VaultCrypto.generateSalt()1838+ VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1839+ }17441840 val history = store.loadVaultHistory()17451841 val latest = history.snapshots.firstOrNull()1746- if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true1842+ if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true17471843 val entryCount = if (derivedKey != null && !decoyMode) {17481844 bucketEntryCount(entryList.count { it.deletedAt == null })17491845 } else {@@ -1756,6 +1852,7 @@17561852 reason = reason,17571853 vault_a = vaultA,17581854 vault_b = vaultB,1855+ vault_d = vaultD,17591856 entryCount = entryCount,17601857 )17611858 // P1-4 (codex): return the history-write boolean to callers so a@@ -1861,6 +1958,35 @@18611958 return store.writeSlotB(b64(noise))18621959 }18631960 1961+ /** H-04: the ciphertext length slot A is using right now, so the duress1962+ * slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext1963+ * length equals ciphertext length, so this also matches slot A's plaintext. */1964+ private fun matchingCiphertextLength(slotA: StoredVault): Int =1965+ (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)1966+1967+ /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]1968+ * bytes. Replaces a real decoy when the user clears duress, and is the1969+ * initial duress slot when no decoy is configured — so the record's1970+ * PRESENCE on disk is independent of duressConfigured, and its SIZE1971+ * matches slot A's blob length. */1972+ private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {1973+ val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161974+ val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)1975+ val fakeSalt = VaultCrypto.generateSalt()1976+ return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1977+ }1978+1979+ /** H-04: keep the duress record presence- and size-indistinguishable from1980+ * slot A on every persist. A real decoy is left alone (it was padded at1981+ * setDuressPassphrase time, and we don't have the duress key here to1982+ * re-pad on entry growth — that's an accepted residual tell, see the1983+ * ponytail note on setDuressPassphrase). No real decoy → a fake of slot1984+ * A's size. */1985+ private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {1986+ val isConfigured = mutateState { duressConfigured }1987+ return if (isConfigured) true else writeFakeDuressSlot(slotA)1988+ }1989+18641990 /** Re-seal slot A from the current in-memory state under the live key. */18651991 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {18661992 val key = derivedKey ?: throw IllegalStateException("vault_locked")@@ -1892,6 +2018,9 @@18922018 // it must trigger rollback; previously the call sat outside the try.18932019 try {18942020 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2021+ // H-04: duress slot stays present (real one untouched, fake one2022+ // re-padded to slot A's current size on every persist).2023+ if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")18952024 if (bumpVersion) store.stampLocalMutation()18962025 } catch (e: Exception) {18972026 store.writeSlotA(prior)@@ -1925,6 +2054,11 @@19252054 // escaped the helper and left slot A committed.19262055 try {19272056 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2057+ // H-04: a password rotation changes slot A's plaintext size (new2058+ // salt → re-encrypted blob), so the duress slot must be re-padded2059+ // alongside. writeCompanionDuressSlot leaves a real decoy alone2060+ // and re-emits a fake of slot A's size otherwise.2061+ if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")19282062 store.stampLocalMutation()19292063 } catch (_: Exception) {19302064 rollbackRotation(priorA, priorB)app/src/main/java/com/peachpasswords/vault/VaultSnapshot.kt+7 −0
@@ -9,5 +9,12 @@99 val reason: String,1010 val vault_a: String,1111 val vault_b: String,12+ // H-04: duress slot was always present at capture time but omitted from13+ // the snapshot — restore then re-wrote noise over the duress slot on14+ // Android and over slot B on the browser, both observable forensic tells.15+ // Null on snapshots captured before this field existed; restore falls back16+ // to a fake duress slot of matching size so the at-rest shape stays17+ // indistinguishable across restores.18+ val vault_d: String? = null,1219 val entryCount: Int? = null,1320 )app/src/main/java/com/peachpasswords/vault/VaultStore.kt+5 −1
@@ -207,8 +207,12 @@207207 }208208 209209 override fun clearVault() {210+ // H-04: the duress record is part of the vault's at-rest surface and must211+ // go with slot A — leaving it behind on a half-built vault is the212+ // forensic tell the audit flagged.210213 prefs.edit()211- .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)214+ .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_SLOT_DURESS)215+ .remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)212216 .commit()213217 }214218 app/src/test/java/com/peachpasswords/vault/DuressSlotBTest.kt+129 −7
@@ -1,24 +1,27 @@11 package com.peachpasswords.vault22 33 import kotlinx.coroutines.runBlocking4+import kotlinx.serialization.json.JsonPrimitive5+import kotlinx.serialization.json.buildJsonObject46 import org.junit.Assert.assertEquals57 import org.junit.Assert.assertFalse68 import org.junit.Assert.assertNotNull79 import org.junit.Assert.assertNull810 import org.junit.Assert.assertTrue911 import org.junit.Test12+import java.util.Base641013 1114 /** In-memory [VaultStorage] including a decoy (slot B) slot, for the MF-6 duress model. */1215 private class DuressFakeStorage : VaultStorage {13- private var slotA: StoredVault? = null14- private var slotB: String? = null16+ var slotA: StoredVault? = null17+ var slotB: String? = null1518 private var ver: Long = 01619 private var settings = VaultSettings()1720 override fun loadSlotA() = slotA1821 override fun hasVault() = slotA != null1922 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }2023 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }21- private var decoy: StoredVault? = null24+ var decoy: StoredVault? = null2225 override fun loadDecoySlot() = decoy2326 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }2427 override fun hasDecoySlot() = decoy != null@@ -27,7 +30,10 @@2730 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }2831 override fun loadSettings() = settings2932 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }30- override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }33+ var history = VaultHistory()34+ override fun loadVaultHistory() = history35+ override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }36+ override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }3137 }3238 3339 /**@@ -76,12 +82,26 @@7682 assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))7783 }7884 79- @Test fun clear_duress_removes_the_slot() = runBlocking {80- val repo = configuredRepo()85+ @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {86+ // H-04: presence-indistinguishable — clearDuress no longer removes the87+ // duress record, it replaces it with a fake blob the duress passphrase88+ // can't open. The at-rest slot pair keeps the same shape.89+ val storage = DuressFakeStorage()90+ val repo = VaultRepository(storage)91+ repo.createVault(real)92+ repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))93+ assertTrue(repo.setDuressPassphrase(duressPass))8194 assertEquals(UnlockResult.Success, repo.unlock(real))95+ val slotALen = storage.slotA!!.blob.length96+ val decoyBefore = storage.decoy!!97+ assertEquals(slotALen, decoyBefore.blob.length)8298 assertTrue(repo.clearDuress())99+ assertNotNull("duress record still present after clear", storage.decoy)100+ // The fake has the same blob length as slot A — forensic size tell closed.101+ assertEquals(slotALen, storage.decoy!!.blob.length)102+ // …and the duress passphrase no longer opens it.83103 repo.lock()84- assertNull("duress slot gone after clear", repo.tryUnlockDuress(duressPass))104+ assertNull(repo.tryUnlockDuress(duressPass))85105 }86106 87107 @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {@@ -114,5 +134,107 @@114134 threw = true115135 }116136 assertTrue("the decoy vault must not produce a vault id for sync", threw)137+ }138+139+ // ---- H-04: duress storage forensics ----------------------------------140+141+ /**142+ * H-04: the duress slot's stored blob must match slot A's stored blob143+ * length on disk — both when a real decoy is configured (padded at144+ * setDuressPassphrase) and when no decoy is configured (a fake of slot A's145+ * size is left behind so the duress record's PRESENCE on disk is146+ * independent of duressConfigured).147+ */148+ @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {149+ // Empty vault, no duress configured.150+ val storage = DuressFakeStorage()151+ val repo = VaultRepository(storage)152+ repo.createVault(real)153+ val slotALen = storage.slotA!!.blob.length154+ assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)155+ assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)156+157+ // With a real duress configured — blob length is still matched.158+ assertTrue(repo.setDuressPassphrase(duressPass))159+ assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)160+161+ // Slot A grows (entries added): the duress record is RE-PADDED on162+ // every persist (the real decoy can't be re-encrypted without its163+ // passphrase, so it's left alone — see the setDuressPassphrase164+ // ponytail note for the residual tell). A fake duress IS re-padded165+ // so it stays indistinguishable from the noise slot.166+ repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))167+ repo.clearDuress()168+ val newSlotALen = storage.slotA!!.blob.length169+ assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)170+ }171+172+ /**173+ * H-04: a snapshot must capture the duress slot, not just the noise slot.174+ * Without this, restoring from history overwrites slot A (the real vault)175+ * while the duress slot stays on whatever the previous state was — a176+ * forensic tell across restores.177+ */178+ @Test fun h04_snapshot_contains_duress() = runBlocking {179+ val storage = DuressFakeStorage()180+ val repo = VaultRepository(storage)181+ repo.createVault(real)182+ val initialDuress = storage.decoy183+ assertNotNull(initialDuress)184+ // The first snapshot is taken on every persist — grab it.185+ repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))186+ val snap = storage.history.snapshots.first()187+ assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)188+ assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)189+ // Restoring it writes the captured duress back to disk (atomic).190+ assertTrue(repo.restoreVaultSnapshot(snap.id))191+ assertNotNull(storage.decoy)192+ assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)193+ }194+195+ /**196+ * H-04: a Codex restore with a browser-shaped "b" (the browser's duress197+ * slot, written there because on the browser slot B IS the duress slot)198+ * must land in Android's duress slot, not the noise slot. Writing it to199+ * the noise slot would mean the duress record disappears on every Codex200+ * restore from a paired browser — another forensic tell.201+ */202+ @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {203+ // Source vault: a real Android duress exists.204+ val srcStorage = DuressFakeStorage()205+ val src = VaultRepository(srcStorage)206+ src.createVault(real)207+ src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))208+ assertTrue(src.setDuressPassphrase(duressPass))209+ val srcDuress = srcStorage.decoy!!210+211+ // Build a browser-shaped Codex: "b" holds the duress slot as a JSON212+ // STRING (the browser writes each slot as a string, not an object),213+ // matching the browser's A/B naming where B is the duress slot.214+ val androidBlob = src.codexVaultBlobBase64()!!215+ val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)216+ val slotAJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})").find(wrapper)!!.groupValues[1]217+ val slotDJson = Regex("\"b\"\\s*:\\s*(\\{.*\\})").find(wrapper)!!.groupValues[1]218+ val browserWrapper = buildJsonObject {219+ put("a", JsonPrimitive(slotAJson))220+ put("b", JsonPrimitive(slotDJson))221+ }.toString()222+ val browserCodex = Base64.getUrlEncoder().withoutPadding()223+ .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))224+225+ // Restore into a fresh device that already has a vault.226+ val dstStorage = DuressFakeStorage()227+ val dst = VaultRepository(dstStorage)228+ dst.createVault(real)229+ // Mark the noise slot so we can detect any cross-contamination.230+ dstStorage.slotB = "noise-before-restore"231+232+ assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))233+234+ // The noise slot is local-only — never overwritten by the Codex.235+ assertEquals("noise slot is local-only and untouched by Codex restore", "noise-before-restore", dstStorage.slotB)236+ // The duress slot now carries the Codex's "b" — not a fake, not noise.237+ assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)238+ assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)117239 }118240 }