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.Test9101112131415class 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 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 67 val repo = VaultRepository(Storage()).also { runBlocking { it.createVault("pw") } }68 assertTrue(findInvalidWords(wordsOf(repo)).isEmpty())69 }7071 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 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 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 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 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 143 assertTrue(repo.unlockWithRecoveryWords(words))144 }145146 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 156 val duress = repo.tryUnlockDuress("decoy-pass-here")!!157 assertEquals(UnlockResult.Success, repo.commitDuressUnlock(duress))158 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 174 assertTrue(repo.unlockWithRecoveryWords(words))175 assertTrue(repo.getEntries().any { it.id == realEntry.id })176 }177178 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}