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(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }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 140 141 142 143 144 145 146 val expectedWords = listOf(147 "sausage", "note", "fresh", "cruel", "ill", "assault",148 "habit", "frog", "faith", "whisper", "allow", "merit",149 )150 val wordMaterialHex = "bfb2d5731a470e1b1a0ae9521f501ac500000000000000000000000000000000"151 val recoveryKeyHex = "06880fb677e5b1cd1a65de6340a605681a55c86268885d644117401a9effd990"152 val saltHex = "000102030405060708090a0b0c0d0e0f"153 val nonceHex = "0b0a090807060504030201ff"154 val ciphertextHex = "bba39ac8dfaaff82b916ff434df340ee458c5e6b9b6a18b3a81f4748191cbf03" +155 "ac9f84707cb10c17430b9d63cd83960d2d2cd9da89aa907d2e3bd0cb78bd3628"156 val tagHex = "6f82150ebdbddef6312e5e630975089c"157158 val rootKey = hexToRootKey(rootKeyHex)159160 161 assertEquals(expectedWords, rootKeyToWords(rootKey))162163 164 assertEquals(wordMaterialHex, rootKeyToHex(wordsToRootKeyMaterial(expectedWords)))165166 167 val recoveryKey = deriveRecoveryKey(wordsToRootKeyMaterial(expectedWords), hexToRootKey(saltHex)).encoded168 assertEquals(recoveryKeyHex, rootKeyToHex(recoveryKey))169170 171 val combined = hexToRootKey(nonceHex) + hexToRootKey(ciphertextHex) + hexToRootKey(tagHex)172 val plaintext = VaultCrypto.decrypt(combined, recoveryKey, VaultCrypto.AAD_RECOVERY_ROOTKEY)173 assertEquals(rootKeyHex, String(plaintext, Charsets.US_ASCII))174 assertArrayEquals(rootKey, hexToRootKey(String(plaintext, Charsets.US_ASCII)))175 }176177 178 179 @Test fun browser_format_vault_recovers_root_key_only() = runBlocking {180 val storage = RecoveryFakeStorage()181 val repo = VaultRepository(storage)182 repo.createVault("pw")183 repo.addEntry(login("Local"))184 val words = recoveryWordsOf(repo)185 val rootKeyBefore = repo.sessionRootKey()!!186 repo.setRecoveryWords(words)187 188 storage.slotA = storage.slotA!!.copy(recoveryEscrow = null)189 repo.lock()190191 assertTrue(repo.unlockWithRecoveryWords(words))192 193 194 assertArrayEquals(rootKeyBefore, repo.sessionRootKey()) 195 assertTrue(repo.getEntries().isEmpty()) 196 }197198 199 @Test fun change_password_keeps_recovery_valid() = runBlocking {200 val storage = RecoveryFakeStorage()201 val repo = VaultRepository(storage)202 repo.createVault("old")203 repo.addEntry(login("Email"))204 val words = recoveryWordsOf(repo)205 repo.setRecoveryWords(words)206 assertTrue(repo.changePassword("old", "new"))207 208 assertNotNull(storage.slotA?.rootKeyEscrow)209 repo.lock()210 assertTrue(repo.unlockWithRecoveryWords(words))211 assertTrue(repo.getEntries().any { it.title == "Email" })212 }213214 215 @Test fun vault_without_escrow_still_opens() = runBlocking {216 val storage = RecoveryFakeStorage()217 val repo = VaultRepository(storage)218 repo.createVault("pw")219 assertNull(storage.slotA?.recoveryEscrow)220 assertNull(storage.slotA?.rootKeyEscrow)221 repo.lock()222 assertEquals(UnlockResult.Success, repo.unlock("pw"))223 }224}