cb62379d810a
fix: Carry browser duress slot across Codex restore instead of dropping it (v2.9.2)
- time
- 2026-07-30 06:52 UTC · 2 months ago
- machine
- koh2
- parent
- d05b98b443d5
↓ peach-android-cb62379d.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+36 −4app/src/test/java/com/peachpasswords/vault/DuressCodexRoundTripTest.kt+224 −0app/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. */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+}