package com.peachpasswords.vault 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 /** * RecoveryEscrow suite (brief Suite 3): word validation/autocomplete, partial and * wrong-word failures, missing-escrow handling, recovery-driven password reset, * and the duress-mode guard that recovery escrows are never sealed for the decoy. */ class RecoveryEscrowSuiteTest { 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() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() } } private fun wordsOf(repo: VaultRepository): List { val key = repo.sessionRootKey()!! return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) } } private fun login(t: String, p: String = "pw") = VaultEntry(id = "", title = t, type = "login", password = p, createdAt = 0, modifiedAt = 0) // ── Word validation / autocomplete (BIP39 list) ─────────────────────────── @Test fun autocomplete_suggests_from_prefix() { val s = getWordSuggestions("aban", 5) assertTrue(s.contains("abandon")) } @Test fun autocomplete_needs_two_chars() { assertTrue(getWordSuggestions("a", 5).isEmpty()) } @Test fun find_invalid_words_flags_non_list_words() { val words = MutableList(12) { "abandon" } words[4] = "zzzznotaword" val invalid = findInvalidWords(words) assertEquals(1, invalid.size) assertEquals(4, invalid.first().first) } @Test fun find_invalid_words_empty_for_all_valid() { // Real words from a generated phrase are all in the list. val repo = VaultRepository(Storage()).also { runBlocking { it.createVault("pw") } } assertTrue(findInvalidWords(wordsOf(repo)).isEmpty()) } // ── Wrong / partial words fail recovery ────────────────────────────────── @Test fun partial_words_11_correct_1_wrong_fail() = runBlocking { val repo = VaultRepository(Storage()) repo.createVault("pw") repo.addEntry(login("Acct")) val words = wordsOf(repo).toMutableList() repo.setRecoveryWords(words) // Swap word 6 for a different valid BIP39 word → checksum/GCM mismatch. words[5] = if (words[5] == "ability") "able" else "ability" repo.lock() assertFalse(repo.unlockWithRecoveryWords(words)) } @Test fun fully_wrong_words_fail() = runBlocking { val repo = VaultRepository(Storage()) repo.createVault("pw") repo.setRecoveryWords(wordsOf(repo)) repo.lock() val wrong = rootKeyToWords(ByteArray(32) { (it * 3 + 1).toByte() }) assertFalse(repo.unlockWithRecoveryWords(wrong)) } // ── Missing escrow ──────────────────────────────────────────────────────── @Test fun recovery_unavailable_when_never_set() = runBlocking { val repo = VaultRepository(Storage()) repo.createVault("pw") val words = wordsOf(repo) repo.lock() assertFalse(repo.hasRecoveryEscrow()) assertFalse(repo.unlockWithRecoveryWords(words)) } // ── Recovery resets the password ────────────────────────────────────────── @Test fun recovery_reset_invalidates_old_password() = runBlocking { val repo = VaultRepository(Storage()) repo.createVault("original-pass") repo.addEntry(login("Bank")) val words = wordsOf(repo) repo.setRecoveryWords(words) repo.lock() assertTrue(repo.unlockWithRecoveryWords(words)) assertTrue(repo.resetPasswordWithRecoveryWords(words, "brand-new-pass")) repo.lock() assertEquals(UnlockResult.WrongPassword, repo.unlock("original-pass")) assertEquals(UnlockResult.Success, repo.unlock("brand-new-pass")) assertTrue(repo.getEntries().any { it.title == "Bank" }) } @Test fun reset_password_requires_prior_recovery_unlock() = runBlocking { val repo = VaultRepository(Storage()) repo.createVault("pw") val words = wordsOf(repo) repo.setRecoveryWords(words) repo.lock() // No unlockWithRecoveryWords first → no in-memory root key → reset refuses. assertFalse(repo.resetPasswordWithRecoveryWords(words, "new-pass-here")) } @Test fun same_words_keep_working_after_recovery_reset() = runBlocking { val repo = VaultRepository(Storage()) repo.createVault("pw") val words = wordsOf(repo) repo.setRecoveryWords(words) repo.lock() assertTrue(repo.unlockWithRecoveryWords(words)) repo.resetPasswordWithRecoveryWords(words, "second-pass-x") repo.lock() // The same 12 words still recover after the reset rotated the escrow salts. assertTrue(repo.unlockWithRecoveryWords(words)) } // ── Duress guard: no recovery escrow leaks via the decoy session ─────────── @Test fun set_recovery_words_is_refused_in_decoy_session() = runBlocking { val storage = Storage() val repo = VaultRepository(storage) repo.createVault("real-pass-here") repo.setDuressPassphrase("decoy-pass-here") repo.lock() // Enter the decoy session. val duress = repo.tryUnlockDuress("decoy-pass-here")!! assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress)) // Attempting to set recovery words in decoy mode must not seal anything. val decoyWords = wordsOf(repo) assertFalse(repo.setRecoveryWords(decoyWords)) } @Test fun recovery_targets_real_vault_not_decoy() = runBlocking { val storage = Storage() val repo = VaultRepository(storage) repo.createVault("real-pass-here") val realEntry = repo.addEntry(login("RealBank")) val words = wordsOf(repo) repo.setRecoveryWords(words) repo.setDuressPassphrase("decoy-pass-here") repo.lock() // Recovery opens the REAL vault (its escrow), surfacing the real entry. assertTrue(repo.unlockWithRecoveryWords(words)) assertTrue(repo.getEntries().any { it.id == realEntry.id }) } // ── Hash verification ───────────────────────────────────────────────────── @Test fun recovery_words_hash_is_canonicalized() { val a = recoveryWordsHash(listOf("Abandon", " ability ", "able")) val b = recoveryWordsHash(listOf("abandon", "ability", "able")) assertEquals("trim + lowercase before hashing", a, b) } @Test fun verify_recovery_words_rejects_reordering() = runBlocking { val repo = VaultRepository(Storage()) repo.createVault("pw") val words = wordsOf(repo) repo.setRecoveryWords(words) assertTrue(repo.verifyRecoveryWords(words)) assertFalse(repo.verifyRecoveryWords(words.reversed())) } }