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
↓ peach-android-065b02e3.tar.gzatlas at this save
files changed4 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 −0app/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. */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 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 fileapp/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