package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * M-04: no operation may leave in-memory session state that disagrees with what * is on disk, and the sync-key accessors must fail closed whenever the session * is not a fully-unlocked real vault. */ class HiddenSessionStateTest { private class FaultStorage : VaultStorage { var slotA: StoredVault? = null var slotB: String? = null var failWriteSlotA = false private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { if (failWriteSlotA) return false 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 } override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() } } private fun recoveryWordsOf(repo: VaultRepository): List { val key = repo.sessionRootKey()!! return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) } } // A failed escrow write must not leave the session claiming words are set: // the recovery screen would stop offering the confirm step against a slot A // that holds no escrow at all. @Test fun failed_escrow_write_restores_prior_recovery_flags() = runBlocking { val storage = FaultStorage() val repo = VaultRepository(storage) repo.createVault("pw") val words = recoveryWordsOf(repo) assertFalse(repo.isRecoveryWordsSet()) storage.failWriteSlotA = true assertFalse(repo.setRecoveryWords(words)) assertFalse("flags must roll back when the escrow never landed", repo.isRecoveryWordsSet()) assertFalse(repo.verifyRecoveryWords(words)) assertFalse(repo.hasRecoveryEscrow()) // And the retry still works once storage recovers. storage.failWriteSlotA = false assertTrue(repo.setRecoveryWords(words)) assertTrue(repo.isRecoveryWordsSet()) assertTrue(repo.verifyRecoveryWords(words)) assertTrue(repo.hasRecoveryEscrow()) } @Test fun sync_accessors_are_null_when_locked() = runBlocking { val repo = VaultRepository(FaultStorage()) repo.createVault("pw") assertNotNull(repo.syncMasterKey()) assertNotNull(repo.syncVaultId()) repo.lock() assertNull(repo.syncMasterKey()) assertNull(repo.syncVaultId()) } // A recovery unlock installs a root key but no password key — the session is // not unlocked yet, so nothing may leave the device under that identity. @Test fun sync_accessors_are_null_during_recovery_half_unlock() = runBlocking { val storage = FaultStorage() val repo = VaultRepository(storage) repo.createVault("pw") val words = recoveryWordsOf(repo) assertTrue(repo.setRecoveryWords(words)) repo.lock() assertTrue(repo.unlockWithRecoveryWords(words)) assertFalse(repo.isUnlocked.value) assertNull(repo.syncMasterKey()) assertNull(repo.syncVaultId()) assertNotNull("the local-only accessor still sees the recovered key", repo.sessionRootKey()) // Finishing the reset unlocks the session and the sync identity returns. assertTrue(repo.resetPasswordWithRecoveryWords(words, "new-pw")) assertTrue(repo.isUnlocked.value) assertNotNull(repo.syncMasterKey()) assertNotNull(repo.syncVaultId()) } @Test fun failed_create_retains_no_session_state() = runBlocking { val storage = FaultStorage() storage.failWriteSlotA = true val repo = VaultRepository(storage) assertTrue(repo.createVault("pw") is CreateVaultResult.Error) assertFalse(repo.isUnlocked.value) assertNull("a failed create must not retain a root key", repo.sessionRootKey()) assertNull(repo.syncMasterKey()) assertNull(repo.currentVaultKey()) } }