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
4 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 −7
app/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)

view file

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 )

view file

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 

view file

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 }

view file