package com.peachpasswords.security import com.peachpasswords.vault.StoredVault import com.peachpasswords.vault.UnlockResult import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.VaultSettings import com.peachpasswords.vault.VaultStorage import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Security-property suite (brief Suite 10): the repository-observable invariants — * key material is gone after lock, the duress passphrase opens a decoy (never the * real vault), and panic/destroy is irreversible. FLAG_SECURE and clipboard-clear * timing are UI/instrumented concerns covered elsewhere. */ class SecurityPropertiesTest { private class Storage : VaultStorage { var slotA: StoredVault? = null 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 } 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(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true } } private fun repo() = VaultRepository(Storage()).also { runBlocking { it.createVault("real-master-pass") } } private fun login(t: String) = VaultEntry(id = "", title = t, type = "login", password = "p", createdAt = 0, modifiedAt = 0) // ── Key material cleared after lock ─────────────────────────────────────── @Test fun vault_key_is_null_after_lock() { val r = repo() assertTrue(r.currentVaultKey() != null) r.lock() assertNull(r.currentVaultKey()) assertNull(r.syncMasterKey()) assertFalse(r.isUnlocked.value) } @Test fun entries_are_cleared_from_memory_after_lock() = runBlocking { val r = repo() r.addEntry(login("Secret")) r.lock() assertTrue(r.getEntries().isEmpty()) } // ── Duress isolation ────────────────────────────────────────────────────── @Test fun duress_passphrase_opens_decoy_not_real_vault() = runBlocking { val r = repo() val real = r.addEntry(login("RealAccount")) r.setDuressPassphrase("duress-pass-xyz") r.lock() val duress = r.tryUnlockDuress("duress-pass-xyz")!! assertEquals(UnlockResult.Success, r.commitDuressUnlock(duress)) // The decoy session never exposes the real entry. assertFalse(r.getEntries().any { it.id == real.id }) assertTrue(r.getEntries().isEmpty()) } @Test fun real_password_does_not_match_duress_slot() = runBlocking { val r = repo() r.setDuressPassphrase("duress-pass-xyz") r.lock() // The real password must not decrypt the duress slot. assertNull(r.tryUnlockDuress("real-master-pass")) } @Test fun duress_passphrase_does_not_open_real_slot() = runBlocking { val r = repo() r.setDuressPassphrase("duress-pass-xyz") r.lock() assertNull(r.tryUnlockSlotA("duress-pass-xyz")) } // ── Panic / destroy is irreversible ─────────────────────────────────────── @Test fun destroy_wipes_vault_and_blocks_old_password() = runBlocking { val storage = Storage() val r = VaultRepository(storage) r.createVault("real-master-pass") r.addEntry(login("Gone")) r.destroyVault() assertFalse(r.hasVault()) assertNull(storage.slotA) assertNull(storage.decoy) assertEquals(UnlockResult.Error("no_vault"), r.unlock("real-master-pass")) } @Test fun destroy_also_clears_the_decoy_slot() = runBlocking { val storage = Storage() val r = VaultRepository(storage) r.createVault("real-master-pass") r.setDuressPassphrase("duress-pass-xyz") assertTrue(storage.decoy != null) r.destroyVault() assertNull(storage.decoy) } // ── Recovery is not offered without an escrow ───────────────────────────── @Test fun recovery_not_available_without_escrow() = runBlocking { val r = repo() r.lock() assertFalse(r.hasRecoveryEscrow()) } // ── PB-26-003: destruction surfaces deletion failures ─────────────────── private class FailingClearStorage : VaultStorage { var slotA: StoredVault? = null var slotB: String? = null var decoy: StoredVault? = 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 } 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(): Boolean { return false } } @Test fun destroy_vault_returns_false_when_clear_vault_fails() = runBlocking { val storage = FailingClearStorage() val r = VaultRepository(storage) r.createVault("real-master-pass") assertFalse("destroy must surface the wipe failure", r.destroyVault()) } }