RecoveryEscrowTest.kt

10.0 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() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }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        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        // (a) words derive from the root key exactly as the browser.154        assertEquals(expectedWords, rootKeyToWords(rootKey))155156        // (b) IKM = 16 entropy bytes followed by 16 zero bytes.157        assertEquals(wordMaterialHex, rootKeyToHex(wordsToRootKeyMaterial(expectedWords)))158159        // (c) HKDF-derived recovery key matches the browser's.160        val recoveryKey = deriveRecoveryKey(wordsToRootKeyMaterial(expectedWords), hexToRootKey(saltHex)).encoded161        assertEquals(recoveryKeyHex, rootKeyToHex(recoveryKey))162163        // (d) the browser-wrapped escrow unwraps to the original root key under AAD 0x01.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    // 8 — A browser-format vault (only the root-key escrow present) recovers the171    // root key and resets the password; entries are empty (they return via sync).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        // Simulate a browser-made vault: drop the Android-only whole-payload escrow.181        storage.slotA = storage.slotA!!.copy(recoveryEscrow = null)182        repo.lock()183184        assertTrue(repo.unlockWithRecoveryWords(words))185        assertArrayEquals(rootKeyBefore, repo.syncMasterKey())   // same root key recovered186        assertTrue(repo.getEntries().isEmpty())                  // entries return via sync187    }188189    // 9 — changePassword keeps the recovery escrow valid for the SAME words.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        // Root-key escrow is carried unchanged (browser parity); offline escrow too.199        assertNotNull(storage.slotA?.rootKeyEscrow)200        repo.lock()201        assertTrue(repo.unlockWithRecoveryWords(words))202        assertTrue(repo.getEntries().any { it.title == "Email" })203    }204205    // 10 — Backward compat: a vault with neither escrow still opens with its password.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}