PasswordRotationAtomicityTest.kt

7.8 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNotEquals7import org.junit.Assert.assertTrue8import org.junit.Test910/**11 * H-01: a wrapping-key rotation (changePassword / resetPasswordWithRecoveryWords)12 * must either fully commit slot A + size-matched noise slot B + version stamp or13 * leave the at-rest state untouched. A partial commit leaves the live session on14 * a key that no longer matches disk, so the next save encrypts under a key that15 * cannot be unlocked next time.16 *17 * The shared fault storage surfaces the three places [commitVaultRotation] can18 * fail: writeSlotA returning false, writeSlotB returning false, and19 * stampLocalMutation throwing. setRecoveryWords is reachable here so the20 * recovery-reset path has a real prior slot A with both escrows.21 */22class PasswordRotationAtomicityTest {2324    private class FaultStorage : VaultStorage {25        var slotA: StoredVault? = null26        var slotB: String? = null27        var failWriteSlotA = false28        var failWriteSlotB = false29        var failStamp = false30        private var ver: Long = 031        private var settings = VaultSettings()32        override fun loadSlotA() = slotA33        override fun hasVault() = slotA != null34        override fun writeSlotA(slot: StoredVault): Boolean {35            if (failWriteSlotA) return false36            slotA = slot37            return true38        }39        override fun loadSlotB() = slotB40        override fun writeSlotB(blobBase64: String): Boolean {41            if (failWriteSlotB) return false42            slotB = blobBase6443            return true44        }45        var decoy: StoredVault? = null46        override fun loadDecoySlot() = decoy47        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }48        override fun hasDecoySlot() = decoy != null49        override fun clearDecoySlot() { decoy = null }50        override fun version() = ver51        override fun stampLocalMutation(): Long {52            if (failStamp) throw RuntimeException("stamp failed")53            ver = maxOf(System.currentTimeMillis(), ver + 1)54            return ver55        }56        override fun loadSettings() = settings57        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }58        override fun loadVaultHistory() = VaultHistory()59        override fun writeVaultHistory(history: VaultHistory): Boolean = true60        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }61    }6263    private fun repoWithPassword(storage: FaultStorage, password: String): VaultRepository = runBlocking {64        val repo = VaultRepository(storage)65        repo.createVault(password)66        // Add an entry so we can prove the rotation preserved it (or prove67        // a failed rotation did NOT touch it).68        repo.addEntry(VaultEntry(id = "", title = "Keep me", type = "login", password = "p",69            createdAt = 0, modifiedAt = 0))70        // Confirm recovery words so resetPasswordWithRecoveryWords has a real71        // escrow to rebuild against.72        val words = rootKeyToWords(repo.syncMasterKey()!!)73        assertTrue(repo.setRecoveryWords(words))74        repo75    }7677    private fun recoveryWordsOf(repo: VaultRepository): List<String> {78        val key = repo.syncMasterKey()!!79        return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }80    }8182    // ---- changePassword ---------------------------------------------------8384    @Test fun change_password_with_slot_b_failure_keeps_old_key() = runBlocking {85        val storage = FaultStorage()86        val repo = repoWithPassword(storage, "old-pass")87        val slotABefore = storage.slotA88        val slotBBefore = storage.slotB89        storage.failWriteSlotB = true9091        // changePassword must return false AND leave the at-rest state intact:92        // the session is still on the old key, slot A on disk still opens under93        // the old password.94        assertFalse(repo.changePassword("old-pass", "new-pass"))9596        // The half-written commit was rolled back: both slots match what was97        // there before the call, not the would-be new values.98        assertEquals(slotABefore, storage.slotA)99        assertEquals(slotBBefore, storage.slotB)100        // Round-trip under the OLD password still works — session key untouched.101        repo.lock()102        assertEquals(UnlockResult.Success, repo.unlock("old-pass"))103        assertEquals(1, repo.getEntries().size)104        assertTrue(repo.getEntries().any { it.title == "Keep me" })105        assertEquals(UnlockResult.WrongPassword, repo.unlock("new-pass"))106    }107108    @Test fun change_password_happy_path_swaps_session_key_and_disk() = runBlocking {109        val storage = FaultStorage()110        val repo = repoWithPassword(storage, "old-pass")111        val slotABefore = storage.slotA!!112113        assertTrue(repo.changePassword("old-pass", "new-pass"))114115        // Slot A on disk is now sealed under the new salt/key, not the old one.116        assertNotEquals(slotABefore.salt, storage.slotA?.salt)117        assertNotEquals(slotABefore.blob, storage.slotA?.blob)118        repo.lock()119        assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))120        assertEquals(UnlockResult.Success, repo.unlock("new-pass"))121        assertTrue(repo.getEntries().any { it.title == "Keep me" })122    }123124    // ---- resetPasswordWithRecoveryWords ----------------------------------125126    @Test fun reset_password_with_slot_b_failure_restores_prior_slot_a() = runBlocking {127        val storage = FaultStorage()128        val repo = repoWithPassword(storage, "first-pass")129        val words = recoveryWordsOf(repo)130        repo.lock()131132        assertTrue(repo.unlockWithRecoveryWords(words))133        val slotABefore = storage.slotA134        val slotBBefore = storage.slotB135        storage.failWriteSlotB = true136137        assertFalse(repo.resetPasswordWithRecoveryWords(words, "second-pass"))138139        // The half-written commit was rolled back; session has no derivedKey140        // yet (unlockWithRecoveryWords leaves it null until the reset succeeds).141        assertEquals(slotABefore, storage.slotA)142        assertEquals(slotBBefore, storage.slotB)143        // A subsequent reset under a healthy store must still work, proving144        // the failed rotation didn't strand the recovery session.145        storage.failWriteSlotB = false146        assertTrue(repo.resetPasswordWithRecoveryWords(words, "second-pass"))147        repo.lock()148        assertEquals(UnlockResult.Success, repo.unlock("second-pass"))149        assertTrue(repo.getEntries().any { it.title == "Keep me" })150    }151152    @Test fun reset_password_with_stamp_failure_restores_both_slots() = runBlocking {153        val storage = FaultStorage()154        val repo = repoWithPassword(storage, "first-pass")155        val words = recoveryWordsOf(repo)156        repo.lock()157158        assertTrue(repo.unlockWithRecoveryWords(words))159        val slotABefore = storage.slotA160        val slotBBefore = storage.slotB161        storage.failStamp = true162163        assertFalse(repo.resetPasswordWithRecoveryWords(words, "second-pass"))164165        // H-01: stamp failure happens AFTER slot B is written, so rollback166        // must restore BOTH slots — otherwise the at-rest pair is size-mismatched.167        assertEquals(slotABefore, storage.slotA)168        assertEquals(slotBBefore, storage.slotB)169        // Session state from unlockWithRecoveryWords is intact (entries loaded,170        // root key present, no derivedKey yet) so a clean retry can complete.171        assertTrue(repo.getEntries().any { it.title == "Keep me" })172        // A clean retry succeeds.173        storage.failStamp = false174        assertTrue(repo.resetPasswordWithRecoveryWords(words, "second-pass"))175        repo.lock()176        assertEquals(UnlockResult.Success, repo.unlock("second-pass"))177    }178}