DuressSlotBTest.kt

15.9 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import kotlinx.serialization.json.JsonPrimitive5import kotlinx.serialization.json.buildJsonObject6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotEquals9import org.junit.Assert.assertNotNull10import org.junit.Assert.assertNull11import org.junit.Assert.assertTrue12import org.junit.Test13import java.util.Base641415/** In-memory [VaultStorage] including a decoy (slot B) slot, for the MF-6 duress model. */16private class DuressFakeStorage : VaultStorage {17    var failWriteDecoy = false18    var slotA: StoredVault? = null19    var slotB: String? = null20    private var ver: Long = 021    private var settings = VaultSettings()22    override fun loadDecoySlot() = decoy23    override fun loadSlotA() = slotA24    override fun hasVault() = slotA != null25    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }26    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }27    var decoy: StoredVault? = null28    override fun writeDecoySlot(slot: StoredVault): Boolean {29        if (failWriteDecoy) return false30        decoy = slot31        return true32    }33    override fun clearDecoySlot() { decoy = null }34    override fun version() = ver35    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }36    override fun loadSettings() = settings37    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }38    var history = VaultHistory()39    override fun loadVaultHistory() = history40    override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }41    override fun clearVault(): Boolean { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true }42}4344/**45 * MF-6: the duress passphrase keys a real second vault (slot B), sealed exactly46 * like slot A. At the lock screen, both slots are tried and the outcome is47 * invisible from outside — no sentinel, no separate hash.48 */49class DuressSlotBTest {50    private val real = "the-real-password-123"51    private val duressPass = "open-the-decoy-456"5253    private fun configuredRepo(): VaultRepository = runBlocking {54        val repo = VaultRepository(DuressFakeStorage())55        repo.createVault(real)56        repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))57        assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass))58        repo.lock()59        repo60    }6162    @Test fun real_password_opens_slot_a_only() = runBlocking {63        val repo = configuredRepo()64        assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real))65        assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real))66    }6768    @Test fun duress_passphrase_opens_decoy_only() = runBlocking {69        val repo = configuredRepo()70        assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass))71        val duress = repo.tryUnlockDuress(duressPass)72        assertNotNull("duress passphrase opens the duress slot", duress)73        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))74        assertTrue(repo.isUnlocked.value)75        assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)76    }7778    @Test fun sibling_duress_tokens_cannot_replace_the_adopted_decoy_session() = runBlocking {79        val repo = configuredRepo()80        val first = repo.tryUnlockDuress(duressPass)!!81        val sibling = repo.tryUnlockDuress(duressPass)!!82        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(first))83        assertEquals(UnlockResult.Error("stale_unlock"), repo.commitDuressUnlock(sibling))84        assertTrue(repo.isUnlocked.value)85    }8687    @Test fun wrong_password_opens_neither() = runBlocking {88        val repo = configuredRepo()89        assertNull(repo.tryUnlockSlotA("totally-wrong"))90        assertNull(repo.tryUnlockDuress("totally-wrong"))91    }92    @Test fun legacy_slot_a_decoy_unlocks_and_migrates_to_slot_b_aad() = runBlocking {93        val storage = DuressFakeStorage()94        val repo = VaultRepository(storage)95        repo.createVault(real)96        assertTrue(repo.setDuressPassphrase(duressPass))97        val current = storage.decoy!!98        val key = VaultCrypto.deriveKey(duressPass, Base64.getDecoder().decode(current.salt))99        val plaintext = VaultCrypto.decrypt(Base64.getDecoder().decode(current.blob), key, VaultCrypto.AAD_SLOT_B)100        val legacyBlob = try {101            VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)102        } finally {103            VaultCrypto.zero(plaintext)104            VaultCrypto.zero(key)105        }106        storage.decoy = current.copy(version = LEGACY_ANDROID_SLOT_VERSION, blob = Base64.getEncoder().encodeToString(legacyBlob))107        assertNotNull(repo.tryUnlockDuress(duressPass))108        assertEquals(CURRENT_ANDROID_SLOT_VERSION, storage.decoy!!.version)109        val migratedKey = VaultCrypto.deriveKey(duressPass, Base64.getDecoder().decode(storage.decoy!!.salt))110        assertNotNull(VaultCrypto.decrypt(Base64.getDecoder().decode(storage.decoy!!.blob), migratedKey, VaultCrypto.AAD_SLOT_B))111        VaultCrypto.zero(migratedKey)112    }113114    @Test fun legacy_migration_write_failure_preserves_legacy_record() = runBlocking {115        val storage = DuressFakeStorage()116        val repo = VaultRepository(storage)117        repo.createVault(real)118        assertTrue(repo.setDuressPassphrase(duressPass))119        val current = storage.decoy!!120        val key = VaultCrypto.deriveKey(duressPass, Base64.getDecoder().decode(current.salt))121        val plaintext = VaultCrypto.decrypt(Base64.getDecoder().decode(current.blob), key, VaultCrypto.AAD_SLOT_B)122        val legacyBlob = try {123            VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)124        } finally {125            VaultCrypto.zero(plaintext)126            VaultCrypto.zero(key)127        }128        val legacy = current.copy(129            version = LEGACY_ANDROID_SLOT_VERSION,130            blob = Base64.getEncoder().encodeToString(legacyBlob),131        )132        storage.decoy = legacy133        storage.failWriteDecoy = true134        assertNotNull(repo.tryUnlockDuress(duressPass))135        assertEquals(legacy, storage.decoy)136    }137138    @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {139        val repo = VaultRepository(DuressFakeStorage())140        repo.createVault(real)141        assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))142    }143144    @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {145        // H-04: presence-indistinguishable — clearDuress no longer removes the146        // duress record, it replaces it with a fake blob the duress passphrase147        // can't open. The at-rest slot pair keeps the same shape.148        val storage = DuressFakeStorage()149        val repo = VaultRepository(storage)150        repo.createVault(real)151        repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))152        assertTrue(repo.setDuressPassphrase(duressPass))153        assertEquals(UnlockResult.Success, repo.unlock(real))154        val slotALen = storage.slotA!!.blob.length155        val decoyBefore = storage.decoy!!156        assertEquals(slotALen, decoyBefore.blob.length)157        assertTrue(repo.clearDuress())158        assertNotNull("duress record still present after clear", storage.decoy)159        // The fake has the same blob length as slot A — forensic size tell closed.160        assertEquals(slotALen, storage.decoy!!.blob.length)161        // …and the duress passphrase no longer opens it.162        repo.lock()163        assertNull(repo.tryUnlockDuress(duressPass))164    }165166    @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {167        val repo = configuredRepo()168169        // Baseline: the real vault does have a sync identity.170        assertNotNull(repo.tryUnlockSlotA(real))171        assertEquals(UnlockResult.Success, repo.unlock(real))172        val realVaultId = repo.syncVaultId()173        assertNotNull("the real vault syncs", realVaultId)174        repo.lock()175176        // The decoy session must expose neither a sync key nor a vault id.177        // A second vault_id would be a duress indicator: a coerced user opening178        // the decoy would make sync visibly fail on their other devices.179        val duress = repo.tryUnlockDuress(duressPass)180        assertNotNull(duress)181        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))182        assertTrue(repo.isUnlocked.value)183        assertNull("the duress vault must expose no sync key", repo.syncMasterKey())184        assertNull("the duress vault must expose no vault id", repo.syncVaultId())185186        // Every sync adapter operation therefore fails closed rather than187        // syncing, manifesting, or revealing an entry count.188        val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")189        var threw = false190        try {191            adapter.computeVaultId()192        } catch (_: Exception) {193            threw = true194        }195        assertTrue("the decoy vault must not produce a vault id for sync", threw)196    }197198    // ---- H-04: duress storage forensics ----------------------------------199200    /**201     * H-04: the duress slot's stored blob must match slot A's stored blob202     * length on disk — both when a real decoy is configured (padded at203     * setDuressPassphrase) and when no decoy is configured (a fake of slot A's204     * size is left behind so the duress record's PRESENCE on disk is205     * independent of duressConfigured).206     */207    @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {208        // Empty vault, no duress configured.209        val storage = DuressFakeStorage()210        val repo = VaultRepository(storage)211        repo.createVault(real)212        val slotALen = storage.slotA!!.blob.length213        assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)214        assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)215216        // With a real duress configured — blob length is still matched.217        assertTrue(repo.setDuressPassphrase(duressPass))218        assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)219220        // Slot A grows (entries added): the duress record is RE-PADDED on221        // every persist (the real decoy can't be re-encrypted without its222        // passphrase, so it's left alone — see the setDuressPassphrase223        // ponytail note for the residual tell). A fake duress IS re-padded224        // so it stays indistinguishable from the noise slot.225        repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))226        repo.clearDuress()227        val newSlotALen = storage.slotA!!.blob.length228        assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)229    }230231    /**232     * H-04: a snapshot must capture the duress slot, not just the noise slot.233     * Without this, restoring from history overwrites slot A (the real vault)234     * while the duress slot stays on whatever the previous state was — a235     * forensic tell across restores.236     */237    @Test fun h04_snapshot_contains_duress() = runBlocking {238        val storage = DuressFakeStorage()239        val repo = VaultRepository(storage)240        repo.createVault(real)241        val initialDuress = storage.decoy242        assertNotNull(initialDuress)243        // The first snapshot is taken on every persist — grab it.244        repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))245        val snap = storage.history.snapshots.first()246        assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)247        assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)248        // Restoring it writes the captured duress back to disk (atomic).249        assertTrue(repo.restoreVaultSnapshot(snap.id))250        assertNotNull(storage.decoy)251        assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)252    }253254    /**255     * H-04: a Codex restore with a browser-shaped "b" (the browser's duress256     * slot, written there because on the browser slot B IS the duress slot)257     * must land in Android's duress slot, not the noise slot. Writing it to258     * the noise slot would mean the duress record disappears on every Codex259     * restore from a paired browser — another forensic tell.260     */261    @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {262        // Source vault: a real Android duress exists.263        val srcStorage = DuressFakeStorage()264        val src = VaultRepository(srcStorage)265        src.createVault(real)266        src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))267        assertTrue(src.setDuressPassphrase(duressPass))268        val srcDuress = srcStorage.decoy!!269270        // Build a browser-shaped Codex: "b" holds the duress slot as a JSON271        // STRING (the browser writes each slot as a string, not an object),272        // matching the browser's A/B naming where B is the duress slot.273        val androidBlob = src.codexVaultBlobBase64()!!274        val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)275        // H-04: codex now carries "a" and "b" as siblings — match the first276        // balanced `{…}` after each key, not the wrapper's last brace.277        val slotAJson = balancedBraceAfter(wrapper, "\"a\"")278        val slotDJson = balancedBraceAfter(wrapper, "\"b\"")279        val browserWrapper = buildJsonObject {280            put("a", JsonPrimitive(slotAJson))281            put("b", JsonPrimitive(slotDJson))282        }.toString()283        val browserCodex = Base64.getUrlEncoder().withoutPadding()284            .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))285286        // Restore into a fresh device that already has a vault.287        val dstStorage: DuressFakeStorage = DuressFakeStorage()288        val dst: VaultRepository = VaultRepository(dstStorage)289        dst.createVault(real)290291        assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))292293        // The duress slot now carries the Codex's "b" — not a fake, not noise.294        assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)295        assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)296        // The noise slot is rewritten to match slot A's size (H-04 size-match297        // invariant) — assert the cross-contamination invariant: noise and298        // duress slots are different.299        assertNotEquals("noise slot is not the duress slot", dstStorage.slotB, dstStorage.decoy?.blob)300    }301302    /**303     * Match the first balanced `{…}` whose opening `{` follows the given key304     * literal. Used after H-04 to extract slot A or B from a wrapper that now305     * carries both slots side by side (regex `\{.*\}` would match through to306     * the wrapper's last brace).307     */308    private fun balancedBraceAfter(wrapper: String, keyLiteral: String): String {309        val openMarker = "${Regex.escape(keyLiteral)}\\s*:\\s*\\{".toRegex().find(wrapper)310            ?: error("key $keyLiteral not found")311        val open = openMarker.range.last312        var depth = 1313        var end = open + 1314        while (end < wrapper.length && depth > 0) {315            when (wrapper[end]) {316                '{' -> depth++317                '}' -> depth--318            }319            if (depth == 0) break320            end++321        }322        return wrapper.substring(open, end + 1)323    }324}