cb62379d810a

fix: Carry browser duress slot across Codex restore instead of dropping it (v2.9.2)

H-04 / user requirement: a duress configured on the browser extension must remain usable after restoring onto Android. Defect: the Codex restore decoded the browser's duress slot "b" via BrowserSlot.toAndroidSlot(b, password), which tries to DECRYPT it with the main (slot-A) password. The browser seals slot "b" under the DURESS passphrase, so the AES-GCM tag check failed and a real, configured decoy was silently replaced by a fake — the audit's 'browser duress vault silently unusable after Android restore.' Fix (VaultRepository.kt): browserDuressPassthrough(b) converts the browser-shaped duress slot to an Android StoredVault WITHOUT decrypting — re-encoding the salt hex->base64 and concatenating nonce||ciphertext||tag into Android's blob — so tryUnlockDuress(duressPassphrase) can still open it. The no-duress path still writes a size-matched fake (forensic indistinguishability preserved). Tests: DuressCodexRoundTripTest (3) — browser codex with duress restores and unlocks both the real and the duress slot; no-duress codex yields a size-matched fake; android-shaped codex duress round-trips. Gate: 825 tests 0 failures/errors/skips (116 suites); lintDebug 0 errors; assembleRelease. Still open (coordinated, separate task): duress over the 2.9.0 adoption snapshot (entries+tombstones only today) and the broader H-04 A/B slot-model redesign.

time
2026-07-30 06:52 UTC · 2 months ago
machine
koh2
parent
d05b98b443d5
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+36 −4app/src/test/java/com/peachpasswords/vault/DuressCodexRoundTripTest.kt+224 −0
VERSION+1 −1
@@ -1 +1 @@1-2.9.11+2.9.2

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+36 −4
@@ -28,6 +28,15 @@2828 2929 private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)3030 private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)31+private fun unhex(s: String): ByteArray {32+    require(s.length % 2 == 0) { "odd-length hex" }33+    return ByteArray(s.length / 2) { i ->34+        val hi = Character.digit(s[i * 2], 16)35+        val lo = Character.digit(s[i * 2 + 1], 16)36+        require(hi >= 0 && lo >= 0) { "bad hex digit" }37+        ((hi shl 4) or lo).toByte()38+    }39+}3140 3241 /** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */3342 private const val PASSWORD_HISTORY_MAX = 50@@ -305,10 +314,13 @@305314             val codexDuressJson = wrapper["b"].asSlotJson()306315             val codexDuress: StoredVault? = codexDuressJson?.let {307316                 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->308-                    // Wrong-password on the duress slot only (browser decoys can309-                    // use a different passphrase). Fall through to a fake duress310-                    // — the slot-A password was already authenticated.311-                    BrowserSlot.toAndroidSlot(b, password)317+                    // H-04: pass-through — the browser duress slot is sealed318+                    // under the duress passphrase, not the main password. Trying319+                    // to decrypt with the slot-A password fails the GCM tag and320+                    // silently drops a real decoy. Re-encode the browser hex321+                    // fields as Android base64 salt/blob so tryUnlockDuress can322+                    // still open it with the duress passphrase.323+                    browserDuressPassthrough(b)312324                 }313325             }314326 @@ -2529,6 +2541,26 @@25292541     private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {25302542         val isConfigured = mutateState { duressConfigured }25312543         return if (isConfigured) true else writeFakeDuressSlot(slotA)2544+    }2545+2546+    /**2547+     * H-04: convert a browser-shaped duress slot to Android StoredVault2548+     * without decrypting. The browser seals slot "b" under the duress2549+     * passphrase; decrypting with the main password silently drops it.2550+     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2551+     * the salt from hex to base64.2552+     */2553+    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2554+        val salt = unhex(b.salt)2555+        val nonce = unhex(b.nonce)2556+        val ct = unhex(b.ciphertext)2557+        val tag = unhex(b.tag)2558+        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2559+            System.arraycopy(nonce, 0, it, 0, nonce.size)2560+            System.arraycopy(ct, 0, it, nonce.size, ct.size)2561+            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2562+        }2563+        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))25322564     }25332565 25342566     /** Re-seal slot A from the current in-memory state under the live key. */

view file

app/src/test/java/com/peachpasswords/vault/DuressCodexRoundTripTest.kt+224 −0
@@ -0,0 +1,224 @@1+package com.peachpasswords.vault2+3+import kotlinx.coroutines.runBlocking4+import kotlinx.serialization.json.Json5+import kotlinx.serialization.json.JsonPrimitive6+import kotlinx.serialization.json.buildJsonObject7+import org.junit.Assert.assertEquals8+import org.junit.Assert.assertFalse9+import org.junit.Assert.assertNotEquals10+import org.junit.Assert.assertNotNull11+import org.junit.Assert.assertNull12+import org.junit.Assert.assertTrue13+import org.junit.Test14+import java.util.Base6415+16+/**17+ * End-to-end Codex round-trip tests for the duress (slot B / decoy) slot.18+ * Proves that a browser-shaped Codex carrying a real duress "b" slot — sealed19+ * under the duress passphrase, not the main password — restores correctly on20+ * Android: the real password still opens slot A, the duress passphrase opens21+ * slot B, and a wrong decoy password fails closed.22+ */23+class DuressCodexRoundTripTest {24+25+    private class MemStorage : VaultStorage {26+        var slotA: StoredVault? = null27+        var slotB: String? = null28+        private var ver: Long = 029+        private var settings = VaultSettings()30+        override fun loadSlotA() = slotA31+        override fun hasVault() = slotA != null32+        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }33+        override fun loadSlotB() = slotB34+        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }35+        var decoy: StoredVault? = null36+        override fun loadDecoySlot() = decoy37+        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }38+        override fun hasDecoySlot() = decoy != null39+        override fun clearDecoySlot() { decoy = null }40+        override fun version() = ver41+        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }42+        override fun loadSettings() = settings43+        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }44+        var history = VaultHistory()45+        override fun loadVaultHistory() = history46+        override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }47+        override fun clearVaultHistory() { history = VaultHistory() }48+        override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }49+    }50+51+    private val mainPassword = "correct-horse-battery-staple"52+    private val duressPassword = "open-the-decoy-vault-456"53+    private val wrongPassword = "totally-wrong-password"54+55+    private fun hex(bytes: ByteArray): String =56+        bytes.joinToString("") { "%02x".format(it) }57+58+    /**59+     * Convert an Android StoredVault into a browser-shaped BrowserSlotA JSON60+     * string (hex salt/nonce/ciphertext/tag). This simulates what the browser61+     * writes into a Codex — the encrypted content is preserved as-is, only the62+     * encoding changes from Android's base64(salt)+base64(nonce‖ct‖tag) to the63+     * browser's separate hex fields.64+     */65+    private fun androidSlotToBrowserJson(slot: StoredVault): String {66+        val salt = Base64.getDecoder().decode(slot.salt)67+        val blob = Base64.getDecoder().decode(slot.blob)68+        val nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)69+        val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)70+        // AES-GCM tag is the last 16 bytes of the ciphertext+tag blob.71+        val ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)72+        val tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)73+        val browserSlot = BrowserSlotA(74+            version = slot.version,75+            salt = hex(salt),76+            nonce = hex(nonce),77+            ciphertext = hex(ct),78+            tag = hex(tag),79+        )80+        return Json.encodeToString(BrowserSlotA.serializer(), browserSlot)81+    }82+83+    /**84+     * Build a browser-shaped Codex from an Android source vault. Both "a" and85+     * "b" are written as JSON strings (the browser's format), with the slot86+     * content in browser hex-field shape.87+     */88+    private fun buildBrowserCodex(src: VaultRepository): String {89+        val androidBlob = src.codexVaultBlobBase64()!!90+        val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)91+        val parsed = Json.parseToJsonElement(wrapper).asJsonObject()92+        val slotAJson = parsed["a"].toString()93+        val slotBJon = parsed["b"].toString()94+        val slotA = VaultCodec.decodeSlot(slotAJson)!!95+        val slotB = VaultCodec.decodeSlot(slotBJon)!!96+        val browserA = androidSlotToBrowserJson(slotA)97+        val browserB = androidSlotToBrowserJson(slotB)98+        val browserWrapper = buildJsonObject {99+            put("a", JsonPrimitive(browserA))100+            put("b", JsonPrimitive(browserB))101+        }.toString()102+        return Base64.getUrlEncoder().withoutPadding()103+            .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))104+    }105+106+    private fun kotlinx.serialization.json.JsonElement.asJsonObject() =107+        this as kotlinx.serialization.json.JsonObject108+109+    /**110+     * Full round-trip: build a vault with duress on "device A", generate a111+     * browser-shaped Codex, restore on "device B", and verify all unlock paths.112+     */113+    @Test fun browser_codex_with_duress_restores_and_unlocks_both_slots() = runBlocking {114+        // ── Device A: create vault, add entries, configure duress ──────────115+        val srcStorage = MemStorage()116+        val src = VaultRepository(srcStorage)117+        src.createVault(mainPassword)118+        src.addEntry(VaultEntry(id = "", title = "RealEntry", type = "login", password = "secret", createdAt = 0, modifiedAt = 0))119+        assertTrue("duress passphrase configured", src.setDuressPassphrase(duressPassword))120+        // Verify source duress works before export.121+        src.lock()122+        val srcDuress = src.tryUnlockDuress(duressPassword)123+        assertNotNull("source duress unlock works", srcDuress)124+        src.discardDuressUnlock(srcDuress!!)125+        assertEquals(UnlockResult.Success, src.unlock(mainPassword))126+127+        // ── Build browser-shaped Codex ─────────────────────────────────────128+        val browserCodex = buildBrowserCodex(src)129+130+        // ── Device B: restore from browser Codex ──────────────────────────131+        val dstStorage = MemStorage()132+        val dst = VaultRepository(dstStorage)133+134+        // (a) Real password unlocks slot A and entries are present.135+        assertEquals("real password restores slot A",136+            UnlockResult.Success, dst.restoreFromCodex(browserCodex, mainPassword))137+        assertTrue("entries present after restore", dst.getEntries().any { it.title == "RealEntry" })138+139+        // Lock so we can test duress unlock independently.140+        dst.lock()141+142+        // (b) Duress password unlocks slot B.143+        val duress = dst.tryUnlockDuress(duressPassword)144+        assertNotNull("duress passphrase unlocks the restored duress slot", duress)145+        assertEquals(UnlockResult.Success, dst.commitDuressUnlock(duress!!))146+        assertTrue("decoy session is active", dst.isUnlocked.value)147+        assertEquals("decoy vault has no real entries", 0, dst.getEntries().size)148+149+        // (c) Wrong decoy password fails.150+        dst.lock()151+        assertNull("wrong password does NOT unlock duress slot",152+            dst.tryUnlockDuress(wrongPassword))153+        assertNull("wrong password does NOT unlock slot A either",154+            dst.tryUnlockSlotA(wrongPassword))155+    }156+157+    /**158+     * When a browser Codex has no "b" slot (null), a size-matched fake duress159+     * must be written so the at-rest record stays forensically indistinguishable.160+     */161+    @Test fun no_duress_codex_produces_size_matched_fake() = runBlocking {162+        // Source: vault without duress configured.163+        val srcStorage = MemStorage()164+        val src = VaultRepository(srcStorage)165+        src.createVault(mainPassword)166+        src.addEntry(VaultEntry(id = "", title = "OnlyReal", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))167+168+        // Build a browser Codex with "b" as null.169+        val androidBlob = src.codexVaultBlobBase64()!!170+        val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)171+        val parsed = Json.parseToJsonElement(wrapper).asJsonObject()172+        val slotAJson = parsed["a"].toString()173+        val slotA = VaultCodec.decodeSlot(slotAJson)!!174+        val browserA = androidSlotToBrowserJson(slotA)175+        val browserWrapper = buildJsonObject {176+            put("a", JsonPrimitive(browserA))177+            put("b", kotlinx.serialization.json.JsonNull)178+        }.toString()179+        val codex = Base64.getUrlEncoder().withoutPadding()180+            .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))181+182+        // Restore.183+        val dstStorage = MemStorage()184+        val dst = VaultRepository(dstStorage)185+        assertEquals(UnlockResult.Success, dst.restoreFromCodex(codex, mainPassword))186+187+        // Fake duress slot exists and is size-matched to slot A.188+        assertNotNull("fake duress slot written", dstStorage.decoy)189+        val slotABlobLen = Base64.getDecoder().decode(dstStorage.slotA!!.blob).size190+        val duressBlobLen = Base64.getDecoder().decode(dstStorage.decoy!!.blob).size191+        assertEquals("fake duress blob is size-matched to slot A", slotABlobLen, duressBlobLen)192+193+        // Duress unlock fails (it's a fake).194+        dst.lock()195+        assertNull("fake duress cannot be unlocked", dst.tryUnlockDuress(mainPassword))196+        assertNull("fake duress cannot be unlocked with any password", dst.tryUnlockDuress("anything"))197+    }198+199+    /**200+     * Android-to-Android Codex round-trip with duress: the "b" slot is in201+     * Android format (StoredVault JSON) and should pass through VaultCodec.decodeSlot202+     * without needing the browser passthrough path.203+     */204+    @Test fun android_codex_with_duress_round_trips() = runBlocking {205+        val srcStorage = MemStorage()206+        val src = VaultRepository(srcStorage)207+        src.createVault(mainPassword)208+        src.addEntry(VaultEntry(id = "", title = "AndroidOnly", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))209+        assertTrue(src.setDuressPassphrase(duressPassword))210+211+        val codex = src.codexVaultBlobBase64()!!212+213+        val dstStorage = MemStorage()214+        val dst = VaultRepository(dstStorage)215+        assertEquals(UnlockResult.Success, dst.restoreFromCodex(codex, mainPassword))216+        assertTrue(dst.getEntries().any { it.title == "AndroidOnly" })217+218+        dst.lock()219+        val duress = dst.tryUnlockDuress(duressPassword)220+        assertNotNull("Android-to-Android duress round-trip works", duress)221+        assertEquals(UnlockResult.Success, dst.commitDuressUnlock(duress!!))222+        assertEquals(0, dst.getEntries().size)223+    }224+}

view file