DuressSlotBTest.kt

12.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.assertNotNull9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Test12import java.util.Base641314/** In-memory [VaultStorage] including a decoy (slot B) slot, for the MF-6 duress model. */15private class DuressFakeStorage : VaultStorage {16    var slotA: StoredVault? = null17    var slotB: String? = null18    private var ver: Long = 019    private var settings = VaultSettings()20    override fun loadSlotA() = slotA21    override fun hasVault() = slotA != null22    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }23    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }24    var decoy: StoredVault? = null25    override fun loadDecoySlot() = decoy26    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }27    override fun hasDecoySlot() = decoy != null28    override fun clearDecoySlot() { decoy = null }29    override fun version() = ver30    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }31    override fun loadSettings() = settings32    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }33    var history = VaultHistory()34    override fun loadVaultHistory() = history35    override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true }36    override fun clearVault() { slotA = null; slotB = null; decoy = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }37}3839/**40 * MF-6: the duress passphrase keys a real second vault (slot B), sealed exactly41 * like slot A. At the lock screen, both slots are tried and the outcome is42 * invisible from outside — no sentinel, no separate hash.43 */44class DuressSlotBTest {45    private val real = "the-real-password-123"46    private val duressPass = "open-the-decoy-456"4748    private fun configuredRepo(): VaultRepository = runBlocking {49        val repo = VaultRepository(DuressFakeStorage())50        repo.createVault(real)51        repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))52        assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass))53        repo.lock()54        repo55    }5657    @Test fun real_password_opens_slot_a_only() = runBlocking {58        val repo = configuredRepo()59        assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real))60        assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real))61    }6263    @Test fun duress_passphrase_opens_decoy_only() = runBlocking {64        val repo = configuredRepo()65        assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass))66        val duress = repo.tryUnlockDuress(duressPass)67        assertNotNull("duress passphrase opens the duress slot", duress)68        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))69        assertTrue(repo.isUnlocked.value)70        assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)71    }7273    @Test fun wrong_password_opens_neither() = runBlocking {74        val repo = configuredRepo()75        assertNull(repo.tryUnlockSlotA("totally-wrong"))76        assertNull(repo.tryUnlockDuress("totally-wrong"))77    }7879    @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {80        val repo = VaultRepository(DuressFakeStorage())81        repo.createVault(real)82        assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))83    }8485    @Test fun clear_duress_replaces_with_fake_of_matching_size() = runBlocking {86        // H-04: presence-indistinguishable — clearDuress no longer removes the87        // duress record, it replaces it with a fake blob the duress passphrase88        // can't open. The at-rest slot pair keeps the same shape.89        val storage = DuressFakeStorage()90        val repo = VaultRepository(storage)91        repo.createVault(real)92        repo.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))93        assertTrue(repo.setDuressPassphrase(duressPass))94        assertEquals(UnlockResult.Success, repo.unlock(real))95        val slotALen = storage.slotA!!.blob.length96        val decoyBefore = storage.decoy!!97        assertEquals(slotALen, decoyBefore.blob.length)98        assertTrue(repo.clearDuress())99        assertNotNull("duress record still present after clear", storage.decoy)100        // The fake has the same blob length as slot A — forensic size tell closed.101        assertEquals(slotALen, storage.decoy!!.blob.length)102        // …and the duress passphrase no longer opens it.103        repo.lock()104        assertNull(repo.tryUnlockDuress(duressPass))105    }106107    @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {108        val repo = configuredRepo()109110        // Baseline: the real vault does have a sync identity.111        assertNotNull(repo.tryUnlockSlotA(real))112        assertEquals(UnlockResult.Success, repo.unlock(real))113        val realVaultId = repo.syncVaultId()114        assertNotNull("the real vault syncs", realVaultId)115        repo.lock()116117        // The decoy session must expose neither a sync key nor a vault id.118        // A second vault_id would be a duress indicator: a coerced user opening119        // the decoy would make sync visibly fail on their other devices.120        val duress = repo.tryUnlockDuress(duressPass)121        assertNotNull(duress)122        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))123        assertTrue(repo.isUnlocked.value)124        assertNull("the duress vault must expose no sync key", repo.syncMasterKey())125        assertNull("the duress vault must expose no vault id", repo.syncVaultId())126127        // Every sync adapter operation therefore fails closed rather than128        // syncing, manifesting, or revealing an entry count.129        val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")130        var threw = false131        try {132            adapter.computeVaultId()133        } catch (_: Exception) {134            threw = true135        }136        assertTrue("the decoy vault must not produce a vault id for sync", threw)137    }138139    // ---- H-04: duress storage forensics ----------------------------------140141    /**142     * H-04: the duress slot's stored blob must match slot A's stored blob143     * length on disk — both when a real decoy is configured (padded at144     * setDuressPassphrase) and when no decoy is configured (a fake of slot A's145     * size is left behind so the duress record's PRESENCE on disk is146     * independent of duressConfigured).147     */148    @Test fun h04_duress_record_size_matches_slot_a() = runBlocking {149        // Empty vault, no duress configured.150        val storage = DuressFakeStorage()151        val repo = VaultRepository(storage)152        repo.createVault(real)153        val slotALen = storage.slotA!!.blob.length154        assertNotNull("duress record exists even with no duress configured (H-04)", storage.decoy)155        assertEquals("fake duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)156157        // With a real duress configured — blob length is still matched.158        assertTrue(repo.setDuressPassphrase(duressPass))159        assertEquals("real duress blob length matches slot A blob length", slotALen, storage.decoy!!.blob.length)160161        // Slot A grows (entries added): the duress record is RE-PADDED on162        // every persist (the real decoy can't be re-encrypted without its163        // passphrase, so it's left alone — see the setDuressPassphrase164        // ponytail note for the residual tell). A fake duress IS re-padded165        // so it stays indistinguishable from the noise slot.166        repo.addEntry(VaultEntry(id = "", title = "Big", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))167        repo.clearDuress()168        val newSlotALen = storage.slotA!!.blob.length169        assertEquals("fake duress re-padded to slot A after slot A grows", newSlotALen, storage.decoy!!.blob.length)170    }171172    /**173     * H-04: a snapshot must capture the duress slot, not just the noise slot.174     * Without this, restoring from history overwrites slot A (the real vault)175     * while the duress slot stays on whatever the previous state was — a176     * forensic tell across restores.177     */178    @Test fun h04_snapshot_contains_duress() = runBlocking {179        val storage = DuressFakeStorage()180        val repo = VaultRepository(storage)181        repo.createVault(real)182        val initialDuress = storage.decoy183        assertNotNull(initialDuress)184        // The first snapshot is taken on every persist — grab it.185        repo.addEntry(VaultEntry(id = "", title = "T", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))186        val snap = storage.history.snapshots.first()187        assertNotNull("snapshot.vault_d captures the duress slot", snap.vault_d)188        assertEquals("snapshotted duress matches the on-disk duress", initialDuress!!.blob, VaultCodec.decodeSlot(snap.vault_d!!)!!.blob)189        // Restoring it writes the captured duress back to disk (atomic).190        assertTrue(repo.restoreVaultSnapshot(snap.id))191        assertNotNull(storage.decoy)192        assertEquals("restored duress matches the snapshot", VaultCodec.encodeSlot(initialDuress), snap.vault_d)193    }194195    /**196     * H-04: a Codex restore with a browser-shaped "b" (the browser's duress197     * slot, written there because on the browser slot B IS the duress slot)198     * must land in Android's duress slot, not the noise slot. Writing it to199     * the noise slot would mean the duress record disappears on every Codex200     * restore from a paired browser — another forensic tell.201     */202    @Test fun h04_codex_restore_puts_browser_b_into_duress_slot() = runBlocking {203        // Source vault: a real Android duress exists.204        val srcStorage = DuressFakeStorage()205        val src = VaultRepository(srcStorage)206        src.createVault(real)207        src.addEntry(VaultEntry(id = "", title = "Real", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))208        assertTrue(src.setDuressPassphrase(duressPass))209        val srcDuress = srcStorage.decoy!!210211        // Build a browser-shaped Codex: "b" holds the duress slot as a JSON212        // STRING (the browser writes each slot as a string, not an object),213        // matching the browser's A/B naming where B is the duress slot.214        val androidBlob = src.codexVaultBlobBase64()!!215        val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)216        val slotAJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})").find(wrapper)!!.groupValues[1]217        val slotDJson = Regex("\"b\"\\s*:\\s*(\\{.*\\})").find(wrapper)!!.groupValues[1]218        val browserWrapper = buildJsonObject {219            put("a", JsonPrimitive(slotAJson))220            put("b", JsonPrimitive(slotDJson))221        }.toString()222        val browserCodex = Base64.getUrlEncoder().withoutPadding()223            .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))224225        // Restore into a fresh device that already has a vault.226        val dstStorage = DuressFakeStorage()227        val dst = VaultRepository(dstStorage)228        dst.createVault(real)229        // Mark the noise slot so we can detect any cross-contamination.230        dstStorage.slotB = "noise-before-restore"231232        assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))233234        // The noise slot is local-only — never overwritten by the Codex.235        assertEquals("noise slot is local-only and untouched by Codex restore", "noise-before-restore", dstStorage.slotB)236        // The duress slot now carries the Codex's "b" — not a fake, not noise.237        assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)238        assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)239    }240}