package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test /** * PB-26-126: the PIN and biometric escrows wrap a copy of the slot-A key, so * every rotation site must fire the invalidation signal. Before this, only * changePassword cleared anything — and it cleared the PIN only, leaving a * biometric wrapper the hardware would still accept. */ class VaultKeyRotationHooksTest { private class MemStorage : 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 loadSlotB() = slotB 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 } var history = VaultHistory() override fun loadVaultHistory() = history override fun writeVaultHistory(h: VaultHistory): Boolean { history = h; return true } override fun clearVaultHistory() { history = VaultHistory() } override fun clearVault(): Boolean { slotA = null; slotB = null; decoy = null; ver = 0 settings = VaultSettings(); history = VaultHistory(); return true } } @Test fun every_rotation_site_fires_the_escrow_signal() = runBlocking { val repo = VaultRepository(MemStorage()) var firings = 0 VaultKeyRotationHooks.onRotated = { firings++ } try { assertTrue(repo.createVault("first-password") is CreateVaultResult.Success) assertEquals("creating a vault is not a rotation", 0, firings) assertTrue(repo.changePassword("first-password", "second-password")) assertEquals("changePassword must invalidate both escrows", 1, firings) // A checksum-invalid phrase still rotates the wrapping key (the prior // escrows are carried forward), which is exactly the case this hook // exists for. val words = List(12) { "not-a-real-word" } assertTrue(repo.resetPasswordWithRecoveryWords(words, "third-password")) assertEquals("recovery reset must invalidate both escrows", 2, firings) } finally { VaultKeyRotationHooks.onRotated = {} } } }