DuressCodexRoundTripTest.kt

10.7 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import kotlinx.serialization.json.Json5import kotlinx.serialization.json.JsonPrimitive6import kotlinx.serialization.json.buildJsonObject7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertNotEquals10import org.junit.Assert.assertNotNull11import org.junit.Assert.assertNull12import org.junit.Assert.assertTrue13import org.junit.Test14import java.util.Base641516/**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 */23class DuressCodexRoundTripTest {2425    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(): Boolean { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true }49    }5051    private val mainPassword = "correct-horse-battery-staple"52    private val duressPassword = "open-the-decoy-vault-456"53    private val wrongPassword = "totally-wrong-password"5455    private fun hex(bytes: ByteArray): String =56        bytes.joinToString("") { "%02x".format(it) }5758    /**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    }8283    /**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    }105106    private fun kotlinx.serialization.json.JsonElement.asJsonObject() =107        this as kotlinx.serialization.json.JsonObject108109    /**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))126127        // ── Build browser-shaped Codex ─────────────────────────────────────128        val browserCodex = buildBrowserCodex(src)129130        // ── Device B: restore from browser Codex ──────────────────────────131        val dstStorage = MemStorage()132        val dst = VaultRepository(dstStorage)133134        // (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" })138139        // Lock so we can test duress unlock independently.140        dst.lock()141142        // (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)148149        // (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    }156157    /**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))167168        // 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))181182        // Restore.183        val dstStorage = MemStorage()184        val dst = VaultRepository(dstStorage)185        assertEquals(UnlockResult.Success, dst.restoreFromCodex(codex, mainPassword))186187        // 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)192193        // 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    }198199    /**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))210211        val codex = src.codexVaultBlobBase64()!!212213        val dstStorage = MemStorage()214        val dst = VaultRepository(dstStorage)215        assertEquals(UnlockResult.Success, dst.restoreFromCodex(codex, mainPassword))216        assertTrue(dst.getEntries().any { it.title == "AndroidOnly" })217218        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}