RecoveryEscrowSuiteTest.kt

8.3 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Test910/**11 * RecoveryEscrow suite (brief Suite 3): word validation/autocomplete, partial and12 * wrong-word failures, missing-escrow handling, recovery-driven password reset,13 * and the duress-mode guard that recovery escrows are never sealed for the decoy.14 */15class RecoveryEscrowSuiteTest {1617    private class Storage : VaultStorage {18        var slotA: StoredVault? = null19        var slotB: String? = null20        private var ver: Long = 021        private var settings = VaultSettings()22        override fun loadSlotA() = slotA23        override fun hasVault() = slotA != null24        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }25        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }26        var decoy: StoredVault? = null27        override fun loadDecoySlot() = decoy28        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }29        override fun hasDecoySlot() = decoy != null30        override fun clearDecoySlot() { decoy = null }31        override fun version() = ver32        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }33        override fun loadSettings() = settings34        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }35        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }36    }3738    private fun wordsOf(repo: VaultRepository): List<String> {39        val key = repo.sessionRootKey()!!40        return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }41    }4243    private fun login(t: String, p: String = "pw") =44        VaultEntry(id = "", title = t, type = "login", password = p, createdAt = 0, modifiedAt = 0)4546    // ── Word validation / autocomplete (BIP39 list) ───────────────────────────4748    @Test fun autocomplete_suggests_from_prefix() {49        val s = getWordSuggestions("aban", 5)50        assertTrue(s.contains("abandon"))51    }5253    @Test fun autocomplete_needs_two_chars() {54        assertTrue(getWordSuggestions("a", 5).isEmpty())55    }5657    @Test fun find_invalid_words_flags_non_list_words() {58        val words = MutableList(12) { "abandon" }59        words[4] = "zzzznotaword"60        val invalid = findInvalidWords(words)61        assertEquals(1, invalid.size)62        assertEquals(4, invalid.first().first)63    }6465    @Test fun find_invalid_words_empty_for_all_valid() {66        // Real words from a generated phrase are all in the list.67        val repo = VaultRepository(Storage()).also { runBlocking { it.createVault("pw") } }68        assertTrue(findInvalidWords(wordsOf(repo)).isEmpty())69    }7071    // ── Wrong / partial words fail recovery ──────────────────────────────────7273    @Test fun partial_words_11_correct_1_wrong_fail() = runBlocking {74        val repo = VaultRepository(Storage())75        repo.createVault("pw")76        repo.addEntry(login("Acct"))77        val words = wordsOf(repo).toMutableList()78        repo.setRecoveryWords(words)79        // Swap word 6 for a different valid BIP39 word → checksum/GCM mismatch.80        words[5] = if (words[5] == "ability") "able" else "ability"81        repo.lock()82        assertFalse(repo.unlockWithRecoveryWords(words))83    }8485    @Test fun fully_wrong_words_fail() = runBlocking {86        val repo = VaultRepository(Storage())87        repo.createVault("pw")88        repo.setRecoveryWords(wordsOf(repo))89        repo.lock()90        val wrong = rootKeyToWords(ByteArray(32) { (it * 3 + 1).toByte() })91        assertFalse(repo.unlockWithRecoveryWords(wrong))92    }9394    // ── Missing escrow ────────────────────────────────────────────────────────9596    @Test fun recovery_unavailable_when_never_set() = runBlocking {97        val repo = VaultRepository(Storage())98        repo.createVault("pw")99        val words = wordsOf(repo)100        repo.lock()101        assertFalse(repo.hasRecoveryEscrow())102        assertFalse(repo.unlockWithRecoveryWords(words))103    }104105    // ── Recovery resets the password ──────────────────────────────────────────106107    @Test fun recovery_reset_invalidates_old_password() = runBlocking {108        val repo = VaultRepository(Storage())109        repo.createVault("original-pass")110        repo.addEntry(login("Bank"))111        val words = wordsOf(repo)112        repo.setRecoveryWords(words)113        repo.lock()114115        assertTrue(repo.unlockWithRecoveryWords(words))116        assertTrue(repo.resetPasswordWithRecoveryWords(words, "brand-new-pass"))117        repo.lock()118        assertEquals(UnlockResult.WrongPassword, repo.unlock("original-pass"))119        assertEquals(UnlockResult.Success, repo.unlock("brand-new-pass"))120        assertTrue(repo.getEntries().any { it.title == "Bank" })121    }122123    @Test fun reset_password_requires_prior_recovery_unlock() = runBlocking {124        val repo = VaultRepository(Storage())125        repo.createVault("pw")126        val words = wordsOf(repo)127        repo.setRecoveryWords(words)128        repo.lock()129        // No unlockWithRecoveryWords first → no in-memory root key → reset refuses.130        assertFalse(repo.resetPasswordWithRecoveryWords(words, "new-pass-here"))131    }132133    @Test fun same_words_keep_working_after_recovery_reset() = runBlocking {134        val repo = VaultRepository(Storage())135        repo.createVault("pw")136        val words = wordsOf(repo)137        repo.setRecoveryWords(words)138        repo.lock()139        assertTrue(repo.unlockWithRecoveryWords(words))140        repo.resetPasswordWithRecoveryWords(words, "second-pass-x")141        repo.lock()142        // The same 12 words still recover after the reset rotated the escrow salts.143        assertTrue(repo.unlockWithRecoveryWords(words))144    }145146    // ── Duress guard: no recovery escrow leaks via the decoy session ───────────147148    @Test fun set_recovery_words_is_refused_in_decoy_session() = runBlocking {149        val storage = Storage()150        val repo = VaultRepository(storage)151        repo.createVault("real-pass-here")152        repo.setDuressPassphrase("decoy-pass-here")153        repo.lock()154155        // Enter the decoy session.156        val duress = repo.tryUnlockDuress("decoy-pass-here")!!157        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress))158        // Attempting to set recovery words in decoy mode must not seal anything.159        val decoyWords = wordsOf(repo)160        assertFalse(repo.setRecoveryWords(decoyWords))161    }162163    @Test fun recovery_targets_real_vault_not_decoy() = runBlocking {164        val storage = Storage()165        val repo = VaultRepository(storage)166        repo.createVault("real-pass-here")167        val realEntry = repo.addEntry(login("RealBank"))168        val words = wordsOf(repo)169        repo.setRecoveryWords(words)170        repo.setDuressPassphrase("decoy-pass-here")171        repo.lock()172173        // Recovery opens the REAL vault (its escrow), surfacing the real entry.174        assertTrue(repo.unlockWithRecoveryWords(words))175        assertTrue(repo.getEntries().any { it.id == realEntry.id })176    }177178    // ── Hash verification ─────────────────────────────────────────────────────179180    @Test fun recovery_words_hash_is_canonicalized() {181        val a = recoveryWordsHash(listOf("Abandon", " ability ", "able"))182        val b = recoveryWordsHash(listOf("abandon", "ability", "able"))183        assertEquals("trim + lowercase before hashing", a, b)184    }185186    @Test fun verify_recovery_words_rejects_reordering() = runBlocking {187        val repo = VaultRepository(Storage())188        repo.createVault("pw")189        val words = wordsOf(repo)190        repo.setRecoveryWords(words)191        assertTrue(repo.verifyRecoveryWords(words))192        assertFalse(repo.verifyRecoveryWords(words.reversed()))193    }194}