RecoveryEscrowTest.kt

10.7 KB · raw

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    /** Recovery words this vault would actually show — derived from its sync root key. */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    // 1 — setRecoveryWords writes BOTH the offline-restore escrow and the45    // browser-compatible root-key escrow (MF-1).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    // 2 — unlockWithRecoveryWords with correct words restores entries (offline escrow).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    // 3 — unlockWithRecoveryWords with wrong (still checksum-valid) words fails.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    // 4 — unlockWithRecoveryWords with no escrow set returns false.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) // never call setRecoveryWords88        repo.lock()89        assertFalse(repo.hasRecoveryEscrow())90        assertFalse(repo.unlockWithRecoveryWords(words))91    }9293    // 5 — Full round-trip: create → set words → reset password via recovery → unlock with new password.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    // 6 — A checksum-INVALID phrase (e.g. all-"abandon") is rejected, matching the115    // browser. Under browser-parity IKM (`wordsToRootKeyMaterial`) such a phrase116    // fails the BIP39 checksum, so no escrow is sealed and recovery cannot proceed.117    // (Previously this asserted the old BLAKE3 behavior, which round-tripped any118    // phrase — that divergence is exactly what MF-1 fixes.)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" } // valid words, invalid checksum125        // No escrow gets sealed for an invalid phrase.126        repo.setRecoveryWords(allAbandon)127        assertNull(storage.slotA?.recoveryEscrow)128        assertNull(storage.slotA?.rootKeyEscrow)129        repo.lock()130        assertFalse(repo.unlockWithRecoveryWords(allAbandon))131    }132133    // 7 — CROSS-PLATFORM KAT. Vector generated from the browser's exact algorithm134    // (recovery-words.ts) against the real browser word list (/tmp/mf1_kat_gen.mjs):135    // proves the same 12 words derive the same recovery key and unwrap the same136    // root key on both platforms.137    @Test fun kat_matches_browser_algorithm() {138        val rootKeyHex = "bfb2d5731a470e1b1a0ae9521f501ac5edad8b4ce8e064b76da4cb3cdf00b937"139        // Corrected in v2.4.13. These were previously this platform's own140        // output under a word list that duplicated "abandon" at index 1, so the141        // vector was one position off the browser at every index >= 1 — this142        // test asserted browser parity while pinning the divergence. Recomputed143        // against the browser's canonical list; the material/HKDF/ciphertext144        // vectors below are unchanged because they key off the root key bytes,145        // not the word ordering.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        // (a) words derive from the root key exactly as the browser.161        assertEquals(expectedWords, rootKeyToWords(rootKey))162163        // (b) IKM = 16 entropy bytes followed by 16 zero bytes.164        assertEquals(wordMaterialHex, rootKeyToHex(wordsToRootKeyMaterial(expectedWords)))165166        // (c) HKDF-derived recovery key matches the browser's.167        val recoveryKey = deriveRecoveryKey(wordsToRootKeyMaterial(expectedWords), hexToRootKey(saltHex)).encoded168        assertEquals(recoveryKeyHex, rootKeyToHex(recoveryKey))169170        // (d) the browser-wrapped escrow unwraps to the original root key under AAD 0x01.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    // 8 — A browser-format vault (only the root-key escrow present) recovers the178    // root key and resets the password; entries are empty (they return via sync).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        // Simulate a browser-made vault: drop the Android-only whole-payload escrow.188        storage.slotA = storage.slotA!!.copy(recoveryEscrow = null)189        repo.lock()190191        assertTrue(repo.unlockWithRecoveryWords(words))192        // sessionRootKey, not syncMasterKey: a recovery unlock has no password key193        // yet, so the sync accessors fail closed until the reset completes (M-04).194        assertArrayEquals(rootKeyBefore, repo.sessionRootKey())  // same root key recovered195        assertTrue(repo.getEntries().isEmpty())                  // entries return via sync196    }197198    // 9 — changePassword keeps the recovery escrow valid for the SAME words.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        // Root-key escrow is carried unchanged (browser parity); offline escrow too.208        assertNotNull(storage.slotA?.rootKeyEscrow)209        repo.lock()210        assertTrue(repo.unlockWithRecoveryWords(words))211        assertTrue(repo.getEntries().any { it.title == "Email" })212    }213214    // 10 — Backward compat: a vault with neither escrow still opens with its password.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}