package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** In-memory [VaultStorage] including a decoy (slot B) slot, for the MF-6 duress model. */ private class DuressFakeStorage : VaultStorage { private var slotA: StoredVault? = null private var slotB: String? = null private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true } override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } private var decoy: StoredVault? = null override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() } } /** * MF-6: the duress passphrase keys a real second vault (slot B), sealed exactly * like slot A. At the lock screen, both slots are tried and the outcome is * invisible from outside — no sentinel, no separate hash. */ class DuressSlotBTest { private val real = "the-real-password-123" private val duressPass = "open-the-decoy-456" private fun configuredRepo(): VaultRepository = runBlocking { val repo = VaultRepository(DuressFakeStorage()) repo.createVault(real) repo.addEntry(VaultEntry(id = "", title = "Secret", type = "login", password = "pw", createdAt = 0, modifiedAt = 0)) assertTrue("duress passphrase sealed into slot B", repo.setDuressPassphrase(duressPass)) repo.lock() repo } @Test fun real_password_opens_slot_a_only() = runBlocking { val repo = configuredRepo() assertNotNull("real password opens slot A", repo.tryUnlockSlotA(real)) assertNull("real password does NOT open the duress slot", repo.tryUnlockDuress(real)) } @Test fun duress_passphrase_opens_decoy_only() = runBlocking { val repo = configuredRepo() assertNull("duress passphrase does NOT open slot A", repo.tryUnlockSlotA(duressPass)) val duress = repo.tryUnlockDuress(duressPass) assertNotNull("duress passphrase opens the duress slot", duress) assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!)) assertTrue(repo.isUnlocked.value) assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size) } @Test fun wrong_password_opens_neither() = runBlocking { val repo = configuredRepo() assertNull(repo.tryUnlockSlotA("totally-wrong")) assertNull(repo.tryUnlockDuress("totally-wrong")) } @Test fun duress_passphrase_cannot_equal_real_password() = runBlocking { val repo = VaultRepository(DuressFakeStorage()) repo.createVault(real) assertFalse("a duress passphrase matching the real password is rejected", repo.setDuressPassphrase(real)) } @Test fun clear_duress_removes_the_slot() = runBlocking { val repo = configuredRepo() assertEquals(UnlockResult.Success, repo.unlock(real)) assertTrue(repo.clearDuress()) repo.lock() assertNull("duress slot gone after clear", repo.tryUnlockDuress(duressPass)) } @Test fun `HOLE 5 the duress vault has no sync identity and cannot sync`() = runBlocking { val repo = configuredRepo() // Baseline: the real vault does have a sync identity. assertNotNull(repo.tryUnlockSlotA(real)) assertEquals(UnlockResult.Success, repo.unlock(real)) val realVaultId = repo.syncVaultId() assertNotNull("the real vault syncs", realVaultId) repo.lock() // The decoy session must expose neither a sync key nor a vault id. // A second vault_id would be a duress indicator: a coerced user opening // the decoy would make sync visibly fail on their other devices. val duress = repo.tryUnlockDuress(duressPass) assertNotNull(duress) assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress!!)) assertTrue(repo.isUnlocked.value) assertNull("the duress vault must expose no sync key", repo.syncMasterKey()) assertNull("the duress vault must expose no vault id", repo.syncVaultId()) // Every sync adapter operation therefore fails closed rather than // syncing, manifesting, or revealing an entry count. val adapter = com.peachpasswords.sync.VaultSyncAdapter(repo, ourDeviceId = "decoy-device") var threw = false try { adapter.computeVaultId() } catch (_: Exception) { threw = true } assertTrue("the decoy vault must not produce a vault id for sync", threw) } }