DuressSlotBTest.kt

13.1 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 slotA: StoredVault? = null18    var slotB: String? = null19    private var ver: Long = 020    private var settings = VaultSettings()21    override fun loadSlotA() = slotA22    override fun hasVault() = slotA != null23    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }24    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }25    var decoy: StoredVault? = null26    override fun loadDecoySlot() = decoy27    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }28    override fun hasDecoySlot() = decoy != null29    override fun clearDecoySlot() { decoy = null }30    override fun version() = ver31    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }32    override fun loadSettings() = settings33    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }34    var history = VaultHistory()35    override fun loadVaultHistory() = history36    override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }37    override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }38}3940/**41 * MF-6: the duress passphrase keys a real second vault (slot B), sealed exactly42 * like slot A. At the lock screen, both slots are tried and the outcome is43 * invisible from outside — no sentinel, no separate hash.44 */45class DuressSlotBTest {46    private val real = "the-real-password-123"47    private val duressPass = "open-the-decoy-456"4849    private fun configuredRepo(): VaultRepository = runBlocking {50        val repo = VaultRepository(DuressFakeStorage())51        repo.createVault(real)52        repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))53        assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass))54        repo.lock()55        repo56    }5758    @Test fun real_password_opens_slot_a_only() = runBlocking {59        val repo = configuredRepo()60        assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real))61        assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real))62    }6364    @Test fun duress_passphrase_opens_decoy_only() = runBlocking {65        val repo = configuredRepo()66        assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass))67        val duress = repo.tryUnlockDuress(duressPass)68        assertNotNull("duress passphrase opens the duress slot", duress)69        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))70        assertTrue(repo.isUnlocked.value)71        assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)72    }7374    @Test fun wrong_password_opens_neither() = runBlocking {75        val repo = configuredRepo()76        assertNull(repo.tryUnlockSlotA("totally-wrong"))77        assertNull(repo.tryUnlockDuress("totally-wrong"))78    }7980    @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {81        val repo = VaultRepository(DuressFakeStorage())82        repo.createVault(real)83        assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))84    }8586    @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {87        // H-04: presence-indistinguishable — clearDuress no longer removes the88        // duress record, it replaces it with a fake blob the duress passphrase89        // can't open. The at-rest slot pair keeps the same shape.90        val storage = DuressFakeStorage()91        val repo = VaultRepository(storage)92        repo.createVault(real)93        repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))94        assertTrue(repo.setDuressPassphrase(duressPass))95        assertEquals(UnlockResult.Success, repo.unlock(real))96        val slotALen = storage.slotA!!.blob.length97        val decoyBefore = storage.decoy!!98        assertEquals(slotALen, decoyBefore.blob.length)99        assertTrue(repo.clearDuress())100        assertNotNull("duress record still present after clear", storage.decoy)101        // The fake has the same blob length as slot A — forensic size tell closed.102        assertEquals(slotALen, storage.decoy!!.blob.length)103        // …and the duress passphrase no longer opens it.104        repo.lock()105        assertNull(repo.tryUnlockDuress(duressPass))106    }107108    @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {109        val repo = configuredRepo()110111        // Baseline: the real vault does have a sync identity.112        assertNotNull(repo.tryUnlockSlotA(real))113        assertEquals(UnlockResult.Success, repo.unlock(real))114        val realVaultId = repo.syncVaultId()115        assertNotNull("the real vault syncs", realVaultId)116        repo.lock()117118        // The decoy session must expose neither a sync key nor a vault id.119        // A second vault_id would be a duress indicator: a coerced user opening120        // the decoy would make sync visibly fail on their other devices.121        val duress = repo.tryUnlockDuress(duressPass)122        assertNotNull(duress)123        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))124        assertTrue(repo.isUnlocked.value)125        assertNull("the duress vault must expose no sync key", repo.syncMasterKey())126        assertNull("the duress vault must expose no vault id", repo.syncVaultId())127128        // Every sync adapter operation therefore fails closed rather than129        // syncing, manifesting, or revealing an entry count.130        val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")131        var threw = false132        try {133            adapter.computeVaultId()134        } catch (_: Exception) {135            threw = true136        }137        assertTrue("the decoy vault must not produce a vault id for sync", threw)138    }139140    // ---- H-04: duress storage forensics ----------------------------------141142    /**143     * H-04: the duress slot's stored blob must match slot A's stored blob144     * length on disk — both when a real decoy is configured (padded at145     * setDuressPassphrase) and when no decoy is configured (a fake of slot A's146     * size is left behind so the duress record's PRESENCE on disk is147     * independent of duressConfigured).148     */149    @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {150        // Empty vault, no duress configured.151        val storage = DuressFakeStorage()152        val repo = VaultRepository(storage)153        repo.createVault(real)154        val slotALen = storage.slotA!!.blob.length155        assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)156        assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)157158        // With a real duress configured — blob length is still matched.159        assertTrue(repo.setDuressPassphrase(duressPass))160        assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)161162        // Slot A grows (entries added): the duress record is RE-PADDED on163        // every persist (the real decoy can't be re-encrypted without its164        // passphrase, so it's left alone — see the setDuressPassphrase165        // ponytail note for the residual tell). A fake duress IS re-padded166        // so it stays indistinguishable from the noise slot.167        repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))168        repo.clearDuress()169        val newSlotALen = storage.slotA!!.blob.length170        assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)171    }172173    /**174     * H-04: a snapshot must capture the duress slot, not just the noise slot.175     * Without this, restoring from history overwrites slot A (the real vault)176     * while the duress slot stays on whatever the previous state was — a177     * forensic tell across restores.178     */179    @Test fun h04_snapshot_contains_duress() = runBlocking {180        val storage = DuressFakeStorage()181        val repo = VaultRepository(storage)182        repo.createVault(real)183        val initialDuress = storage.decoy184        assertNotNull(initialDuress)185        // The first snapshot is taken on every persist — grab it.186        repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))187        val snap = storage.history.snapshots.first()188        assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)189        assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)190        // Restoring it writes the captured duress back to disk (atomic).191        assertTrue(repo.restoreVaultSnapshot(snap.id))192        assertNotNull(storage.decoy)193        assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)194    }195196    /**197     * H-04: a Codex restore with a browser-shaped "b" (the browser's duress198     * slot, written there because on the browser slot B IS the duress slot)199     * must land in Android's duress slot, not the noise slot. Writing it to200     * the noise slot would mean the duress record disappears on every Codex201     * restore from a paired browser — another forensic tell.202     */203    @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {204        // Source vault: a real Android duress exists.205        val srcStorage = DuressFakeStorage()206        val src = VaultRepository(srcStorage)207        src.createVault(real)208        src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))209        assertTrue(src.setDuressPassphrase(duressPass))210        val srcDuress = srcStorage.decoy!!211212        // Build a browser-shaped Codex: "b" holds the duress slot as a JSON213        // STRING (the browser writes each slot as a string, not an object),214        // matching the browser's A/B naming where B is the duress slot.215        val androidBlob = src.codexVaultBlobBase64()!!216        val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)217        // H-04: codex now carries "a" and "b" as siblings — match the first218        // balanced `{…}` after each key, not the wrapper's last brace.219        val slotAJson = balancedBraceAfter(wrapper, "\"a\"")220        val slotDJson = balancedBraceAfter(wrapper, "\"b\"")221        val browserWrapper = buildJsonObject {222            put("a", JsonPrimitive(slotAJson))223            put("b", JsonPrimitive(slotDJson))224        }.toString()225        val browserCodex = Base64.getUrlEncoder().withoutPadding()226            .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))227228        // Restore into a fresh device that already has a vault.229        val dstStorage: DuressFakeStorage = DuressFakeStorage()230        val dst: VaultRepository = VaultRepository(dstStorage)231        dst.createVault(real)232233        assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))234235        // The duress slot now carries the Codex's "b" — not a fake, not noise.236        assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)237        assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)238        // The noise slot is rewritten to match slot A's size (H-04 size-match239        // invariant) — assert the cross-contamination invariant: noise and240        // duress slots are different.241        assertNotEquals("noise slot is not the duress slot", dstStorage.slotB, dstStorage.decoy?.blob)242    }243244    /**245     * Match the first balanced `{…}` whose opening `{` follows the given key246     * literal. Used after H-04 to extract slot A or B from a wrapper that now247     * carries both slots side by side (regex `\{.*\}` would match through to248     * the wrapper's last brace).249     */250    private fun balancedBraceAfter(wrapper: String, keyLiteral: String): String {251        val openMarker = "${Regex.escape(keyLiteral)}\\s*:\\s*\\{".toRegex().find(wrapper)252            ?: error("key $keyLiteral not found")253        val open = openMarker.range.last254        var depth = 1255        var end = open + 1256        while (end < wrapper.length && depth > 0) {257            when (wrapper[end]) {258                '{' -> depth++259                '}' -> depth--260            }261            if (depth == 0) break262            end++263        }264        return wrapper.substring(open, end + 1)265    }266}