1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertArrayEquals5import org.junit.Assert.assertEquals6import org.junit.Assert.assertFalse7import org.junit.Assert.assertNotNull8import org.junit.Assert.assertNull9import org.junit.Assert.assertTrue10import org.junit.Test1112private class RecoveryFakeStorage : VaultStorage {13 var slotA: StoredVault? = null14 var slotB: String? = null15 private var ver: Long = 016 private var settings = VaultSettings()17 override fun loadSlotA() = slotA18 override fun hasVault() = slotA != null19 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }20 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }21 var decoy: StoredVault? = null22 override fun loadDecoySlot() = decoy23 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }24 override fun hasDecoySlot() = decoy != null25 override fun clearDecoySlot() { decoy = null }26 override fun version() = ver27 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }28 override fun loadSettings() = settings29 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }30 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }31}3233class RecoveryEscrowTest {3435 36 private fun recoveryWordsOf(repo: VaultRepository): List<String> {37 val key = repo.syncMasterKey()!!38 return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }39 }4041 private fun login(title: String, password: String = "pw") =42 VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)4344 45 46 @Test fun set_recovery_words_writes_both_escrows() = runBlocking {47 val storage = RecoveryFakeStorage()48 val repo = VaultRepository(storage)49 repo.createVault("pw")50 assertNull(storage.slotA?.recoveryEscrow)51 assertNull(storage.slotA?.rootKeyEscrow)52 assertTrue(repo.setRecoveryWords(recoveryWordsOf(repo)))53 assertNotNull(storage.slotA?.recoveryEscrow)54 assertNotNull(storage.slotA?.rootKeyEscrow)55 assertTrue(repo.hasRecoveryEscrow())56 }5758 59 @Test fun unlock_with_correct_words_succeeds() = runBlocking {60 val storage = RecoveryFakeStorage()61 val repo = VaultRepository(storage)62 repo.createVault("pw")63 repo.addEntry(login("GitHub"))64 val words = recoveryWordsOf(repo)65 repo.setRecoveryWords(words)66 repo.lock()67 assertTrue(repo.unlockWithRecoveryWords(words))68 assertTrue(repo.getEntries().any { it.title == "GitHub" })69 }7071 72 @Test fun unlock_with_wrong_words_fails() = runBlocking {73 val storage = RecoveryFakeStorage()74 val repo = VaultRepository(storage)75 repo.createVault("pw")76 repo.setRecoveryWords(recoveryWordsOf(repo))77 repo.lock()78 val wrong = rootKeyToWords(ByteArray(32) { (it + 7).toByte() })79 assertFalse(repo.unlockWithRecoveryWords(wrong))80 }8182 83 @Test fun unlock_with_no_escrow_fails() = runBlocking {84 val storage = RecoveryFakeStorage()85 val repo = VaultRepository(storage)86 repo.createVault("pw")87 val words = recoveryWordsOf(repo) 88 repo.lock()89 assertFalse(repo.hasRecoveryEscrow())90 assertFalse(repo.unlockWithRecoveryWords(words))91 }9293 94 @Test fun full_recovery_resets_password() = runBlocking {95 val storage = RecoveryFakeStorage()96 val repo = VaultRepository(storage)97 repo.createVault("old-pass")98 repo.addEntry(login("Bank"))99 val words = recoveryWordsOf(repo)100 repo.setRecoveryWords(words)101 repo.lock()102103 assertTrue(repo.unlockWithRecoveryWords(words))104 assertTrue(repo.resetPasswordWithRecoveryWords(words, "brand-new-pass"))105 assertTrue(repo.isUnlocked.value)106 repo.lock()107108 assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))109 assertEquals(UnlockResult.Success, repo.unlock("brand-new-pass"))110 assertTrue(repo.getEntries().any { it.title == "Bank" })111 assertTrue(repo.hasRecoveryEscrow())112 }113114 115 116 117 118 119 @Test fun checksum_invalid_phrase_is_rejected() = runBlocking {120 val storage = RecoveryFakeStorage()121 val repo = VaultRepository(storage)122 repo.createVault("pw")123 repo.addEntry(login("Mail"))124 val allAbandon = List(12) { "abandon" } 125 126 repo.setRecoveryWords(allAbandon)127 assertNull(storage.slotA?.recoveryEscrow)128 assertNull(storage.slotA?.rootKeyEscrow)129 repo.lock()130 assertFalse(repo.unlockWithRecoveryWords(allAbandon))131 }132133 134 135 136 137 @Test fun kat_matches_browser_algorithm() {138 val rootKeyHex = "bfb2d5731a470e1b1a0ae9521f501ac5edad8b4ce8e064b76da4cb3cdf00b937"139 val expectedWords = listOf(140 "sauce", "notable", "frequent", "crucial", "ignore", "aspect",141 "gym", "fringe", "faint", "whip", "alley", "merge",142 )143 val wordMaterialHex = "bfb2d5731a470e1b1a0ae9521f501ac500000000000000000000000000000000"144 val recoveryKeyHex = "06880fb677e5b1cd1a65de6340a605681a55c86268885d644117401a9effd990"145 val saltHex = "000102030405060708090a0b0c0d0e0f"146 val nonceHex = "0b0a090807060504030201ff"147 val ciphertextHex = "bba39ac8dfaaff82b916ff434df340ee458c5e6b9b6a18b3a81f4748191cbf03" +148 "ac9f84707cb10c17430b9d63cd83960d2d2cd9da89aa907d2e3bd0cb78bd3628"149 val tagHex = "6f82150ebdbddef6312e5e630975089c"150151 val rootKey = hexToRootKey(rootKeyHex)152153 154 assertEquals(expectedWords, rootKeyToWords(rootKey))155156 157 assertEquals(wordMaterialHex, rootKeyToHex(wordsToRootKeyMaterial(expectedWords)))158159 160 val recoveryKey = deriveRecoveryKey(wordsToRootKeyMaterial(expectedWords), hexToRootKey(saltHex)).encoded161 assertEquals(recoveryKeyHex, rootKeyToHex(recoveryKey))162163 164 val combined = hexToRootKey(nonceHex) + hexToRootKey(ciphertextHex) + hexToRootKey(tagHex)165 val plaintext = VaultCrypto.decrypt(combined, recoveryKey, VaultCrypto.AAD_RECOVERY_ROOTKEY)166 assertEquals(rootKeyHex, String(plaintext, Charsets.US_ASCII))167 assertArrayEquals(rootKey, hexToRootKey(String(plaintext, Charsets.US_ASCII)))168 }169170 171 172 @Test fun browser_format_vault_recovers_root_key_only() = runBlocking {173 val storage = RecoveryFakeStorage()174 val repo = VaultRepository(storage)175 repo.createVault("pw")176 repo.addEntry(login("Local"))177 val words = recoveryWordsOf(repo)178 val rootKeyBefore = repo.syncMasterKey()!!179 repo.setRecoveryWords(words)180 181 storage.slotA = storage.slotA!!.copy(recoveryEscrow = null)182 repo.lock()183184 assertTrue(repo.unlockWithRecoveryWords(words))185 assertArrayEquals(rootKeyBefore, repo.syncMasterKey()) 186 assertTrue(repo.getEntries().isEmpty()) 187 }188189 190 @Test fun change_password_keeps_recovery_valid() = runBlocking {191 val storage = RecoveryFakeStorage()192 val repo = VaultRepository(storage)193 repo.createVault("old")194 repo.addEntry(login("Email"))195 val words = recoveryWordsOf(repo)196 repo.setRecoveryWords(words)197 assertTrue(repo.changePassword("old", "new"))198 199 assertNotNull(storage.slotA?.rootKeyEscrow)200 repo.lock()201 assertTrue(repo.unlockWithRecoveryWords(words))202 assertTrue(repo.getEntries().any { it.title == "Email" })203 }204205 206 @Test fun vault_without_escrow_still_opens() = runBlocking {207 val storage = RecoveryFakeStorage()208 val repo = VaultRepository(storage)209 repo.createVault("pw")210 assertNull(storage.slotA?.recoveryEscrow)211 assertNull(storage.slotA?.rootKeyEscrow)212 repo.lock()213 assertEquals(UnlockResult.Success, repo.unlock("pw"))214 }215}