065b02e39f66

H-01: transactional password rotation with rollback on every storage step; throw on stamp commit failure

time
2026-07-29 10:32 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
1d32590746bc
4 files app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+209 −58app/src/main/java/com/peachpasswords/vault/VaultStore.kt+7 −1app/src/test/java/com/peachpasswords/vault/PasswordRotationAtomicityTest.kt+178 −0app/src/test/java/com/peachpasswords/vault/SnapshotRestoreAtomicityTest.kt+181 −0
app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+209 −58
@@ -267,9 +267,20 @@267267             rootKey = generateRecoverableRootKey()268268             val slotA = sealSlotA(key, salt)269269             if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")270-            writeMatchingNoiseSlotB(slotA)271-            store.stampLocalMutation()272-            store.writeSettings(_settings.value)270+            // P1-3 (codex): every later step must roll the slot pair back if it271+            // fails — leaving a half-built vault is worse than refusing to make it.272+            val created = try {273+                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")274+                store.stampLocalMutation()275+                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")276+                true277+            } catch (_: Exception) {278+                // Half-built vault on disk is worse than no vault — refuse to279+                // create if any step past slot A fails.280+                store.clearVault()281+                false282+            }283+            if (!created) return CreateVaultResult.Error("storage_write_failed")273284             derivedKey = key274285             publish()275286             _isUnlocked.value = true@@ -307,9 +318,18 @@307318             val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))308319             val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)309320             if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")310-            writeMatchingNoiseSlotB(slotA)311-            store.stampLocalMutation()312-            store.writeSettings(_settings.value)321+            // P1-3 (codex): same partial-commit guard as createVault — any failure322+            // past slot A wipes the half-built vault and refuses adoption.323+            val created = try {324+                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")325+                store.stampLocalMutation()326+                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")327+                true328+            } catch (_: Exception) {329+                store.clearVault()330+                false331+            }332+            if (!created) return AdoptVaultResult.Error("storage_write_failed")313333 314334             val previousDerivedKey = derivedKey315335             val previousRootKey = rootKey@@ -362,7 +382,9 @@362382      * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw363383      * (un-normalized) password bytes. On success the slot is HEALED — re-sealed364384      * under the NFC-normalized key — so every future unlock takes the normal365-     * path. Returns null when the raw key also fails (genuinely wrong password).385+     * path. Returns null when the raw key also fails (genuinely wrong password)386+     * OR when any step of the heal commits fails (the original slot A is restored387+     * so disk and session stay aligned).366388      */367389     private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {368390         val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))@@ -376,23 +398,32 @@376398         // re-seal). Escrows carry forward unchanged — only the wrapping rotates.377399         val newSalt = VaultCrypto.generateSalt()378400         val newKey = VaultCrypto.deriveKey(password, newSalt)379-        val healed = try {380-            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))381-            val next = StoredVault(382-                version = slotA.version,383-                salt = b64(newSalt),384-                blob = blob,385-                recoveryEscrow = slotA.recoveryEscrow,386-                rootKeyEscrow = slotA.rootKeyEscrow,387-            )388-            if (store.writeSlotA(next)) {389-                writeMatchingNoiseSlotB(next)401+        val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))402+        val next = StoredVault(403+            version = slotA.version,404+            salt = b64(newSalt),405+            blob = blob,406+            recoveryEscrow = slotA.recoveryEscrow,407+            rootKeyEscrow = slotA.rootKeyEscrow,408+        )409+        // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,410+        // or stampLocalMutation must restore the prior slot A AND slot B so disk411+        // and the eventual session key never disagree on the wrapping.412+        val priorB = store.loadSlotB()413+        val healed = if (!store.writeSlotA(next)) {414+            false415+        } else try {416+            if (!writeMatchingNoiseSlotB(next)) {417+                runCatching { store.writeSlotA(slotA) }418+                if (priorB != null) runCatching { store.writeSlotB(priorB) }419+                false420+            } else {390421                 store.stampLocalMutation()391422                 true392-            } else {393-                false394423             }395424         } catch (_: Exception) {425+            runCatching { store.writeSlotA(slotA) }426+            if (priorB != null) runCatching { store.writeSlotB(priorB) }396427             false397428         }398429         return if (healed) {@@ -793,15 +824,18 @@793824 794825     suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {795826         val slotA = store.loadSlotA() ?: return false827+        var oldKey: ByteArray? = null828+        var newKey: ByteArray? = null829+        var plaintext: ByteArray? = null796830         return try {797-            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))831+            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))798832             val payload = VaultCodec.decode(799833                 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),800834             )801835             val newSalt = VaultCrypto.generateSalt()802-            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)836+            newKey = VaultCrypto.deriveKey(newPassword, newSalt)803837             // Re-seal the existing payload under the new key/salt.804-            val plaintext = VaultCodec.encode(payload)838+            plaintext = VaultCodec.encode(payload)805839             val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))806840             // The escrow seals the slot-A PLAINTEXT, which a password change does not807841             // alter — only the wrapping changes — so carry it forward; rebuilding the@@ -817,15 +851,22 @@817851                 recoveryEscrow = slotA.recoveryEscrow,818852                 rootKeyEscrow = slotA.rootKeyEscrow,819853             )820-            if (!store.writeSlotA(next)) return false821-            writeMatchingNoiseSlotB(next)822-            store.stampLocalMutation()823-            VaultCrypto.zero(oldKey)854+            // H-01: durably commit slot A + size-matched noise + version stamp855+            // before swapping the session key. A partial commit (new A on disk,856+            // old key in memory) would have the next save encrypt under the old857+            // key while matching disk on the new one.858+            val priorB = store.loadSlotB()859+            if (!commitVaultRotation(slotA, priorB, next)) return false860+            VaultCrypto.zero(oldKey); oldKey = null824861             derivedKey?.let(VaultCrypto::zero)825-            derivedKey = newKey862+            derivedKey = newKey; newKey = null826863             true827864         } catch (_: Exception) {828865             false866+        } finally {867+            oldKey?.let(VaultCrypto::zero)868+            newKey?.let(VaultCrypto::zero)869+            plaintext?.let(VaultCrypto::zero)829870         }830871     }831872 @@ -1059,14 +1100,57 @@10591100     suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {10601101         val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false10611102         val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1062-        takeVaultSnapshot("restore")1063-        if (!store.writeSlotA(restoredA)) return false1064-        if (!store.writeSlotB(snapshot.vault_b)) return false1065-        store.stampLocalMutation()1103+        val priorA = store.loadSlotA()1104+        val priorB = store.loadSlotB()1105+        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1106+        // slot write — the old code took this snapshot, then stranded it on1107+        // every failure, polluting history with a "restore" entry that never1108+        // restored anything. The history below is rolled back together with1109+        // the slots if the commit fails.1110+        val priorHistory = store.loadVaultHistory()1111+        // P1-4 (codex): the recovery snapshot must land before we commit the1112+        // slot pair, otherwise a failed restore cannot be rolled back to the1113+        // pre-restore history. If the history write itself fails, refuse to1114+        // proceed — the alternative is a successful restore with no undo path.1115+        if (!takeVaultSnapshot("restore")) {1116+            return false1117+        }1118+        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) {1119+            store.writeVaultHistory(priorHistory)1120+            return false1121+        }10661122         lock()10671123         true10681124     }10691125 1126+    /**1127+     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1128+     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1129+     * (a restore preserves the duress-shaped blob from history), not a fresh1130+     * noise record. Any failure rolls back so the at-rest pair never holds a1131+     * restored slot A against a pre-restore slot B. Caller is responsible for1132+     * restoring the pre-snapshot history on a false return.1133+     */1134+    private fun commitRestoreAtomic(1135+        nextA: StoredVault,1136+        nextB: String,1137+        priorA: StoredVault?,1138+        priorB: String?,1139+    ): Boolean {1140+        if (!store.writeSlotA(nextA)) return false1141+        // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1142+        // must trigger rollback; previously a throw escaped without restoring1143+        // the prior slot A and history.1144+        try {1145+            if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1146+            store.stampLocalMutation()1147+        } catch (_: Exception) {1148+            rollbackRestore(priorA, priorB)1149+            return false1150+        }1151+        return true1152+    }1153+10701154     // ---- Sync apply (does NOT advance the version) ------------------------10711155 10721156     /**@@ -1240,36 +1324,42 @@12401324         newPassword: String,12411325     ): Boolean = mutex.withLock {12421326         if (rootKey == null) return false // must have recovered first1327+        val priorA = store.loadSlotA() ?: return false1328+        var newKey: ByteArray? = null1329+        var plaintext: ByteArray? = null12431330         return try {12441331             val newSalt = VaultCrypto.generateSalt()1245-            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1246-            val plaintext = VaultCodec.encode(currentPayload())1332+            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1333+            plaintext = VaultCodec.encode(currentPayload())12471334             val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1248-            val prior = store.loadSlotA()12491335             // Rebuild both escrows against the recovered session so their salts rotate12501336             // with the password reset; fall back to the prior escrows if rebuild fails12511337             // (e.g. a checksum-invalid phrase). The same 12 words keep working.12521338             val valid = words.size == 12 && findInvalidWords(words).isEmpty()1253-            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1254-            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1339+            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1340+            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow12551341             val next = StoredVault(1256-                version = prior?.version ?: 1,1342+                version = priorA.version,12571343                 salt = b64(newSalt),12581344                 blob = blob,12591345                 recoveryEscrow = freshRecovery,12601346                 rootKeyEscrow = freshRootKey,12611347             )1262-            if (!store.writeSlotA(next)) return false1263-            writeMatchingNoiseSlotB(next)1264-            store.stampLocalMutation()1348+            // H-01: see changePassword — both rotations share the same1349+            // commit-or-keep-old-key contract so a partial write can't strand1350+            // the session on a key that no longer matches disk.1351+            val priorB = store.loadSlotB()1352+            if (!commitVaultRotation(priorA, priorB, next)) return false12651353             derivedKey?.let(VaultCrypto::zero)1266-            derivedKey = newKey1267-            VaultCrypto.zero(plaintext)1354+            derivedKey = newKey; newKey = null12681355             publish()12691356             _isUnlocked.value = true12701357             true12711358         } catch (_: Exception) {12721359             false1360+        } finally {1361+            newKey?.let(VaultCrypto::zero)1362+            plaintext?.let(VaultCrypto::zero)12731363         }12741364     }12751365 @@ -1407,19 +1497,25 @@14071497         return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"14081498     }14091499 1410-    private fun takeVaultSnapshot(reason: String) {1411-        runCatching {1412-            val slotA = store.loadSlotA() ?: return1413-            val vaultA = VaultCodec.encodeSlot(slotA)1414-            val vaultB = store.loadSlotB() ?: slotA.blob1415-            val history = store.loadVaultHistory()1416-            val latest = history.snapshots.firstOrNull()1417-            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1418-            val entryCount = if (derivedKey != null && !decoyMode) {1419-                bucketEntryCount(entryList.count { it.deletedAt == null })1420-            } else {1421-                null1422-            }1500+    /**1501+     * Capture the current slot pair (and metadata) into the history list.1502+     * Returns true when the history write landed, false when it didn't — callers1503+     * that depend on the snapshot existing (notably [restoreVaultSnapshot])1504+     * must check the result and roll back if it does not.1505+     */1506+    private fun takeVaultSnapshot(reason: String): Boolean {1507+        val slotA = store.loadSlotA() ?: return false1508+        val vaultA = VaultCodec.encodeSlot(slotA)1509+        val vaultB = store.loadSlotB() ?: slotA.blob1510+        val history = store.loadVaultHistory()1511+        val latest = history.snapshots.firstOrNull()1512+        if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true1513+        val entryCount = if (derivedKey != null && !decoyMode) {1514+            bucketEntryCount(entryList.count { it.deletedAt == null })1515+        } else {1516+            null1517+        }1518+        return try {14231519             val snapshot = VaultSnapshot(14241520                 id = UUID.randomUUID().toString(),14251521                 savedAt = System.currentTimeMillis(),@@ -1428,9 +1524,14 @@14281524                 vault_b = vaultB,14291525                 entryCount = entryCount,14301526             )1527+            // P1-4 (codex): return the history-write boolean to callers so a1528+            // false return no longer leaves the snapshot out of history while1529+            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.14311530             store.writeVaultHistory(14321531                 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),14331532             )1533+        } catch (_: Exception) {1534+            false14341535         }14351536     }14361537 @@ -1525,21 +1626,71 @@15251626             return store.writeDecoySlot(prior.copy(blob = blob))15261627         }15271628         val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1629+        // H-01: captured BEFORE the noise write so a stamp failure can restore1630+        // the prior slot B alongside the prior slot A. Slot B is always1631+        // overwritten by writeMatchingNoiseSlotB below, so a stamp failure1632+        // leaves (new A, new B, old version) on disk — partial commit.1633+        val priorB = store.loadSlotB()15281634         if (snapshotReason != null) takeVaultSnapshot(snapshotReason)15291635         val plaintext = VaultCodec.encode(currentPayload())15301636         val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))15311637         val next = prior.copy(blob = blob)15321638         if (!store.writeSlotA(next)) return false15331639         // Slot A committed — keep slot B size-matched, then advance the version1534-        // for local writes only. Restore slot A if a later step fails.1640+        // for local writes only. Restore both A and B if a later step fails.1641+        // P1-1 (codex): the noise write's boolean AND any thrown exception from1642+        // it must trigger rollback; previously the call sat outside the try.15351643         try {1536-            writeMatchingNoiseSlotB(next)1644+            if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")15371645             if (bumpVersion) store.stampLocalMutation()15381646         } catch (e: Exception) {15391647             store.writeSlotA(prior)1648+            if (priorB != null) runCatching { store.writeSlotB(priorB) }15401649             throw e15411650         }15421651         return true1652+    }1653+1654+    /**1655+     * H-01: durably commit a slot-A rotation by writing slot A, its1656+     * size-matched noise slot B, and the local-mutation stamp in order. Any1657+     * failure rolls the previously-committed steps back so the at-rest pair1658+     * matches what was there before the call. Returns true only when every1659+     * step succeeded — callers must NOT swap their session key on false.1660+     */1661+    private fun commitVaultRotation(1662+        priorA: StoredVault,1663+        priorB: String?,1664+        nextA: StoredVault,1665+    ): Boolean {1666+        if (!store.writeSlotA(nextA)) return false1667+        // P1-1 (codex): the noise write's boolean AND any thrown exception from1668+        // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB1669+        // escaped the helper and left slot A committed.1670+        try {1671+            if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")1672+            store.stampLocalMutation()1673+        } catch (_: Exception) {1674+            rollbackRotation(priorA, priorB)1675+            return false1676+        }1677+        return true1678+    }1679+1680+    /**1681+     * Best-effort undo of the writes [commitVaultRotation] may have committed.1682+     * Each step runs independently — a failed rollback must not mask the1683+     * original commit failure. Log is a throwing stub off-device.1684+     */1685+    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {1686+        val failures = mutableListOf<String>()1687+        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }1688+            .onFailure { failures += "writeSlotA: $it" }1689+        if (priorB != null) {1690+            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }1691+                .onFailure { failures += "writeSlotB: $it" }1692+        }1693+        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }15431694     }15441695 15451696     /** Persist a local mutation, rolling the in-memory list back on failure. */

view file

app/src/main/java/com/peachpasswords/vault/VaultStore.kt+7 −1
@@ -180,7 +180,13 @@180180 181181     override fun stampLocalMutation(): Long {182182         val next = maxOf(System.currentTimeMillis(), version() + 1L)183-        prefs.edit().putString(KEY_VERSION, next.toString()).commit()183+        // commit() returning false must surface as a failure — every caller184+        // either treats an exception as a rollback trigger (H-01/H-02 helpers,185+        // persistSlotA) or advances a version it has not actually persisted.186+        // Throwing keeps the boolean-visible failure mode aligned with codex P1-2.187+        if (!prefs.edit().putString(KEY_VERSION, next.toString()).commit()) {188+            throw java.io.IOException("version_stamp_commit_failed")189+        }184190         return next185191     }186192 

view file

app/src/test/java/com/peachpasswords/vault/PasswordRotationAtomicityTest.kt+178 −0
@@ -0,0 +1,178 @@1+package com.peachpasswords.vault2+3+import kotlinx.coroutines.runBlocking4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertFalse6+import org.junit.Assert.assertNotEquals7+import org.junit.Assert.assertTrue8+import org.junit.Test9+10+/**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+ */22+class PasswordRotationAtomicityTest {23+24+    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() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }61+    }62+63+    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+    }76+77+    private fun recoveryWordsOf(repo: VaultRepository): List<String> {78+        val key = repo.syncMasterKey()!!79+        return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }80+    }81+82+    // ---- changePassword ---------------------------------------------------83+84+    @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 = true90+91+        // 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"))95+96+        // 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+    }107+108+    @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!!112+113+        assertTrue(repo.changePassword("old-pass", "new-pass"))114+115+        // 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+    }123+124+    // ---- resetPasswordWithRecoveryWords ----------------------------------125+126+    @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()131+132+        assertTrue(repo.unlockWithRecoveryWords(words))133+        val slotABefore = storage.slotA134+        val slotBBefore = storage.slotB135+        storage.failWriteSlotB = true136+137+        assertFalse(repo.resetPasswordWithRecoveryWords(words, "second-pass"))138+139+        // 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+    }151+152+    @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()157+158+        assertTrue(repo.unlockWithRecoveryWords(words))159+        val slotABefore = storage.slotA160+        val slotBBefore = storage.slotB161+        storage.failStamp = true162+163+        assertFalse(repo.resetPasswordWithRecoveryWords(words, "second-pass"))164+165+        // 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+}\ No newline at end of file

view file

app/src/test/java/com/peachpasswords/vault/SnapshotRestoreAtomicityTest.kt+181 −0
@@ -0,0 +1,181 @@1+package com.peachpasswords.vault2+3+import kotlinx.coroutines.runBlocking4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertFalse6+import org.junit.Assert.assertNotEquals7+import org.junit.Assert.assertTrue8+import org.junit.Test9+10+/**11+ * H-02: a snapshot restore must either fully commit slot A + slot B + version12+ * stamp or leave every byte of at-rest state (slot A, slot B, history)13+ * untouched. A partial commit strands the session on a vault that no longer14+ * matches disk, and a "restore" history entry written before the commit makes15+ * a failed restore look like a successful one in the history list.16+ */17+class SnapshotRestoreAtomicityTest {18+19+    private class FaultStorage : VaultStorage {20+        var slotA: StoredVault? = null21+        var slotB: String? = null22+        var failWriteSlotA = false23+        var failWriteSlotB = false24+        var failStamp = false25+        private var ver: Long = 026+        private var settings = VaultSettings()27+        var history = VaultHistory()28+        override fun loadSlotA() = slotA29+        override fun hasVault() = slotA != null30+        override fun writeSlotA(slot: StoredVault): Boolean {31+            if (failWriteSlotA) return false32+            slotA = slot; return true33+        }34+        override fun loadSlotB() = slotB35+        override fun writeSlotB(blobBase64: String): Boolean {36+            if (failWriteSlotB) return false37+            slotB = blobBase64; return true38+        }39+        var decoy: StoredVault? = null40+        override fun loadDecoySlot() = decoy41+        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }42+        override fun hasDecoySlot() = decoy != null43+        override fun clearDecoySlot() { decoy = null }44+        override fun version() = ver45+        override fun stampLocalMutation(): Long {46+            if (failStamp) throw RuntimeException("stamp failed")47+            ver = maxOf(System.currentTimeMillis(), ver + 1); return ver48+        }49+        override fun loadSettings() = settings50+        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }51+        override fun loadVaultHistory() = history52+        override fun writeVaultHistory(history: VaultHistory): Boolean { this.history = history; return true }53+        override fun clearVaultHistory() { history = VaultHistory() }54+        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }55+    }56+57+    /**58+     * Build a repo with two entries plus a restorable snapshot. After setup,59+     * the on-disk vault holds both entries; the captured snapshot holds only60+     * the first entry (takeVaultSnapshot reads slot A from disk BEFORE the new61+     * blob is written, so the snapshot from addEntry("Second") captures the62+     * First-only state that was on disk just before Second was persisted).63+     */64+    private data class Fixture(65+        val repo: VaultRepository,66+        val storage: FaultStorage,67+        val snapshotId: String,68+    )69+70+    private fun setup(): Fixture = runBlocking {71+        val storage = FaultStorage()72+        val repo = VaultRepository(storage)73+        repo.createVault("pw")74+        repo.addEntry(VaultEntry(id = "", title = "First", type = "login", password = "p", createdAt = 0, modifiedAt = 0))75+        repo.addEntry(VaultEntry(id = "", title = "Second", type = "login", password = "p", createdAt = 0, modifiedAt = 0))76+        val snapshotId = repo.getVaultHistory().first().id77+        Fixture(repo, storage, snapshotId)78+    }79+80+    private fun loginEntry(title: String) =81+        VaultEntry(id = "", title = title, type = "login", password = "p", createdAt = 0, modifiedAt = 0)82+83+    @Test fun happy_path_swaps_slots_locks_and_appends_restore_snapshot() = runBlocking {84+        val f = setup()85+        val slotABefore = f.storage.slotA86+        val slotBBefore = f.storage.slotB87+        val historyBefore = f.storage.history.snapshots.toList()88+89+        assertTrue(f.repo.restoreVaultSnapshot(f.snapshotId))90+91+        // Disk now holds the restored pair, not the prior pair.92+        assertNotEquals(slotABefore, f.storage.slotA)93+        assertNotEquals(slotBBefore, f.storage.slotB)94+        // Session is locked; the original "restore" entry was taken BEFORE the95+        // commit (it captures the pre-restore state), so it remains in history.96+        assertFalse(f.repo.isUnlocked.value)97+        assertEquals(historyBefore.size + 1, f.storage.history.snapshots.size)98+        assertEquals("restore", f.storage.history.snapshots.first().reason)99+        // Vault still opens under the same password and shows the restored entry set.100+        assertEquals(UnlockResult.Success, f.repo.unlock("pw"))101+        assertEquals(1, f.repo.getEntries().size)102+        assertTrue(f.repo.getEntries().any { it.title == "First" })103+        assertFalse(f.repo.getEntries().any { it.title == "Second" })104+    }105+106+    @Test fun write_slot_a_failure_keeps_slots_and_history_unchanged() = runBlocking {107+        val f = setup()108+        val slotABefore = f.storage.slotA109+        val slotBBefore = f.storage.slotB110+        val historyBefore = f.storage.history.snapshots.toList()111+        f.storage.failWriteSlotA = true112+113+        assertFalse(f.repo.restoreVaultSnapshot(f.snapshotId))114+115+        assertEquals(slotABefore, f.storage.slotA)116+        assertEquals(slotBBefore, f.storage.slotB)117+        assertEquals(historyBefore, f.storage.history.snapshots)118+        assertTrue(f.storage.history.snapshots.none { it.reason == "restore" })119+        // Session is still on the pre-restore key.120+        assertTrue(f.repo.isUnlocked.value)121+        assertEquals(UnlockResult.Success, f.repo.unlock("pw"))122+        assertTrue(f.repo.getEntries().any { it.title == "Second" })123+    }124+125+    @Test fun write_slot_b_failure_rolls_back_slot_a_and_history() = runBlocking {126+        val f = setup()127+        val slotABefore = f.storage.slotA128+        val slotBBefore = f.storage.slotB129+        val historyBefore = f.storage.history.snapshots.toList()130+        f.storage.failWriteSlotB = true131+132+        assertFalse(f.repo.restoreVaultSnapshot(f.snapshotId))133+134+        // The pre-restore pair is back on disk — without the rollback, the at-rest135+        // pair would be (restored A, prior B) and an at-rest observer could pick136+        // the real slot by size (MN-1).137+        assertEquals(slotABefore, f.storage.slotA)138+        assertEquals(slotBBefore, f.storage.slotB)139+        assertEquals(historyBefore, f.storage.history.snapshots)140+        assertTrue(f.storage.history.snapshots.none { it.reason == "restore" })141+        // Session still on prior key.142+        assertTrue(f.repo.isUnlocked.value)143+        assertEquals(UnlockResult.Success, f.repo.unlock("pw"))144+        assertTrue(f.repo.getEntries().any { it.title == "Second" })145+    }146+147+    @Test fun stamp_failure_rolls_back_both_slots_and_history() = runBlocking {148+        val f = setup()149+        val slotABefore = f.storage.slotA150+        val slotBBefore = f.storage.slotB151+        val historyBefore = f.storage.history.snapshots.toList()152+        f.storage.failStamp = true153+154+        assertFalse(f.repo.restoreVaultSnapshot(f.snapshotId))155+156+        // Stamp happens AFTER slot B is written; rollback must restore BOTH slots.157+        assertEquals(slotABefore, f.storage.slotA)158+        assertEquals(slotBBefore, f.storage.slotB)159+        assertEquals(historyBefore, f.storage.history.snapshots)160+        assertTrue(f.storage.history.snapshots.none { it.reason == "restore" })161+        // Vault still on prior key — both entries still readable.162+        assertEquals(UnlockResult.Success, f.repo.unlock("pw"))163+        assertTrue(f.repo.getEntries().any { it.title == "First" })164+        assertTrue(f.repo.getEntries().any { it.title == "Second" })165+    }166+167+    @Test fun unknown_snapshot_id_is_a_no_op() = runBlocking {168+        val f = setup()169+        val slotABefore = f.storage.slotA170+        val slotBBefore = f.storage.slotB171+        val historyBefore = f.storage.history.snapshots.toList()172+        assertTrue(f.repo.isUnlocked.value)173+174+        assertFalse(f.repo.restoreVaultSnapshot("does-not-exist"))175+176+        assertEquals(slotABefore, f.storage.slotA)177+        assertEquals(slotBBefore, f.storage.slotB)178+        assertEquals(historyBefore, f.storage.history.snapshots)179+        assertTrue(f.repo.isUnlocked.value)180+    }181+}\ No newline at end of file

view file