SecurityPropertiesTest.kt

6.8 KB · raw

1package com.peachpasswords.security23import com.peachpasswords.vault.StoredVault4import com.peachpasswords.vault.UnlockResult5import com.peachpasswords.vault.VaultEntry6import com.peachpasswords.vault.VaultRepository7import com.peachpasswords.vault.VaultSettings8import com.peachpasswords.vault.VaultStorage9import kotlinx.coroutines.runBlocking10import org.junit.Assert.assertEquals11import org.junit.Assert.assertFalse12import org.junit.Assert.assertNull13import org.junit.Assert.assertTrue14import org.junit.Test1516/**17 * Security-property suite (brief Suite 10): the repository-observable invariants —18 * key material is gone after lock, the duress passphrase opens a decoy (never the19 * real vault), and panic/destroy is irreversible. FLAG_SECURE and clipboard-clear20 * timing are UI/instrumented concerns covered elsewhere.21 */22class SecurityPropertiesTest {2324    private class Storage : VaultStorage {25        var slotA: StoredVault? = null26        var slotB: String? = null27        private var ver: Long = 028        private var settings = VaultSettings()29        override fun loadSlotA() = slotA30        override fun hasVault() = slotA != null31        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }32        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }33        var decoy: StoredVault? = null34        override fun loadDecoySlot() = decoy35        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }36        override fun hasDecoySlot() = decoy != null37        override fun clearDecoySlot() { decoy = null }38        override fun version() = ver39        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }40        override fun loadSettings() = settings41        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }42        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }43    }4445    private fun repo() = VaultRepository(Storage()).also { runBlocking { it.createVault("real-master-pass") } }46    private fun login(t: String) = VaultEntry(id = "", title = t, type = "login", password = "p", createdAt = 0, modifiedAt = 0)4748    // ── Key material cleared after lock ───────────────────────────────────────4950    @Test fun vault_key_is_null_after_lock() {51        val r = repo()52        assertTrue(r.currentVaultKey() != null)53        r.lock()54        assertNull(r.currentVaultKey())55        assertNull(r.syncMasterKey())56        assertFalse(r.isUnlocked.value)57    }5859    @Test fun entries_are_cleared_from_memory_after_lock() = runBlocking {60        val r = repo()61        r.addEntry(login("Secret"))62        r.lock()63        assertTrue(r.getEntries().isEmpty())64    }6566    // ── Duress isolation ──────────────────────────────────────────────────────6768    @Test fun duress_passphrase_opens_decoy_not_real_vault() = runBlocking {69        val r = repo()70        val real = r.addEntry(login("RealAccount"))71        r.setDuressPassphrase("duress-pass-xyz")72        r.lock()7374        val duress = r.tryUnlockDuress("duress-pass-xyz")!!75        assertEquals(UnlockResult.Success, r.commitDuressUnlock(duress))76        // The decoy session never exposes the real entry.77        assertFalse(r.getEntries().any { it.id == real.id })78        assertTrue(r.getEntries().isEmpty())79    }8081    @Test fun real_password_does_not_match_duress_slot() = runBlocking {82        val r = repo()83        r.setDuressPassphrase("duress-pass-xyz")84        r.lock()85        // The real password must not decrypt the duress slot.86        assertNull(r.tryUnlockDuress("real-master-pass"))87    }8889    @Test fun duress_passphrase_does_not_open_real_slot() = runBlocking {90        val r = repo()91        r.setDuressPassphrase("duress-pass-xyz")92        r.lock()93        assertNull(r.tryUnlockSlotA("duress-pass-xyz"))94    }9596    // ── Panic / destroy is irreversible ───────────────────────────────────────9798    @Test fun destroy_wipes_vault_and_blocks_old_password() = runBlocking {99        val storage = Storage()100        val r = VaultRepository(storage)101        r.createVault("real-master-pass")102        r.addEntry(login("Gone"))103        r.destroyVault()104        assertFalse(r.hasVault())105        assertNull(storage.slotA)106        assertNull(storage.decoy)107        assertEquals(UnlockResult.Error("no_vault"), r.unlock("real-master-pass"))108    }109110    @Test fun destroy_also_clears_the_decoy_slot() = runBlocking {111        val storage = Storage()112        val r = VaultRepository(storage)113        r.createVault("real-master-pass")114        r.setDuressPassphrase("duress-pass-xyz")115        assertTrue(storage.decoy != null)116        r.destroyVault()117        assertNull(storage.decoy)118    }119120    // ── Recovery is not offered without an escrow ─────────────────────────────121122    @Test fun recovery_not_available_without_escrow() = runBlocking {123        val r = repo()124        r.lock()125        assertFalse(r.hasRecoveryEscrow())126    }127128    // ── PB-26-003: destruction surfaces deletion failures ───────────────────129130    private class FailingClearStorage : VaultStorage {131        var slotA: StoredVault? = null132        var slotB: String? = null133        var decoy: StoredVault? = null134        private var ver: Long = 0135        private var settings = VaultSettings()136        override fun loadSlotA() = slotA137        override fun hasVault() = slotA != null138        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }139        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }140        override fun loadDecoySlot() = decoy141        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }142        override fun hasDecoySlot() = decoy != null143        override fun clearDecoySlot() { decoy = null }144        override fun version() = ver145        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }146        override fun loadSettings() = settings147        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }148        override fun clearVault(): Boolean { return false }149    }150151    @Test fun destroy_vault_returns_false_when_clear_vault_fails() = runBlocking {152        val storage = FailingClearStorage()153        val r = VaultRepository(storage)154        r.createVault("real-master-pass")155        assertFalse("destroy must surface the wipe failure", r.destroyVault())156    }157}