DuressSlotBTest.kt

5.3 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNotNull7import org.junit.Assert.assertNull8import org.junit.Assert.assertTrue9import org.junit.Test1011/** In-memory [VaultStorage] including a decoy (slot B) slot, for the MF-6 duress model. */12private class DuressFakeStorage : VaultStorage {13    private var slotA: StoredVault? = null14    private var slotB: String? = null15    private var ver: Long = 016    private var settings = VaultSettings()17    override fun loadSlotA() = slotA18    override fun hasVault() = slotA != null19    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }20    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }21    private var decoy: StoredVault? = null22    override fun loadDecoySlot() = decoy23    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }24    override fun hasDecoySlot() = decoy != null25    override fun clearDecoySlot() { decoy = null }26    override fun version() = ver27    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }28    override fun loadSettings() = settings29    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }30    override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }31}3233/**34 * MF-6: the duress passphrase keys a real second vault (slot B), sealed exactly35 * like slot A. At the lock screen, both slots are tried and the outcome is36 * invisible from outside — no sentinel, no separate hash.37 */38class DuressSlotBTest {39    private val real = "the-real-password-123"40    private val duressPass = "open-the-decoy-456"4142    private fun configuredRepo(): VaultRepository = runBlocking {43        val repo = VaultRepository(DuressFakeStorage())44        repo.createVault(real)45        repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))46        assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass))47        repo.lock()48        repo49    }5051    @Test fun real_password_opens_slot_a_only() = runBlocking {52        val repo = configuredRepo()53        assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real))54        assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real))55    }5657    @Test fun duress_passphrase_opens_decoy_only() = runBlocking {58        val repo = configuredRepo()59        assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass))60        val duress = repo.tryUnlockDuress(duressPass)61        assertNotNull("duress passphrase opens the duress slot", duress)62        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))63        assertTrue(repo.isUnlocked.value)64        assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)65    }6667    @Test fun wrong_password_opens_neither() = runBlocking {68        val repo = configuredRepo()69        assertNull(repo.tryUnlockSlotA("totally-wrong"))70        assertNull(repo.tryUnlockDuress("totally-wrong"))71    }7273    @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking {74        val repo = VaultRepository(DuressFakeStorage())75        repo.createVault(real)76        assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real))77    }7879    @Test fun clear_duress_removes_the_slot() = runBlocking {80        val repo = configuredRepo()81        assertEquals(UnlockResult.Success, repo.unlock(real))82        assertTrue(repo.clearDuress())83        repo.lock()84        assertNull("duress slot gone after clear", repo.tryUnlockDuress(duressPass))85    }8687    @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking {88        val repo = configuredRepo()8990        // Baseline: the real vault does have a sync identity.91        assertNotNull(repo.tryUnlockSlotA(real))92        assertEquals(UnlockResult.Success, repo.unlock(real))93        val realVaultId = repo.syncVaultId()94        assertNotNull("the real vault syncs", realVaultId)95        repo.lock()9697        // The decoy session must expose neither a sync key nor a vault id.98        // A second vault_id would be a duress indicator: a coerced user opening99        // the decoy would make sync visibly fail on their other devices.100        val duress = repo.tryUnlockDuress(duressPass)101        assertNotNull(duress)102        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!))103        assertTrue(repo.isUnlocked.value)104        assertNull("the duress vault must expose no sync key", repo.syncMasterKey())105        assertNull("the duress vault must expose no vault id", repo.syncVaultId())106107        // Every sync adapter operation therefore fails closed rather than108        // syncing, manifesting, or revealing an entry count.109        val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device")110        var threw = false111        try {112            adapter.computeVaultId()113        } catch (_: Exception) {114            threw = true115        }116        assertTrue("the decoy vault must not produce a vault id for sync", threw)117    }118}