4bbb8467f10a

fix: Fence vault sessions and scrub transient secrets (v2.8.5)

time
2026-07-29 21:01 UTC · 2 months ago
machine
koh2
parent
519a145e56fe
23 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+33 −8app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+14 −0app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+50 −0app/src/main/java/com/peachpasswords/sync/PendingVouchedDeviceStore.kt+65 −1app/src/main/java/com/peachpasswords/sync/SyncIdentity.kt+16 −4app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+131 −79app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+132 −27app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+13 −2app/src/main/java/com/peachpasswords/vault/BrowserSlot.kt+73 −21app/src/main/java/com/peachpasswords/vault/RecoveryWords.kt+64 −32app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+1023 −475app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+1 −0app/src/test/java/com/peachpasswords/sync/RegistryTransactionTest.kt+41 −0app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+71 −0app/src/test/java/com/peachpasswords/sync/VaultSyncManifestEpochRaceTest.kt+166 −0app/src/test/java/com/peachpasswords/vault/CodexRestoreAtomicityTest.kt+81 −0app/src/test/java/com/peachpasswords/vault/DuressSlotBTest.kt+9 −0app/src/test/java/com/peachpasswords/vault/HiddenSessionStateTest.kt+186 −3app/src/test/java/com/peachpasswords/vault/SessionEpochRaceTest.kt+169 −0app/src/test/java/com/peachpasswords/vault/SlotAUnlockTest.kt+43 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+3 −0
VERSION+1 −1
@@ -1 +1 @@1-2.8.41+2.8.5

view file

app/build.gradle+1 −1
@@ -32,7 +32,7 @@3232         minSdk 263333         targetSdk 353434         // Keep the Android code in Gradle; the semantic name comes from root VERSION.35-        versionCode 24014635+        versionCode 2401473636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+33 −8
@@ -130,6 +130,7 @@130130                 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)131131                 val scope = rememberCoroutineScope()132132                 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }133+                var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }133134                 var error by remember { mutableStateOf<String?>(null) }134135                 var busy by remember { mutableStateOf(false) }135136 @@ -148,7 +149,10 @@148149                 LaunchedEffect(unlocked) {149150                     if (!unlocked && canBiometric()) {150151                         tryBiometric(151-                            onUnlocked = { unlocked = true },152+                            onUnlocked = {153+                                unlocked = true154+                                unlockedEpoch = vault.unlockedSessionEpoch()155+                            },152156                             onFail = { },153157                         )154158                     }@@ -170,13 +174,24 @@170174                         error = error,171175                         busy = busy,172176                         canBiometric = canBiometric(),173-                        onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },177+                        onBiometric = {178+                            tryBiometric(179+                                onUnlocked = {180+                                    unlocked = true181+                                    unlockedEpoch = vault.unlockedSessionEpoch()182+                                },183+                                onFail = {},184+                            )185+                        },174186                         onUnlock = { password ->175187                             busy = true176188                             error = null177189                             scope.launch {178190                                 when (vault.unlock(password)) {179-                                    is UnlockResult.Success -> unlocked = true191+                                    is UnlockResult.Success -> {192+                                        unlocked = true193+                                        unlockedEpoch = vault.unlockedSessionEpoch()194+                                    }180195                                     UnlockResult.WrongPassword -> error = "Wrong master password."181196                                     else -> error = "Couldn't unlock."182197                                 }@@ -184,7 +199,12 @@184199                             }185200                         },186201                         onPick = { entry, confirmedTarget ->187-                            returnDataset(parsed, entry, confirmedTarget)202+                            val epoch = unlockedEpoch203+                            if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {204+                                cancel()205+                            } else {206+                                returnDataset(parsed, entry, confirmedTarget)207+                            }188208                         },189209                         onCreateNew = { confirmedTarget ->190210                             launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)@@ -211,12 +231,17 @@211231                 // so the caller-owned reference is now redundant. Zero it whether212232                 // the unlock succeeds or fails so the unwrapped biometric key213233                 // doesn't outlive the auth prompt.214-                val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()215234                 lifecycleScope.launch {235+                    // Unseal inside the launched ownership scope. If the236+                    // lifecycle scope is cancelled before execution, no key is237+                    // allocated before a finally can protect it.238+                    var key: ByteArray? = null216239                     try {217-                        if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()240+                        key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)241+                        if (key == null) return@launch onFail()242+                        if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()218243                     } finally {219-                        com.peachpasswords.vault.VaultCrypto.zero(key)244+                        key?.let(com.peachpasswords.vault.VaultCrypto::zero)220245                     }221246                 }222247             },@@ -654,4 +679,4 @@654679 }655680 656681 /** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */657-private const val SEARCH_DEBOUNCE_MS = 200L\ No newline at end of file682+private const val SEARCH_DEBOUNCE_MS = 200L

view file

app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+14 −0
@@ -20,6 +20,17 @@2020 private const val MAX_MANIFEST_STRING = 2562121 internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"2222 23+/**24+ * Fully staged membership change. Implementations must linearize the whole25+ * batch; callers hold the vault session epoch fence while invoking it, so a26+ * lock/re-unlock cannot observe or leave behind a half-applied manifest.27+ */28+data class DeviceManifestRegistryBatch(29+    val incomingRevocations: List<DeviceRevocation>,30+    val removals: Set<String>,31+    val upserts: List<PairedDevice>,32+)33+2334 interface PairedDeviceRegistry {2435     fun getAll(): List<PairedDevice>2536     fun get(id: String): PairedDevice?@@ -30,6 +41,9 @@3041     fun upsert(device: PairedDevice)3142     fun remove(deviceId: String) {}3243     fun listRevocations(): List<DeviceRevocation> = emptyList()44+45+    /** Commit a complete staged manifest as one logical registry mutation. */46+    fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean3347 3448     /** Returns the ids that were not already revoked. */3549     fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+50 −0
@@ -75,6 +75,39 @@7575         persist(getAll().filterNot { it.deviceId == deviceId })7676     }7777 78+    /**79+     * Commit a staged manifest in one synchronous SharedPreferences transaction.80+     * `apply()` is appropriate for ordinary UI writes, but it would expose each81+     * per-device edit independently to observers and could leave an old-session82+     * manifest half-applied across a lock. `commit()` updates both keys together83+     * in memory and does not return until the durable write has completed.84+     */85+    override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {86+        val priorPrefs = readPrefsState()87+        val currentDevices = getAll()88+        val currentRevocations = listRevocations()89+        val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()90+        val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }91+        val nextRevocations = (currentRevocations + freshRevocations).takeLast(MAX_REVOCATIONS)92+        val removed = batch.removals93+        val nextDevices = currentDevices94+            .filterNot { removed.contains(it.deviceId) }95+            .toMutableList()96+        batch.upserts.forEach { device ->97+            nextDevices.removeAll { it.deviceId == device.deviceId }98+            nextDevices += device99+        }100+101+        val nextPrefs = RegistryPrefsState(102+            devices = json.encodeToString(listSerializer, nextDevices),103+            revocations = json.encodeToString(revocationSerializer, nextRevocations),104+        )105+        // Explicitly restore the old encrypted-preference values when commit()106+        // reports false. The platform usually keeps them in memory, but a real107+        // store failure must not rely on that undocumented outcome.108+        return commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)109+    }110+78111     // ── Device revocation (Part 5 §C) ────────────────────────────────────────79112 80113     override fun listRevocations(): List<DeviceRevocation> {@@ -194,6 +227,23 @@194227         prefs.edit()195228             .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))196229             .apply()230+    }231+232+    private data class RegistryPrefsState(233+        val devices: String?,234+        val revocations: String?,235+    )236+237+    private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(238+        devices = prefs.getString(KEY_DEVICES, null),239+        revocations = prefs.getString(KEY_REVOCATIONS, null),240+    )241+242+    private fun writePrefsState(state: RegistryPrefsState): Boolean {243+        val edit = prefs.edit()244+        if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)245+        if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)246+        return edit.commit()197247     }198248 199249     private companion object {

view file

app/src/main/java/com/peachpasswords/sync/PendingVouchedDeviceStore.kt+65 −1
@@ -30,6 +30,26 @@3030     fun getAll(): List<PendingVouchedDevice>3131     fun upsert(device: PendingVouchedDevice)3232     fun remove(id: String)33+34+    /**35+     * Remove a set of pending introductions synchronously and report failure.36+     * Implementations must linearize the whole batch; the default exists only37+     * for small in-memory test registries that have no durable store.38+     */39+    fun removeBatch(ids: Set<String>): Boolean {40+        return runCatching {41+            ids.forEach(::remove)42+            true43+        }.getOrDefault(false)44+    }45+46+    /** Restore a previously captured pending set after a later trust commit fails. */47+    fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {48+        return runCatching {49+            devices.forEach(::upsert)50+            true51+        }.getOrDefault(false)52+    }3353 }3454 3555 @Singleton@@ -66,6 +86,21 @@6686         persist(getAll().filterNot { it.deviceId == id })6787     }6888 89+    override fun removeBatch(ids: Set<String>): Boolean {90+        if (ids.isEmpty()) return true91+        val before = getAll()92+        val after = before.filterNot { it.deviceId in ids }93+        return commitWithRollback(before, after, ::commitList)94+    }95+96+    override fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {97+        if (devices.isEmpty()) return true98+        val before = getAll()99+        val restoredIds = devices.map { it.deviceId }.toSet()100+        val after = (before.filterNot { it.deviceId in restoredIds } + devices).takeLast(MAX_PENDING)101+        return commitWithRollback(before, after, ::commitList)102+    }103+69104     fun clear() {70105         persist(emptyList())71106     }@@ -82,13 +117,42 @@82117     private fun persist(devices: List<PendingVouchedDevice>) {83118         prefs.edit()84119             .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))85-            .apply()120+            // Pending introductions participate in fail-closed trust ordering;121+            // an outstanding async apply could replay a stale row after a122+            // synchronous revocation/removal commit. Keep every write in this123+            // store synchronous so removal cannot be overwritten by a queued124+            // disk task after a crash.125+            .commit()86126     }87127 128+    /** Synchronous writer used by the fail-closed batch/rollback paths. */129+    private fun commitList(devices: List<PendingVouchedDevice>): Boolean =130+        prefs.edit()131+            .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))132+            .commit()133+88134     private companion object {89135         const val PREFS_FILE = "peach_pending_vouched_devices"90136         const val KEY_PENDING = "pending_vouched_devices"91137         const val MAX_PENDING = 5092138         val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }93139     }140+}141+142+/**143+ * Apply a durable state transition, explicitly restoring the prior in-process144+ * value when the forward commit reports failure. Android's SharedPreferences145+ * contract normally leaves the old value in memory on `commit()==false`, but146+ * relying on that implementation detail made failure injection indistinguishable147+ * from a successful transaction. Both registry stores use this helper.148+ */149+internal fun <T> commitWithRollback(150+    before: T,151+    after: T,152+    commit: (T) -> Boolean,153+): Boolean {154+    val applied = runCatching { commit(after) }.getOrDefault(false)155+    if (applied) return true156+    runCatching { commit(before) }157+    return false94158 }

view file

app/src/main/java/com/peachpasswords/sync/SyncIdentity.kt+16 −4
@@ -1,5 +1,6 @@11 package com.peachpasswords.sync22 3+import com.peachpasswords.vault.VaultCrypto34 import org.bouncycastle.crypto.digests.Blake3Digest45 import org.bouncycastle.crypto.params.X25519PrivateKeyParameters56 import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters@@ -35,8 +36,13 @@3536     val digest = Blake3Digest(256)3637     digest.update(buf, 0, buf.size)3738     val out = ByteArray(32)38-    digest.doFinal(out, 0)39-    return out.joinToString("") { "%02x".format(it) }39+    return try {40+        digest.doFinal(out, 0)41+        out.joinToString("") { "%02x".format(it) }42+    } finally {43+        VaultCrypto.zero(out)44+        VaultCrypto.zero(buf)45+    }4046 }4147 4248 fun deriveSyncSigningKeypair(masterKey: ByteArray, deviceId: String): SyncSigningKeypair {@@ -44,8 +50,14 @@4450     require(deviceId.isNotEmpty()) { "deviceId must not be empty" }4551     val info = deviceId.encodeToByteArray()4652     val seed = hkdf(masterKey, SYNC_SIGNING_SALT, info, 32)47-    val privateKey = Ed25519PrivateKeyParameters(seed, 0)48-    return SyncSigningKeypair(privateKey = privateKey, publicKeyRaw = privateKey.generatePublicKey().encoded)53+    return try {54+        val privateKey = Ed25519PrivateKeyParameters(seed, 0)55+        SyncSigningKeypair(privateKey = privateKey, publicKeyRaw = privateKey.generatePublicKey().encoded)56+    } finally {57+        // Ed25519PrivateKeyParameters copies the seed into its own immutable58+        // key object; the HKDF output itself is only constructor workspace.59+        VaultCrypto.zero(seed)60+    }4961 }5062 5163 fun verifyEd25519(publicKeyRaw: ByteArray, message: ByteArray, signature: ByteArray): Boolean {

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+131 −79
@@ -46,14 +46,25 @@4646 4747     override suspend fun getLocalVersion(): Long = repo.getVaultVersion()4848 49-    override suspend fun buildEntryManifest(): Map<String, Long> =50-        repo.getEntries().associate { it.id to it.modifiedAt }49+    override suspend fun buildEntryManifest(): Map<String, Long> {50+        val snapshot = requireMasterKeySnapshot()51+        VaultCrypto.zero(snapshot.key)52+        val manifest = repo.getEntries().associate { it.id to it.modifiedAt }53+        if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()54+        return manifest55+    }5156 52-    override suspend fun buildTombstoneManifest(): Map<String, Long> =53-        repo.getTombstones().associate { it.id to it.deletedAt }57+    override suspend fun buildTombstoneManifest(): Map<String, Long> {58+        val snapshot = requireMasterKeySnapshot()59+        VaultCrypto.zero(snapshot.key)60+        val manifest = repo.getTombstones().associate { it.id to it.deletedAt }61+        if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()62+        return manifest63+    }5464 5565     override suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String> {56-        val master = requireMasterKey()66+        val snapshot = requireMasterKeySnapshot()67+        val master = snapshot.key5768         try {5869             val out = LinkedHashMap<String, String>()5970             for (id in entryIds) {@@ -69,14 +80,16 @@6980                     entryKey?.let(VaultCrypto::zero)7081                 }7182             }83+            if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()7284             return out7385         } finally {74-            master.fill(0)86+            VaultCrypto.zero(master)7587         }7688     }7789 7890     override suspend fun applyEntries(entries: Map<String, String>) {79-        val master = requireMasterKey()91+        val snapshot = requireMasterKeySnapshot()92+        val master = snapshot.key8093         val decrypted = try {8194             entries.mapNotNull { (id, envelope) ->8295                 // M-05: per-entry derived key is used exactly once below; zero it@@ -94,10 +107,10 @@94107                 if (entry != null && entry.id == id) entry else null95108             }96109         } finally {97-            master.fill(0)110+            VaultCrypto.zero(master)98111         }99112         if (decrypted.isNotEmpty()) {100-            repo.applyMergedRemoteEntries(decrypted, emptyList())113+            repo.applyMergedRemoteEntries(decrypted, emptyList(), expectedEpoch = snapshot.epoch)101114         }102115     }103116 @@ -106,7 +119,9 @@106119             .filterValues { it > 0L }107120             .map { (id, ts) -> Tombstone(id, ts) }108121         if (cleaned.isNotEmpty()) {109-            repo.applyMergedRemoteEntries(emptyList(), cleaned)122+            val snapshot = requireMasterKeySnapshot()123+            VaultCrypto.zero(snapshot.key)124+            repo.applyMergedRemoteEntries(emptyList(), cleaned, expectedEpoch = snapshot.epoch)110125         }111126     }112127 @@ -118,7 +133,8 @@118133 119134     override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? {120135         val registry = pairedDevices ?: return null121-        val master = requireMasterKey()136+        val snapshot = requireMasterKeySnapshot()137+        val master = snapshot.key122138         return try {123139             val signing = deriveSyncSigningKeypair(master, ourDeviceId)124140             val revoked = registry.listRevocations()@@ -126,14 +142,16 @@126142             val devices = registry.getAll()127143                 .filterNot { revokedIds.contains(it.deviceId) }128144                 .mapNotNull { it.toManifestEntry() }129-            buildSignedDeviceManifest(145+            val manifest = buildSignedDeviceManifest(130146                 ourDeviceId,131147                 devices,132148                 signing.privateKey,133149                 revoked = revoked,134150             )151+            if (!repo.isSessionEpochCurrent(snapshot.epoch)) throw VaultLockedException()152+            manifest135153         } finally {136-            master.fill(0)154+            VaultCrypto.zero(master)137155         }138156     }139157 @@ -155,86 +173,119 @@155173         val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return156174         val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return157175         if (body.issuerDeviceId != issuer.deviceId) return158-        val master = requireMasterKey()176+        val snapshot = requireMasterKeySnapshot()177+        val master = snapshot.key159178         val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)160179 161180         try {162-            // Revocations are absorbed BEFORE any vouching, so a manifest that both163-            // revokes and re-vouches the same device cannot race itself. A peer may164-            // not revoke US — that would let one compromised device sever the whole165-            // mesh — so our own id is filtered out first.166-            val newlyRevoked = registry.mergeRevocations(167-                body.revoked.filterNot { it.deviceId == ourDeviceId },168-            )169-            for (deviceId in newlyRevoked) {170-                registry.remove(deviceId)171-                pending.remove(deviceId)172-                syncAlerts?.record(173-                    listOf(174-                        SyncAlert(175-                            type = SyncAlertType.DEVICE_REVOKED,176-                            deviceName = issuer.deviceName ?: "paired device",177-                            entryTitle = deviceId,178-                            timestamp = System.currentTimeMillis(),179-                        ),180-                    ),181+            if (!repo.isSessionEpochCurrent(snapshot.epoch)) return182+            // Stage the complete result against one read-only registry snapshot.183+            // No registry mutation is allowed until the epoch-fenced batch below;184+            // a per-item epoch check would still leave a prefix behind if lock()185+            // wins between two items.186+            val incomingRevocations = body.revoked.filterNot { it.deviceId == ourDeviceId }187+            val currentRevocations = registry.listRevocations()188+            val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()189+            val newlyRevoked = incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }190+            val revokedIds = (knownRevokedIds + incomingRevocations.map { it.deviceId }).toSet()191+            val revokedKeys = (currentRevocations + incomingRevocations)192+                .map { it.longTermPublicKeyHex.lowercase() }193+                .filterNot { it == NO_KEY_SENTINEL }194+                .toSet()195+            fun revokedInStagedBatch(deviceId: String, longTermKey: String): Boolean {196+                return deviceId in revokedIds || longTermKey.lowercase() in revokedKeys197+            }198+199+            val stagedUpserts = LinkedHashMap<String, PairedDevice>()200+            val stagedAlerts = ArrayList<SyncAlert>()201+            newlyRevoked.forEach { revocation ->202+                stagedAlerts += SyncAlert(203+                    type = SyncAlertType.DEVICE_REVOKED,204+                    deviceName = issuer.deviceName ?: "paired device",205+                    entryTitle = revocation.deviceId,206+                    timestamp = System.currentTimeMillis(),181207                 )182208             }183209             for (entry in body.devices) {184210                 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue185-                if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue186-                val existing = registry.get(entry.deviceId)211+                if (revokedInStagedBatch(entry.deviceId, entry.longTermPublicKeyHex)) continue212+                val existing = stagedUpserts[entry.deviceId] ?: registry.get(entry.deviceId)187213                 val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)188214                 if (existing == null) {189215                     if (meshRoute == null) continue190-                    registry.upsert(191-                        PairedDevice(192-                            deviceId = entry.deviceId,193-                            deviceName = entry.deviceName,194-                            deviceType = entry.deviceType,195-                            longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),196-                            signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),197-                            trustSource = "vouched",198-                            wsPort = entry.wsPort,199-                            rendezvousSecretHex = meshRoute.first,200-                            relayUrl = meshRoute.second,201-                            pairedAt = entry.pairedAt,202-                        ),216+                    stagedUpserts[entry.deviceId] = PairedDevice(217+                        deviceId = entry.deviceId,218+                        deviceName = entry.deviceName,219+                        deviceType = entry.deviceType,220+                        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),221+                        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),222+                        trustSource = "vouched",223+                        wsPort = entry.wsPort,224+                        rendezvousSecretHex = meshRoute.first,225+                        relayUrl = meshRoute.second,226+                        pairedAt = entry.pairedAt,203227                     )204-                    pending.remove(entry.deviceId)205228                 } else {206-                    deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->207-                        syncAlerts?.record(listOf(alert))208-                    }209-                    registry.upsert(210-                        existing.copy(211-                            deviceName = existing.deviceName?.takeIf { it.isNotBlank() }212-                                ?: entry.deviceName,213-                            deviceType = entry.deviceType,214-                            longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),215-                            signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),216-                            wsPort = existing.wsPort ?: entry.wsPort,217-                            rendezvousSecretHex =218-                                if (existing.trustSource == "vouched" && meshRoute != null) {219-                                    meshRoute.first220-                                } else {221-                                    existing.rendezvousSecretHex222-                                },223-                            relayUrl =224-                                if (existing.trustSource == "vouched" && meshRoute != null) {225-                                    meshRoute.second226-                                } else {227-                                    existing.relayUrl228-                                },229-                            pairedAt = existing.pairedAt,230-                            trustSource = existing.trustSource ?: "direct",231-                        ),229+                    deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let(stagedAlerts::add)230+                    stagedUpserts[entry.deviceId] = existing.copy(231+                        deviceName = existing.deviceName?.takeIf { it.isNotBlank() }232+                            ?: entry.deviceName,233+                        deviceType = entry.deviceType,234+                        longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),235+                        signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),236+                        wsPort = existing.wsPort ?: entry.wsPort,237+                        rendezvousSecretHex =238+                            if (existing.trustSource == "vouched" && meshRoute != null) {239+                                meshRoute.first240+                            } else {241+                                existing.rendezvousSecretHex242+                            },243+                        relayUrl =244+                            if (existing.trustSource == "vouched" && meshRoute != null) {245+                                meshRoute.second246+                            } else {247+                                existing.relayUrl248+                            },249+                        pairedAt = existing.pairedAt,250+                        trustSource = existing.trustSource ?: "direct",232251                     )233252                 }234253             }254+255+            val batch = DeviceManifestRegistryBatch(256+                incomingRevocations = incomingRevocations,257+                removals = newlyRevoked.map { it.deviceId }.toSet(),258+                upserts = stagedUpserts.values.toList(),259+            )260+            // Remove any matching pending introductions synchronously BEFORE261+            // installing trust. If the registry commit fails, restoring this262+            // exact pending subset is safe; if restoration also fails, the263+            // fail-closed outcome is a lost introduction, never stale trust.264+            val pendingIds = batch.removals + stagedUpserts.keys265+            val priorPending = pending.getAll().filter { it.deviceId in pendingIds }266+            val committed = repo.withSessionEpochCommit(snapshot.epoch) {267+                val pendingRemoved = runCatching { pending.removeBatch(pendingIds) }.getOrDefault(false)268+                if (!pendingRemoved) {269+                    pending.restoreBatch(priorPending)270+                    false271+                } else {272+                    val registryCommitted = runCatching { registry.applyManifestBatch(batch) }.getOrDefault(false)273+                    if (registryCommitted) {274+                        // Pending entries and alerts are kept inside the same275+                        // epoch fence. Alerts follow the trust commit and never276+                        // participate in its semantics.277+                        if (stagedAlerts.isNotEmpty()) syncAlerts?.record(stagedAlerts)278+                        true279+                    } else {280+                        pending.restoreBatch(priorPending)281+                        false282+                    }283+                }284+            }285+            if (committed != true) return235286         } finally {236-            master.fill(0)237-            localKeys.secretKey.fill(0)287+            VaultCrypto.zero(master)288+            VaultCrypto.zero(localKeys.secretKey)238289         }239290     }240291 @@ -256,8 +307,9 @@256307         }257308     }.getOrNull()258309 259-    private fun requireMasterKey(): ByteArray =260-        repo.syncMasterKey() ?: throw VaultLockedException()310+    private fun requireMasterKeySnapshot(): VaultRepository.SyncMasterKeySnapshot =311+        repo.syncMasterKeySnapshot() ?: throw VaultLockedException()312+261313 262314     private fun deriveEntryKey(masterKey: ByteArray, entryId: String): ByteArray {263315         // Null-byte separator (HKDF hardening): entryId cannot contain U+0000, so

view file

app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+132 −27
@@ -98,36 +98,133 @@9898     fun postUnlockDestination(): PeachNav =9999         postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())100100 101+    /**102+     * Owns candidate unlock tokens for the whole concurrent-derivation scope.103+     *104+     * Argon2id is deliberately not cancellable.  A token can therefore be105+     * produced after the caller has been cancelled unless ownership is claimed106+     * by the child immediately, before that child completes.  The scope closes107+     * only after [coroutineScope] has joined both children, so every registered108+     * loser is discarded even when cancellation happens between either await.109+     */110+    private class UnlockCandidates(private val vault: VaultRepository) : AutoCloseable {111+        private val lock = Any()112+        private var closed = false113+        private var slotA: VaultRepository.SlotAUnlock? = null114+        private var duress: VaultRepository.DuressUnlock? = null115+116+        fun registerSlotA(candidate: VaultRepository.SlotAUnlock) {117+            val discard = synchronized(lock) {118+                if (closed) true else {119+                    slotA?.let(vault::discardSlotAUnlock)120+                    slotA = candidate121+                    false122+                }123+            }124+            if (discard) vault.discardSlotAUnlock(candidate)125+        }126+127+        fun registerDuress(candidate: VaultRepository.DuressUnlock) {128+            val discard = synchronized(lock) {129+                if (closed) true else {130+                    duress?.let(vault::discardDuressUnlock)131+                    duress = candidate132+                    false133+                }134+            }135+            if (discard) vault.discardDuressUnlock(candidate)136+        }137+138+        fun takeSlotA(): VaultRepository.SlotAUnlock? = synchronized(lock) {139+            slotA.also { slotA = null }140+        }141+142+        fun takeDuress(): VaultRepository.DuressUnlock? = synchronized(lock) {143+            duress.also { duress = null }144+        }145+146+        override fun close() {147+            val candidates = synchronized(lock) {148+                if (closed) return149+                closed = true150+                val result = slotA to duress151+                slotA = null152+                duress = null153+                result154+            }155+            candidates.first?.let(vault::discardSlotAUnlock)156+            candidates.second?.let(vault::discardDuressUnlock)157+        }158+    }159+101160     fun unlock(password: String) {102161         if (password.isBlank() || _state.value.loading) return103162         _state.update { it.copy(loading = true, error = null) }104163         viewModelScope.launch {105-            // Derive slot A and the duress slot (slot B) CONCURRENTLY so the wall-clock106-            // time to reject/accept never reveals whether the entered password was the107-            // real password, the duress passphrase, or simply wrong (MF-5/MF-6, mirrors108-            // browser DUR-002). Both Argon2id derivations run to completion; the branch109-            // happens only after both finish. Each derivation is non-committing (no110-            // mutex, no session mutation) so the loser is simply discarded.111-            val (duressUnlock, slotA) = withContext(Dispatchers.Default) {112-                val duressDeferred = async { if (duress.isConfigured()) vault.tryUnlockDuress(password) else null }113-                val slotADeferred = async { vault.tryUnlockSlotA(password) }114-                duressDeferred.await() to slotADeferred.await()115-            }116-            when {117-                // Duress takes precedence: the duress passphrase never reaches the real vault.118-                duressUnlock != null -> {119-                    slotA?.let(vault::discardSlotAUnlock)120-                    handleDuress(duressUnlock)164+            val candidates = UnlockCandidates(vault)165+            try {166+                // Derive slot A and the duress slot (slot B) CONCURRENTLY so the167+                // wall-clock time to reject/accept never reveals which password was168+                // entered.  Each child registers its token before it completes.169+                withContext(Dispatchers.Default) {170+                    kotlinx.coroutines.coroutineScope {171+                        val duressDeferred = async {172+                            if (duress.isConfigured()) {173+                                vault.tryUnlockDuress(password)?.also(candidates::registerDuress)174+                            } else null175+                        }176+                        val slotADeferred = async {177+                            vault.tryUnlockSlotA(password)?.also(candidates::registerSlotA)178+                        }179+                        // Await both children before choosing a winner.  The180+                        // candidates object, not a post-await local, owns tokens.181+                        duressDeferred.await()182+                        slotADeferred.await()183+                    }121184                 }122-                slotA != null -> {123-                    vault.commitSlotAUnlock(slotA)124-                    lockTimer.start()125-                    foregroundSync.triggerForegroundSync()126-                    _state.update { it.copy(loading = false, unlocked = true) }185+186+                when (val duressUnlock = candidates.takeDuress()) {187+                    // Duress takes precedence: the duress passphrase never reaches188+                    // the real vault.  Taking it transfers ownership to this branch.189+                    null -> when (val slotAUnlock = candidates.takeSlotA()) {190+                        null -> _state.update {191+                            it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)192+                        }193+                        else -> try {194+                            when (val result = vault.commitSlotAUnlock(slotAUnlock)) {195+                                is UnlockResult.Success -> {196+                                    lockTimer.start()197+                                    foregroundSync.triggerForegroundSync()198+                                    _state.update { it.copy(loading = false, unlocked = true) }199+                                }200+                                is UnlockResult.Error -> _state.update {201+                                    it.copy(loading = false, error = result.reason, shakeNonce = it.shakeNonce + 1)202+                                }203+                                is UnlockResult.WrongPassword -> _state.update {204+                                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)205+                                }206+                            }207+                        } finally {208+                            // If cancellation happens while commit waits on the209+                            // repository mutex, this transferred token still owns210+                            // its key until this finally discards it.211+                            vault.discardSlotAUnlock(slotAUnlock)212+                        }213+                    }214+                    else -> {215+                        candidates.takeSlotA()?.let(vault::discardSlotAUnlock)216+                        try {217+                            handleDuress(duressUnlock)218+                        } finally {219+                            vault.discardDuressUnlock(duressUnlock)220+                        }221+                    }127222                 }128-                else -> _state.update {129-                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)130-                }223+            } finally {224+                // coroutineScope has joined all children before this closes the225+                // owner, covering cancellation at either await and cancellation226+                // while the winner waits on the repository mutex.227+                candidates.close()131228             }132229         }133230     }@@ -196,10 +293,18 @@196293             crypto = BiometricPrompt.CryptoObject(cipher),197294             onSuccess = { result ->198295                 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto199-                val key = BiometricKeyStore.unwrap(appContext, used) ?: return@authenticateCrypto200296                 viewModelScope.launch {201-                    val r = vault.unlockWithKey(key)202-                    VaultCrypto.zero(key)297+                    // Allocate/unseal only inside the launched ownership scope:298+                    // if viewModelScope is cancelled before execution, no key is299+                    // created that could be stranded before finally runs.300+                    var key: ByteArray? = null301+                    val r = try {302+                        key = BiometricKeyStore.unwrap(appContext, used)303+                        if (key == null) return@launch304+                        vault.unlockWithKey(key!!)305+                    } finally {306+                        key?.let(VaultCrypto::zero)307+                    }203308                     when (r) {204309                         is UnlockResult.Success -> {205310                             lockTimer.start()

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+13 −2
@@ -142,9 +142,21 @@142142         // can predate the revocation that names it, so revocation is re-checked143143         // here rather than trusted to have swept the queue.144144         if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {145-            pendingVouchedStore.remove(id)145+            pendingVouchedStore.removeBatch(setOf(id))146146             return@launch147147         }148+        // Never let a stale queue item overwrite an already trusted row (in149+        // particular a direct SAS-paired row). The queued introduction is150+        // consumed, but the established trust record remains authoritative.151+        if (deviceStore.get(pending.deviceId) != null) {152+            pendingVouchedStore.removeBatch(setOf(id))153+            return@launch154+        }155+        // Remove the pending row synchronously before publishing trust. If the156+        // subsequent upsert fails, the safe outcome is a lost introduction,157+        // not a restart that replays stale pending trust over a revocation.158+        if (!pendingVouchedStore.removeBatch(setOf(id))) return@launch159+        if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) return@launch148160         deviceStore.upsert(149161             PairedDevice(150162                 deviceId = pending.deviceId,@@ -157,7 +169,6 @@157169                 pairedAt = pending.pairedAt,158170             ),159171         )160-        pendingVouchedStore.remove(id)161172     }162173 163174     fun rejectVouchedDevice(id: String) = viewModelScope.launch {

view file

app/src/main/java/com/peachpasswords/vault/BrowserSlot.kt+73 −21
@@ -62,12 +62,20 @@6262      * they are very different things to a user holding a paper Codex.6363      */6464     fun toAndroidSlot(browser: BrowserSlotA, password: String): StoredVault? {65-        val salt = unhex(browser.salt)66-        val key = VaultCrypto.deriveKey(password, salt)65+        var salt: ByteArray? = null66+        var key: ByteArray? = null67+        var sealed: ByteArray? = null68+        var payloadJson: ByteArray? = null69+        var plainBytes: ByteArray? = null70+        var encoded: ByteArray? = null71+        var encrypted: ByteArray? = null72+        var rootKey: ByteArray? = null6773         try {68-            val sealed = unhex(browser.nonce) + unhex(browser.ciphertext) + unhex(browser.tag)69-            val payloadJson = try {70-                VaultCrypto.decrypt(sealed, key, VaultCrypto.AAD_SLOT_A)74+            salt = unhex(browser.salt)75+            key = VaultCrypto.deriveKey(password, salt!!)76+            sealed = sealedHex(browser.nonce, browser.ciphertext, browser.tag)77+            payloadJson = try {78+                VaultCrypto.decrypt(sealed!!, key!!, VaultCrypto.AAD_SLOT_A)7179             } catch (_: Exception) {7280                 return null7381             }@@ -81,23 +89,33 @@8189             // payload ended, reporting an offset equal to the payload's own8290             // length. It looked like a corrupt Codex; the vault was fine and8391             // the password was right.84-            val plain = payloadJson.toString(Charsets.UTF_8).trimBucketPadding()85-            val payload = VaultCodec.decode(plain.toByteArray(Charsets.UTF_8))92+            val plain = payloadJson!!.toString(Charsets.UTF_8).trimBucketPadding()93+            plainBytes = plain.toByteArray(Charsets.UTF_8)94+            val payload = VaultCodec.decode(plainBytes!!)8695 87-            val rootKey = unwrapRootKey(browser, key)96+            rootKey = unwrapRootKey(browser, key!!)8897             val migrated = payload.copy(8998                 rootKeyB64 = rootKey?.let { b64(it) } ?: payload.rootKeyB64,9099             )91-            rootKey?.let(VaultCrypto::zero)92100 101+            encoded = VaultCodec.encode(migrated)102+            encrypted = VaultCrypto.encrypt(encoded!!, key!!, VaultCrypto.AAD_SLOT_A)103+93104             return StoredVault(94105                 version = 1,95-                salt = b64(salt),96-                blob = b64(VaultCrypto.encrypt(VaultCodec.encode(migrated), key, VaultCrypto.AAD_SLOT_A)),106+                salt = b64(salt!!),107+                blob = b64(encrypted!!),97108                 rootKeyEscrow = browser.toRootKeyEscrow(),98109             )99110         } finally {100-            VaultCrypto.zero(key)111+            encrypted?.let(VaultCrypto::zero)112+            encoded?.let(VaultCrypto::zero)113+            plainBytes?.let(VaultCrypto::zero)114+            payloadJson?.let(VaultCrypto::zero)115+            sealed?.let(VaultCrypto::zero)116+            rootKey?.let(VaultCrypto::zero)117+            salt?.let(VaultCrypto::zero)118+            key?.let(VaultCrypto::zero)101119         }102120     }103121 @@ -106,14 +124,22 @@106124         val ct = browser.pwRootCiphertext ?: return null107125         val nonce = browser.pwRootNonce ?: return null108126         val tag = browser.pwRootTag ?: return null109-        return runCatching {110-            val hexBytes = VaultCrypto.decrypt(111-                unhex(nonce) + unhex(ct) + unhex(tag),127+        var sealed: ByteArray? = null128+        var hexBytes: ByteArray? = null129+        return try {130+            sealed = sealedHex(nonce, ct, tag)131+            hexBytes = VaultCrypto.decrypt(132+                sealed!!,112133                 derivedKey,113134                 VaultCrypto.AAD_ROOT_KEY,114135             )115-            unhex(hexBytes.toString(Charsets.UTF_8).trimBucketPadding())116-        }.getOrNull()136+            unhex(hexBytes!!.toString(Charsets.UTF_8).trimBucketPadding())137+        } catch (_: Exception) {138+            null139+        } finally {140+            hexBytes?.let(VaultCrypto::zero)141+            sealed?.let(VaultCrypto::zero)142+        }117143     }118144 119145     private fun BrowserSlotA.toRootKeyEscrow(): RootKeyEscrow? {@@ -122,11 +148,37 @@122148         val n = recoveryNonce ?: return null123149         val t = recoveryTag ?: return null124150         return RootKeyEscrow(125-            salt = b64(unhex(s)),126-            wrappedRootKey = b64(unhex(w)),127-            nonce = b64(unhex(n)),128-            tag = b64(unhex(t)),151+            salt = b64Hex(s),152+            wrappedRootKey = b64Hex(w),153+            nonce = b64Hex(n),154+            tag = b64Hex(t),129155         )156+    }157+158+    /** Decode concatenated browser hex fields while scrubbing each component. */159+    private fun sealedHex(vararg parts: String): ByteArray {160+        val decoded = ArrayList<ByteArray>(parts.size)161+        return try {162+            parts.forEach { decoded += unhex(it) }163+            val out = ByteArray(decoded.sumOf { it.size })164+            var offset = 0165+            decoded.forEach {166+                System.arraycopy(it, 0, out, offset, it.size)167+                offset += it.size168+            }169+            out170+        } finally {171+            decoded.forEach(VaultCrypto::zero)172+        }173+    }174+175+    private fun b64Hex(hex: String): String {176+        val bytes = unhex(hex)177+        return try {178+            b64(bytes)179+        } finally {180+            VaultCrypto.zero(bytes)181+        }130182     }131183 132184     private fun unhex(s: String): ByteArray {

view file

app/src/main/java/com/peachpasswords/vault/RecoveryWords.kt+64 −32
@@ -13,26 +13,34 @@1313 fun rootKeyToWords(rootKeyBytes: ByteArray): List<String> {1414     require(rootKeyBytes.size == 32) { "root key must be 32 bytes" }1515     val entropy16 = rootKeyBytes.copyOfRange(0, 16)16-    val entropyHash = MessageDigest.getInstance("SHA-256").digest(entropy16)16+    val entropyHash = try {17+        MessageDigest.getInstance("SHA-256").digest(entropy16)18+    } finally {19+        VaultCrypto.zero(entropy16)20+    }1721     // Mask to a byte before the unsigned shift: a Byte >= 0x80 sign-extends to a1822     // negative Int, so `toInt() ushr 4` would yield a 28-bit value, not the 4-bit1923     // checksum. The browser reads a Uint8 (`recovery-words.ts:36`). Without the2024     // mask, ~50% of root keys (SHA[0] >= 0x80) produce non-browser-parity words.21-    val checksum = (entropyHash[0].toInt() and 0xff) ushr 425+    try {26+        val checksum = (entropyHash[0].toInt() and 0xff) ushr 42227 23-    var bitsBig = BigInteger.ZERO24-    for (i in 0 until 16) {25-        bitsBig = bitsBig.shiftLeft(8).or(BigInteger.valueOf((rootKeyBytes[i].toInt() and 0xff).toLong()))26-    }27-    bitsBig = bitsBig.shiftLeft(4).or(BigInteger.valueOf(checksum.toLong()))28+        var bitsBig = BigInteger.ZERO29+        for (i in 0 until 16) {30+            bitsBig = bitsBig.shiftLeft(8).or(BigInteger.valueOf((rootKeyBytes[i].toInt() and 0xff).toLong()))31+        }32+        bitsBig = bitsBig.shiftLeft(4).or(BigInteger.valueOf(checksum.toLong()))2833 29-    val words = ArrayList<String>(TOTAL_WORDS)30-    for (i in 0 until TOTAL_WORDS) {31-        val shift = (TOTAL_WORDS - 1 - i) * WORD_BITS32-        val index = bitsBig.shiftRight(shift).and(BigInteger.valueOf(0x7ffL)).toInt()33-        words += WORD_LIST[index]34+        val words = ArrayList<String>(TOTAL_WORDS)35+        for (i in 0 until TOTAL_WORDS) {36+            val shift = (TOTAL_WORDS - 1 - i) * WORD_BITS37+            val index = bitsBig.shiftRight(shift).and(BigInteger.valueOf(0x7ffL)).toInt()38+            words += WORD_LIST[index]39+        }40+        return words41+    } finally {42+        VaultCrypto.zero(entropyHash)3443     }35-    return words3644 }3745 3846 fun wordsToRootKeyMaterial(words: List<String>): ByteArray {@@ -52,18 +60,24 @@5260     }5361 5462     val entropy = ByteArray(16)55-    for (i in 0 until 16) {56-        val byteShift = 124 - i * 857-        entropy[i] = bitsBig.shiftRight(byteShift).and(BigInteger.valueOf(0xffL)).toByte()58-    }63+    var entropyHash: ByteArray? = null64+    return try {65+        for (i in 0 until 16) {66+            val byteShift = 124 - i * 867+            entropy[i] = bitsBig.shiftRight(byteShift).and(BigInteger.valueOf(0xffL)).toByte()68+        }5969 60-    val storedChecksum = bitsBig.and(BigInteger.valueOf(0xFL)).toInt()61-    val entropyHash = MessageDigest.getInstance("SHA-256").digest(entropy)62-    // See rootKeyToWords: mask to a byte before the unsigned shift (browser parity).63-    val computedChecksum = (entropyHash[0].toInt() and 0xff) ushr 464-    require(storedChecksum == computedChecksum) { "invalid recovery words — checksum mismatch" }70+        val storedChecksum = bitsBig.and(BigInteger.valueOf(0xFL)).toInt()71+        entropyHash = MessageDigest.getInstance("SHA-256").digest(entropy)72+        // See rootKeyToWords: mask to a byte before the unsigned shift (browser parity).73+        val computedChecksum = (entropyHash!![0].toInt() and 0xff) ushr 474+        require(storedChecksum == computedChecksum) { "invalid recovery words — checksum mismatch" }6575 66-    return ByteArray(32).also { it.fill(0); System.arraycopy(entropy, 0, it, 0, entropy.size) }76+        ByteArray(32).also { System.arraycopy(entropy, 0, it, 0, entropy.size) }77+    } finally {78+        entropyHash?.let(VaultCrypto::zero)79+        VaultCrypto.zero(entropy)80+    }6781 }6882 6983 /**@@ -77,10 +91,17 @@7791  */7892 fun generateRecoverableRootKey(): ByteArray {7993     val candidate = ByteArray(32).also(java.security.SecureRandom()::nextBytes)80-    check(runCatching { wordsToRootKeyMaterial(rootKeyToWords(candidate)) }.isSuccess) {81-        "generated a root key whose recovery words do not round-trip"94+    var roundTrip: ByteArray? = null95+    return try {96+        roundTrip = runCatching { wordsToRootKeyMaterial(rootKeyToWords(candidate)) }.getOrNull()97+        check(roundTrip != null) { "generated a root key whose recovery words do not round-trip" }98+        candidate99+    } catch (e: Throwable) {100+        VaultCrypto.zero(candidate)101+        throw e102+    } finally {103+        roundTrip?.let(VaultCrypto::zero)82104     }83-    return candidate84105 }85106 86107 /** Lowercase, unseparated hex of [bytes] — the exact ASCII the browser wraps in the escrow. */@@ -96,8 +117,14 @@96117     val generator = HKDFBytesGenerator(SHA256Digest())97118     generator.init(HKDFParameters(wordMaterial, salt, "peach-recovery-v1".encodeToByteArray()))98119     val out = ByteArray(32)99-    generator.generateBytes(out, 0, out.size)100-    return SecretKeySpec(out, "AES")120+    return try {121+        generator.generateBytes(out, 0, out.size)122+        SecretKeySpec(out, "AES")123+    } finally {124+        // SecretKeySpec copies its constructor input; the HKDF workspace is125+        // never returned to the caller and can be scrubbed immediately.126+        VaultCrypto.zero(out)127+    }101128 }102129 103130 fun findInvalidWords(words: List<String>): List<Pair<Int, String>> {@@ -124,9 +151,14 @@124151  */125152 fun recoveryWordsHash(words: List<String>): String {126153     val canonical = words.joinToString(" ") { it.trim().lowercase() }.encodeToByteArray()127-    val digest = org.bouncycastle.crypto.digests.Blake3Digest(256)128-    digest.update(canonical, 0, canonical.size)129154     val out = ByteArray(32)130-    digest.doFinal(out, 0)131-    return out.joinToString("") { "%02x".format(it) }155+    return try {156+        val digest = org.bouncycastle.crypto.digests.Blake3Digest(256)157+        digest.update(canonical, 0, canonical.size)158+        digest.doFinal(out, 0)159+        out.joinToString("") { "%02x".format(it) }160+    } finally {161+        VaultCrypto.zero(out)162+        VaultCrypto.zero(canonical)163+    }132164 }

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+1023 −475
@@ -85,6 +85,24 @@8585     private val rng = SecureRandom()8686 8787     /**88+     * Monotonic session generation.  [lock] advances this before clearing any89+     * secret state; work that captured the previous generation is therefore90+     * stale even if it is still running its (non-suspending) storage step.91+     */92+    private var sessionEpoch: Long = 0L93+94+    private fun nextSessionEpoch(epoch: Long): Long =95+        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L96+97+    /** Test seam for deterministic "lock while waiting to commit" races. */98+    @VisibleForTesting99+    internal var beforeDurableCommit: () -> Unit = {}100+101+    /** Test seam after a failed Codex rollback, while the epoch fence is held. */102+    @VisibleForTesting103+    internal var afterCodexRollback: () -> Unit = {}104+105+    /**88106      * Run [block] with [stateLock] held. Used to fence the small state-write89107      * portions of suspend operations against [lock] and [requireUnlocked]90108      * racing from another thread.@@ -94,12 +112,77 @@94112         return try { block() } finally { stateLock.unlock() }95113     }96114 115+    /** Run a durable commit only while the session generation is still live. */116+    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {117+        stateLock.lock()118+        return try {119+            if (sessionEpoch != epoch) null else block()120+        } finally {121+            stateLock.unlock()122+        }123+    }124+125+    /** Test seam for a caller that must stage work before the epoch fence. */126+    @VisibleForTesting127+    internal var beforeSessionEpochCommit: () -> Unit = {}128+129+    /**130+     * Test seam for the narrower H-03 race: a suspend mutation has authenticated131+     * the session and staged its candidate state, but has not yet entered the132+     * epoch-fenced in-memory write. Production callers leave this as a no-op.133+     */134+    @VisibleForTesting135+    internal var beforeSessionStateMutation: () -> Unit = {}136+137+    /**138+     * Run an external session-bound commit while the repository's epoch fence is139+     * held. The callback is deliberately non-suspending: [lock] cannot advance140+     * the epoch until the whole external batch has reached its linearization141+     * point.142+     */143+    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {144+        beforeSessionEpochCommit()145+        return withEpochCommit(epoch, block)146+    }147+148+    private fun currentEpoch(): Long = mutateState { sessionEpoch }149+150+    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =151+        mutateState { if (sessionEpoch == epoch) block() else null }152+153+    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */154+    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {155+        beforeSessionStateMutation()156+        return mutateIfCurrentEpoch(epoch, block)157+    }158+159+    private data class PersistSnapshot(160+        val epoch: Long,161+        val key: ByteArray,162+        val payload: VaultPayload,163+        val decoy: Boolean,164+    )165+166+    /** Copy all session material while fenced; callers own and must zero [key]. */167+    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {168+        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null169+        val liveKey = derivedKey ?: return@mutateState null170+        PersistSnapshot(171+            epoch = sessionEpoch,172+            key = liveKey.copyOf(),173+            payload = currentPayloadLocked(),174+            decoy = decoyMode,175+        )176+    }177+97178     private var derivedKey: ByteArray? = null98179     private var rootKey: ByteArray? = null99180     private var entryList: MutableList<VaultEntry> = mutableListOf()100181     private var duressConfigured: Boolean = false101182     private var recoveryWordHash: String? = null102183     private var recoveryWordsSet: Boolean = false184+    /** True when an authenticated vault payload is available for local reads. */185+    private var sessionDataAvailable: Boolean = false103186     /** True while a decoy (duress) session is active — persistence targets the decoy slot. */104187     private var decoyMode: Boolean = false105188 @@ -113,6 +196,23 @@113196     private val _settings = MutableStateFlow(VaultSettings())114197     val settings: StateFlow<VaultSettings> = _settings.asStateFlow()115198 199+    /** Clear every in-memory session field while [stateLock] is held. */200+    private fun clearSessionStateLocked() {201+        derivedKey?.let(VaultCrypto::zero)202+        rootKey?.let(VaultCrypto::zero)203+        derivedKey = null204+        rootKey = null205+        entryList = mutableListOf()206+        duressConfigured = false207+        recoveryWordHash = null208+        recoveryWordsSet = false209+        sessionDataAvailable = false210+        decoyMode = false211+        _settings.value = VaultSettings()212+        _entries.value = emptyList()213+        _isUnlocked.value = false214+    }215+116216     fun hasVault(): Boolean = store.hasVault()117217 118218     // ---- Codex (printed paper backup) -------------------------------------@@ -146,6 +246,7 @@146246     }147247 148248     suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {249+        val expectedEpoch = currentEpoch()149250         var candidateKey: ByteArray? = null150251         var plaintext: ByteArray? = null151252         return try {@@ -211,29 +312,45 @@211312                 }212313             }213314 214-            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")315+            beforeDurableCommit()215316             val committed = runCatching {216-                // Noise slot is always size-matched to slot A — local-only,217-                // never written from a Codex.218-                if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")219-                // H-04: duress slot always present; real one from the Codex or220-                // a fake the size of slot A.221-                val duressOk = if (codexDuress != null) {222-                    store.writeDecoySlot(codexDuress)223-                } else {224-                    writeFakeDuressSlot(slotA)225-                }226-                if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")227-                store.stampLocalMutation()228-            }.isSuccess317+                withEpochCommit(expectedEpoch) {318+                    try {319+                        // Keep every slot write, including the inverse, under the320+                        // same epoch fence. Releasing stateLock after a partial321+                        // write and reacquiring it for rollback lets lock() advance322+                        // the epoch in between and strand the incoming slot A.323+                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")324+                        // Noise slot is always size-matched to slot A — local-only,325+                        // never written from a Codex.326+                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")327+                        // H-04: duress slot always present; real one from the Codex or328+                        // a fake the size of slot A.329+                        val duressOk = if (codexDuress != null) {330+                            store.writeDecoySlot(codexDuress)331+                        } else {332+                            writeFakeDuressSlot(slotA)333+                        }334+                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")335+                        store.stampLocalMutation()336+                        true337+                    } catch (_: Exception) {338+                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))339+                        // Keep the fence held through the final rollback340+                        // observation. A concurrent lock() may queue here, but341+                        // cannot invalidate the restored A/B/D triad.342+                        afterCodexRollback()343+                        false344+                    }345+                } ?: false346+            }.getOrDefault(false)229347             if (!committed) {230-                rollbackRestore(priorA, priorB, priorD)231348                 return UnlockResult.Error("storage_write_failed")232349             }233350 234351             val ownedKey = candidateKey!!235352             candidateKey = null // the session owns the key from here on236-            adoptSlotA(ownedKey, payload)353+            adoptSlotA(ownedKey, payload, expectedEpoch)237354         } catch (e: Exception) {238355             // A raw kotlinx parse error ("Unexpected JSON token at offset N")239356             // reaching the user says nothing about WHICH json failed — the@@ -254,12 +371,19 @@254371         runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()255372 256373     /**257-     * Undo a partially-committed Codex restore. Every step is attempted258-     * independently so one failure cannot strand the others. With no prior vault259-     * the accurate inverse is having none at all, rather than a half-written260-     * slot A that [hasVault] would report as a real vault.374+     * Undo a partially-committed Codex restore while [stateLock] is already held.375+     * Every step is attempted independently so one failure cannot strand the376+     * others. Callers that need epoch validation must enter through377+     * [withEpochCommit] first; this helper deliberately does not reacquire the378+     * lock, avoiding a reentrant gap between a failed write and its inverse.379+     * With no prior vault the accurate inverse is having none at all, rather380+     * than a half-written slot A that [hasVault] would report as a real vault.261381      */262-    private fun rollbackRestore(priorA: StoredVault?, priorB: String?, priorD: StoredVault? = null) {382+    private fun rollbackRestoreLocked(383+        priorA: StoredVault?,384+        priorB: String?,385+        priorD: StoredVault? = null,386+    ): List<String> {263387         val failures = mutableListOf<String>()264388         if (priorA == null) {265389             runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }@@ -280,6 +404,10 @@280404                 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }281405             }282406         }407+        return failures408+    }409+410+    private fun reportRollbackFailures(failures: List<String>) {283411         // Log is a throwing stub off-device, and a failed log must never mask284412         // the rollback result the caller is about to report.285413         if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }@@ -315,59 +443,69 @@315443     }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }316444 317445     suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {446+        val expectedEpoch = currentEpoch()447+        var key: ByteArray? = null318448         return try {319449             val salt = VaultCrypto.generateSalt()320-            val key = VaultCrypto.deriveKey(password, salt)321-            mutateState {450+            key = VaultCrypto.deriveKey(password, salt)451+            if (mutateIfCurrentEpoch(expectedEpoch) {322452                 entryList = mutableListOf()323453                 duressConfigured = false324454                 recoveryWordHash = null325455                 recoveryWordsSet = false326456                 decoyMode = false457+                sessionDataAvailable = false327458                 rootKey = generateRecoverableRootKey()328-            }459+            } == null) return CreateVaultResult.Error("stale_create")329460             // M-04: the fields above have to be live before sealSlotA, since it330461             // seals currentPayload(). That stages a root key the session would331462             // otherwise keep after a failed create — [lock] discards the whole332463             // staged session on every non-success exit below.333-            val slotA = sealSlotA(key, salt)334-            if (!store.writeSlotA(slotA)) {335-                lock()336-                return CreateVaultResult.Error("storage_write_failed")337-            }464+            val slotA = sealSlotA(key!!, salt)338465             // P1-3 (codex): every later step must roll the slot pair back if it339466             // fails — leaving a half-built vault is worse than refusing to make it.340-            val created = try {341-                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")342-                // H-04: duress slot must be present from the start so its343-                // existence on disk is independent of duressConfigured.344-                if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")345-                store.stampLocalMutation()346-                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")347-                true348-            } catch (_: Exception) {349-                // Half-built vault on disk is worse than no vault — refuse to350-                // create if any step past slot A fails.351-                store.clearVault()352-                false353-            }467+            beforeDurableCommit()468+            val created = withEpochCommit(expectedEpoch) {469+                if (!store.writeSlotA(slotA)) return@withEpochCommit false470+                try {471+                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")472+                    // H-04: duress slot must be present from the start so its473+                    // existence on disk is independent of duressConfigured.474+                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")475+                    store.stampLocalMutation()476+                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")477+                    true478+                } catch (_: Exception) {479+                    // Half-built vault on disk is worse than no vault — refuse to480+                    // create if any step past slot A fails.481+                    store.clearVault()482+                    false483+                }484+            } ?: false354485             if (!created) {355486                 lock()356487                 return CreateVaultResult.Error("storage_write_failed")357488             }358-            mutateState {359-                derivedKey = key489+            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")490+            if (mutateIfCurrentEpoch(expectedEpoch) {491+                derivedKey = sessionKey492+                key = null360493                 publish()361494                 _isUnlocked.value = true362-            }495+                sessionDataAvailable = true496+                sessionEpoch = nextSessionEpoch(sessionEpoch)497+            } == null) return CreateVaultResult.Error("stale_create")363498             CreateVaultResult.Success364499         } catch (e: Exception) {365500             lock()366501             CreateVaultResult.Error(e.message ?: "create_failed")502+        } finally {503+            key?.let(VaultCrypto::zero)367504         }368505     }369506 370507     suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {508+        val expectedEpoch = currentEpoch()371509         if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")372510         if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")373511         if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {@@ -392,28 +530,36 @@392530                 recoveryWordHash = null,393531                 recoveryWordsSet = false,394532             )395-            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))533+            val plaintext = VaultCodec.encode(payload)534+            val blob = try {535+                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))536+            } finally {537+                VaultCrypto.zero(plaintext)538+            }396539             val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)397-            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")398540             // P1-3 (codex): same partial-commit guard as createVault — any failure399541             // past slot A wipes the half-built vault and refuses adoption.400-            val created = try {401-                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")402-                // H-04: duress slot must be present from the start so its403-                // existence on disk is independent of duressConfigured.404-                if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")405-                store.stampLocalMutation()406-                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")407-                true408-            } catch (_: Exception) {409-                store.clearVault()410-                false411-            }542+            beforeDurableCommit()543+            val created = withEpochCommit(expectedEpoch) {544+                if (!store.writeSlotA(slotA)) return@withEpochCommit false545+                try {546+                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")547+                    // H-04: duress slot must be present from the start so its548+                    // existence on disk is independent of duressConfigured.549+                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")550+                    store.stampLocalMutation()551+                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")552+                    true553+                } catch (_: Exception) {554+                    store.clearVault()555+                    false556+                }557+            } ?: false412558             if (!created) return AdoptVaultResult.Error("storage_write_failed")413559 414-            val previousDerivedKey = derivedKey415-            val previousRootKey = rootKey416-            mutateState {560+            val adopted = mutateIfCurrentEpoch(expectedEpoch) {561+                val previousDerivedKey = derivedKey562+                val previousRootKey = rootKey417563                 derivedKey = nextDerivedKey418564                 rootKey = sessionRootKey419565                 nextDerivedKey = null@@ -423,9 +569,14 @@423569                 recoveryWordHash = null424570                 recoveryWordsSet = false425571                 decoyMode = false572+                sessionDataAvailable = true426573                 publish()427574                 _isUnlocked.value = true575+                sessionEpoch = nextSessionEpoch(sessionEpoch)576+                previousDerivedKey to previousRootKey428577             }578+            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")579+            val (previousDerivedKey, previousRootKey) = adopted429580             previousDerivedKey?.let(VaultCrypto::zero)430581             previousRootKey?.let(VaultCrypto::zero)431582             AdoptVaultResult.Success(vaultId)@@ -438,26 +589,33 @@438589         }439590     }440591 441-    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }592+    suspend fun unlock(password: String): UnlockResult = mutex.withLock {593+        unlockLocked(password, currentEpoch())594+    }442595 443596     /** Unlock body without acquiring the mutex — callers must already hold it. */444-    private fun unlockLocked(password: String): UnlockResult {597+    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {445598         val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")446599         val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))447600         val payload = try {448-            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))601+            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)602+            try {603+                VaultCodec.decode(plaintext)604+            } finally {605+                VaultCrypto.zero(plaintext)606+            }449607         } catch (_: Exception) {450608             VaultCrypto.zero(key)451609             // LV-1: a vault sealed before NFC normalization with a non-NFC452610             // password derives a different key now. If the supplied password is453611             // not already in NFC form, retry with the raw (un-normalized) bytes.454612             if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {455-                val legacy = tryLegacyRawUnlock(password, slotA)613+                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)456614                 if (legacy != null) return legacy457615             }458616             return UnlockResult.WrongPassword459617         }460-        return adoptSlotA(key, payload)618+        return adoptSlotA(key, payload, expectedEpoch)461619     }462620 463621     /**@@ -468,83 +626,122 @@468626      * OR when any step of the heal commits fails (the original slot A is restored469627      * so disk and session stay aligned).470628      */471-    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {472-        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))473-        val payload = try {474-            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))475-        } catch (_: Exception) {476-            VaultCrypto.zero(rawKey)477-            return null478-        }479-        // Heal: re-seal slot A under the NFC key (mirrors changePassword's480-        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.481-        val newSalt = VaultCrypto.generateSalt()482-        val newKey = VaultCrypto.deriveKey(password, newSalt)483-        val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))484-        val next = StoredVault(485-            version = slotA.version,486-            salt = b64(newSalt),487-            blob = blob,488-            recoveryEscrow = slotA.recoveryEscrow,489-            rootKeyEscrow = slotA.rootKeyEscrow,490-        )491-        // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,492-        // or stampLocalMutation must restore the prior slot A AND slot B so disk493-        // and the eventual session key never disagree on the wrapping.494-        val priorB = store.loadSlotB()495-        val healed = if (!store.writeSlotA(next)) {496-            false497-        } else try {498-            if (!writeMatchingNoiseSlotB(next)) {499-                runCatching { store.writeSlotA(slotA) }500-                if (priorB != null) runCatching { store.writeSlotB(priorB) }501-                false502-            } else if (!writeCompanionDuressSlot(next)) {503-                runCatching { store.writeSlotA(slotA) }504-                if (priorB != null) runCatching { store.writeSlotB(priorB) }505-                false629+    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {630+        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))631+        var newKey: ByteArray? = null632+        return try {633+            val payload = try {634+                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)635+                try {636+                    VaultCodec.decode(plaintext)637+                } finally {638+                    VaultCrypto.zero(plaintext)639+                }640+            } catch (_: Exception) {641+                return null642+            }643+            // Heal: re-seal slot A under the NFC key (mirrors changePassword's644+            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.645+            val newSalt = VaultCrypto.generateSalt()646+            newKey = VaultCrypto.deriveKey(password, newSalt)647+            val plaintext = VaultCodec.encode(payload)648+            val blob = try {649+                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))650+            } finally {651+                VaultCrypto.zero(plaintext)652+            }653+            val next = StoredVault(654+                version = slotA.version,655+                salt = b64(newSalt),656+                blob = blob,657+                recoveryEscrow = slotA.recoveryEscrow,658+                rootKeyEscrow = slotA.rootKeyEscrow,659+            )660+            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,661+            // or stampLocalMutation must restore the prior slot A AND slot B so disk662+            // and the eventual session key never disagree on the wrapping.663+            val priorB = store.loadSlotB()664+            beforeDurableCommit()665+            val healed = withEpochCommit(expectedEpoch) {666+                if (!store.writeSlotA(next)) {667+                    false668+                } else try {669+                    if (!writeMatchingNoiseSlotB(next)) {670+                        runCatching { store.writeSlotA(slotA) }671+                        if (priorB != null) runCatching { store.writeSlotB(priorB) }672+                        false673+                    } else if (!writeCompanionDuressSlot(next)) {674+                        runCatching { store.writeSlotA(slotA) }675+                        if (priorB != null) runCatching { store.writeSlotB(priorB) }676+                        false677+                    } else {678+                        store.stampLocalMutation()679+                        true680+                    }681+                } catch (_: Exception) {682+                    runCatching { store.writeSlotA(slotA) }683+                    if (priorB != null) runCatching { store.writeSlotB(priorB) }684+                    false685+                }686+            } ?: false687+            if (healed) {688+                VaultCrypto.zero(rawKey!!)689+                rawKey = null690+                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot691+                newKey = null692+                result506693             } else {507-                store.stampLocalMutation()508-                true694+                VaultCrypto.zero(newKey!!)695+                newKey = null696+                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key697+                rawKey = null698+                result509699             }510-        } catch (_: Exception) {511-            runCatching { store.writeSlotA(slotA) }512-            if (priorB != null) runCatching { store.writeSlotB(priorB) }513-            false700+        } finally {701+            // Cover codec, encryption, storage, and stale-epoch failures before702+            // either candidate key can be transferred into the live session.703+            rawKey?.let(VaultCrypto::zero)704+            newKey?.let(VaultCrypto::zero)514705         }515-        return if (healed) {516-            VaultCrypto.zero(rawKey)517-            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot518-        } else {519-            VaultCrypto.zero(newKey)520-            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key521-        }522706     }523707 524708     /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */525-    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {709+    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {526710         return try {527711             // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they528712             // can throw after the fields above are live. There is no prior session529713             // worth restoring — adoption has already replaced it — so a failed530714             // adoption discards everything rather than leaving entries and keys531715             // resident behind _isUnlocked=false.532-            mutateState {716+            val adopted = mutateIfCurrentEpoch(expectedEpoch) {533717                 derivedKey = key534718                 decoyMode = false535719                 entryList = payload.entries.toMutableList()536720                 duressConfigured = payload.duressConfigured537721                 recoveryWordHash = payload.recoveryWordHash538722                 recoveryWordsSet = payload.recoveryWordsSet723+                sessionDataAvailable = true539724                 _settings.value = store.loadSettings()540725                 ensureRootKey(payload)541726                 autoPurgeExpiredTrash()542727                 publish()543728                 _isUnlocked.value = true729+                // A successful adoption starts a new generation. A sibling730+                // SlotAUnlock captured in the prior epoch will fail closed at731+                // the next commit instead of replacing this session.732+                sessionEpoch = nextSessionEpoch(sessionEpoch)544733             }545-            UnlockResult.Success734+            if (adopted == null) {735+                VaultCrypto.zero(key)736+                UnlockResult.Error("stale_unlock")737+            } else {738+                UnlockResult.Success739+            }546740         } catch (e: Exception) {547-            lock()741+            // Do not call lock() here: a concurrent newer session may already742+            // own this repository. Only invalidate the failed epoch's fields.743+            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }744+            VaultCrypto.zero(key)548745             UnlockResult.Error(e.message ?: "unlock_failed")549746         }550747     }@@ -558,10 +755,29 @@558755      * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.559756      */560757     class SlotAUnlock internal constructor(561-        internal val key: ByteArray,758+        key: ByteArray,562759         internal val payload: VaultPayload,563-    )760+        internal val epoch: Long,761+    ) {762+        private var ownedKey: ByteArray? = key763+        private var consumed = false564764 765+        /** Transfer the key exactly once to the adopter. */766+        internal fun takeKey(): ByteArray? = synchronized(this) {767+            if (consumed) return@synchronized null768+            consumed = true769+            ownedKey.also { ownedKey = null }770+        }771+772+        /** Discard an uncommitted token without touching a transferred key. */773+        internal fun discard() = synchronized(this) {774+            if (consumed) return@synchronized775+            consumed = true776+            ownedKey?.let(VaultCrypto::zero)777+            ownedKey = null778+        }779+    }780+565781     /**566782      * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.567783      * Pure computation (no mutex), so it can run concurrently with other candidate@@ -569,11 +785,17 @@569785      * no-vault or wrong password (the derived key is zeroed on a tag mismatch).570786      */571787     fun tryUnlockSlotA(password: String): SlotAUnlock? {788+        val epoch = currentEpoch()572789         val slotA = store.loadSlotA() ?: return null573790         val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))574791         return try {575-            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))576-            SlotAUnlock(key, payload)792+            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)793+            val payload = try {794+                VaultCodec.decode(plaintext)795+            } finally {796+                VaultCrypto.zero(plaintext)797+            }798+            SlotAUnlock(key, payload, epoch)577799         } catch (_: Exception) {578800             VaultCrypto.zero(key)579801             null@@ -582,11 +804,12 @@582804 583805     /** Adopt a previously-derived [SlotAUnlock] as the live session. */584806     suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {585-        adoptSlotA(unlock.key, unlock.payload)807+        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")808+        adoptSlotA(key, unlock.payload, unlock.epoch)586809     }587810 588811     /** Zero a derived [SlotAUnlock] that will not be adopted. */589-    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)812+    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()590813 591814     // ---- Duress slot B (MF-6) ---------------------------------------------592815 @@ -596,10 +819,27 @@596819      * only committed if the duress passphrase was the one entered (MF-5/MF-6).597820      */598821     class DuressUnlock internal constructor(599-        internal val key: ByteArray,822+        key: ByteArray,600823         internal val payload: VaultPayload,601-    )824+        internal val epoch: Long,825+    ) {826+        private var ownedKey: ByteArray? = key827+        private var consumed = false602828 829+        internal fun takeKey(): ByteArray? = synchronized(this) {830+            if (consumed) return@synchronized null831+            consumed = true832+            ownedKey.also { ownedKey = null }833+        }834+835+        internal fun discard() = synchronized(this) {836+            if (consumed) return@synchronized837+            consumed = true838+            ownedKey?.let(VaultCrypto::zero)839+            ownedKey = null840+        }841+    }842+603843     /**604844      * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the605845      * session. Success means the entered password is the duress passphrase that keys@@ -608,11 +848,17 @@608848      * Returns null when no duress slot exists or the password is not the duress one.609849      */610850     fun tryUnlockDuress(password: String): DuressUnlock? {851+        val epoch = currentEpoch()611852         val slot = store.loadDecoySlot() ?: return null612853         val key = VaultCrypto.deriveKey(password, unb64(slot.salt))613854         return try {614-            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))615-            DuressUnlock(key, payload)855+            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)856+            val payload = try {857+                VaultCodec.decode(plaintext)858+            } finally {859+                VaultCrypto.zero(plaintext)860+            }861+            DuressUnlock(key, payload, epoch)616862         } catch (_: Exception) {617863             VaultCrypto.zero(key)618864             null@@ -621,28 +867,46 @@621867 622868     /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */623869     suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {870+        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")871+        if (currentEpoch() != unlock.epoch) {872+            VaultCrypto.zero(key)873+            return@withLock UnlockResult.Error("stale_unlock")874+        }624875         return try {625-            mutateState {626-                derivedKey = unlock.key876+            val adopted = mutateIfCurrentEpoch(unlock.epoch) {877+                derivedKey = key627878                 decoyMode = true628879                 entryList = unlock.payload.entries.toMutableList()629880                 duressConfigured = false630881                 recoveryWordHash = unlock.payload.recoveryWordHash631882                 recoveryWordsSet = unlock.payload.recoveryWordsSet883+                sessionDataAvailable = true632884                 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)633885                           else VaultCrypto.generateRootKey()634886                 _settings.value = store.loadSettings()635887                 publish()636888                 _isUnlocked.value = true889+                sessionEpoch = nextSessionEpoch(sessionEpoch)637890             }638-            UnlockResult.Success891+            if (adopted == null) {892+                VaultCrypto.zero(key)893+                UnlockResult.Error("stale_unlock")894+            } else {895+                UnlockResult.Success896+            }639897         } catch (e: Exception) {898+            // A payload/root/settings failure can occur after the candidate key899+            // has been assigned to derivedKey. Tear down only this epoch so a900+            // newer session is not disturbed, and scrub the token reference on901+            // every error path.902+            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }903+            VaultCrypto.zero(key)640904             UnlockResult.Error(e.message ?: "decoy_unlock_failed")641905         }642906     }643907 644908     /** Zero a derived [DuressUnlock] that will not be adopted. */645-    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)909+    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()646910 647911     /**648912      * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an@@ -652,17 +916,22 @@652916      */653917     suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {654918         // H-03: read derivedKey/decoyMode atomically with any concurrent lock().655-        val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }656-        if (!unlockedAndReal) return false919+        val expectedEpoch = mutateState {920+            if (derivedKey != null && !decoyMode) sessionEpoch else null921+        } ?: return false657922         // Reject a duress passphrase that also opens the real vault: it would make the658923         // lock screen ambiguous (both slots would decrypt for the same input).659924         tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }925+        var key: ByteArray? = null926+        var basePlaintext: ByteArray? = null660927         return try {661928             val salt = VaultCrypto.generateSalt()662-            val key = VaultCrypto.deriveKey(duressPassphrase, salt)929+            key = VaultCrypto.deriveKey(duressPassphrase, salt)663930             // An empty decoy vault with its own fresh sync root key — nothing real leaks.664-            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))665-            val basePlaintext = VaultCodec.encode(decoyPayload)931+            val decoyRoot = VaultCrypto.generateRootKey()932+            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }933+            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)934+            basePlaintext = VaultCodec.encode(decoyPayload)666935             // H-04: pad the duress plaintext to slot A's ciphertext length so the667936             // duress blob matches slot A's blob on disk. VaultCodec.decode strips668937             // trailing NULs, so the extra padding survives encrypt but never@@ -673,18 +942,35 @@673942             // keeping the duress passphrase in memory, which we don't.674943             val slotA = store.loadSlotA()675944             val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0676-            val plaintext = if (targetSize > basePlaintext.size) basePlaintext.copyOf(targetSize) else basePlaintext677-            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))678-            VaultCrypto.zero(key)679-            VaultCrypto.zero(plaintext)680-            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))681-            if (ok) {682-                mutateState { duressConfigured = true }683-                runCatching { persistSlotA(bumpVersion = false) }945+            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!946+            val blob = try {947+                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))948+            } finally {949+                VaultCrypto.zero(plaintext)950+                // Padding creates a second array; scrub the original encoded951+                // payload too rather than dropping its last reference untouched.952+                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)953+                basePlaintext = null954+                key?.let(VaultCrypto::zero)955+                key = null684956             }957+            beforeDurableCommit()958+            val ok = withEpochCommit(expectedEpoch) {959+                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))960+            } ?: false961+            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {962+                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }963+            } else if (ok) {964+                return false965+            }685966             ok686967         } catch (_: Exception) {687968             false969+        } finally {970+            // Cover failures before the encrypt block is entered (for example a971+            // codec/copy exception) as well as any early return after key derivation.972+            key?.let(VaultCrypto::zero)973+            basePlaintext?.let(VaultCrypto::zero)688974         }689975     }690976 @@ -694,41 +980,84 @@694980         // blob of slot A's size rather than removing the duress record. The695981         // record stays on disk at the same length, so an at-rest observer696982         // cannot tell "duress cleared" from "no duress ever configured".697-        store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) } }698-            ?: store.clearDecoySlot()983+        val expectedEpoch = currentEpoch()984+        val replaced = withEpochCommit(expectedEpoch) {985+            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }986+                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)987+        } ?: false988+        if (!replaced) return@withLock false699989         // H-03: stateLock-fence the derivedKey/decoyMode read AND the700990         // duressConfigured write so a concurrent lock() cannot land between them.701-        val shouldClear = mutateState { derivedKey != null && !decoyMode }702-        if (shouldClear) {703-            mutateState { duressConfigured = false }704-            runCatching { persistSlotA(bumpVersion = false) }991+        if (!isSessionEpochCurrent(expectedEpoch)) return false992+        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true993+        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {994+            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }705995         }706996         true707997     }708998 999+    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1000+    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1001+        var current: Throwable? = error1002+        while (current != null) {1003+            if (current is javax.crypto.AEADBadTagException) return true1004+            // Some Android providers expose a GCM tag failure as the parent1005+            // BadPaddingException rather than the AEAD-specific subtype.  A1006+            // short ciphertext is an IllegalBlockSizeException and therefore1007+            // remains an internal/malformed-payload Error below.1008+            if (current is javax.crypto.BadPaddingException) return true1009+            current = current.cause1010+        }1011+        return false1012+    }1013+7091014     /** Unlock with an already-derived AES-256 key (biometric path). */7101015     suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {711-        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")1016+        val expectedEpoch = currentEpoch()1017+        val slotA = try {1018+            store.loadSlotA()1019+        } catch (_: Exception) {1020+            return@withLock UnlockResult.Error("unlock_key_storage_failed")1021+        } ?: return@withLock UnlockResult.Error("no_vault")1022+        // Keep the caller-owned key separate from the staged session key. The1023+        // biometric caller always zeroes its input in a finally block; adoption1024+        // must therefore own a copy and use the same all-or-nothing path as a1025+        // password unlock rather than publishing a partially decoded session.1026+        var stagedKey: ByteArray? = key.copyOf()7121027         return try {713-            val payload = VaultCodec.decode(714-                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),715-            )716-            mutateState {717-                derivedKey = key.copyOf()718-                decoyMode = false719-                entryList = payload.entries.toMutableList()720-                duressConfigured = payload.duressConfigured721-                recoveryWordHash = payload.recoveryWordHash722-                recoveryWordsSet = payload.recoveryWordsSet723-                _settings.value = store.loadSettings()724-                ensureRootKey(payload)725-                autoPurgeExpiredTrash()726-                publish()727-                _isUnlocked.value = true1028+            val encrypted = try {1029+                unb64(slotA.blob)1030+            } catch (_: Exception) {1031+                return@withLock UnlockResult.Error("invalid_vault_blob")7281032             }729-            UnlockResult.Success730-        } catch (_: Exception) {731-            UnlockResult.WrongPassword1033+            // A GCM record must include nonce + authentication tag.  Providers1034+            // differ on whether a shorter record is reported as BadPadding or1035+            // AEADBadTag; reject it before decrypt so malformed storage cannot be1036+            // misreported as a user-entered wrong key.1037+            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1038+                return@withLock UnlockResult.Error("invalid_vault_blob")1039+            }1040+            val plaintext = try {1041+                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1042+            } catch (e: Exception) {1043+                if (isGcmAuthenticationFailure(e)) {1044+                    return@withLock UnlockResult.WrongPassword1045+                }1046+                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1047+            }1048+            val payload = try {1049+                VaultCodec.decode(plaintext)1050+            } catch (_: Exception) {1051+                return@withLock UnlockResult.Error("invalid_vault_payload")1052+            } finally {1053+                VaultCrypto.zero(plaintext)1054+            }1055+            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1056+            val result = adoptSlotA(candidate, payload, expectedEpoch)1057+            if (result is UnlockResult.Success) stagedKey = null1058+            result1059+        } finally {1060+            stagedKey?.let(VaultCrypto::zero)7321061         }7331062     }7341063 @@ -736,20 +1065,13 @@7361065         // H-03: must observe derivedKey/entryList atomically with any in-flight7371066         // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,7381067         // etc.). mutex cannot be acquired from this non-suspend caller; stateLock739-        // guards just the brief state reset.1068+        // guards just the brief state reset. Advancing the epoch first invalidates1069+        // every operation that captured this session before it reaches a later1070+        // durable commit or publication.7401071         stateLock.lock()7411072         try {742-            derivedKey?.let(VaultCrypto::zero)743-            rootKey?.let(VaultCrypto::zero)744-            derivedKey = null745-            rootKey = null746-            entryList = mutableListOf()747-            duressConfigured = false748-            recoveryWordHash = null749-            recoveryWordsSet = false750-            decoyMode = false751-            _entries.value = emptyList()752-            _isUnlocked.value = false1073+            sessionEpoch = nextSessionEpoch(sessionEpoch)1074+            clearSessionStateLocked()7531075         } finally {7541076             stateLock.unlock()7551077         }@@ -813,9 +1135,22 @@8131135     // [unlockWithRecoveryWords], which installs a root key with no password key8141136     // yet) and in a create that failed after staging. Sync must fail closed8151137     // there, so the authoritative unlocked flag gates it, not just decoyMode.816-    fun syncMasterKey(): ByteArray? =817-        if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()1138+    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)8181139 1140+    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1141+        if (decoyMode || !_isUnlocked.value) null1142+        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1143+    }1144+1145+    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1146+        mutateState { sessionEpoch == epoch }1147+1148+    internal fun unlockedSessionEpoch(): Long? = mutateState {1149+        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1150+    }1151+1152+    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key1153+8191154     /**8201155      * The current session's root key, decoy included. For local-only uses that8211156      * must behave identically in a decoy session — Codex backup and recovery@@ -825,13 +1160,16 @@8251160      * leak the real vault. Anything that leaves the device must use8261161      * [syncMasterKey] instead.8271162      */828-    fun sessionRootKey(): ByteArray? = rootKey?.copyOf()1163+    fun sessionRootKey(): ByteArray? = mutateState {1164+        if (!sessionDataAvailable) null else rootKey?.copyOf()1165+    }8291166 8301167     /** Stable per-vault id two paired devices must share to sync. Null when8311168      *  locked, and never present for the decoy vault (see [syncMasterKey]). */832-    fun syncVaultId(): String? =1169+    fun syncVaultId(): String? = mutateState {8331170         if (decoyMode || !_isUnlocked.value) null8341171         else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1172+    }8351173 8361174     /**8371175      * Browser parity (MV-8): rotate the sync identity by replacing the stable@@ -839,7 +1177,9 @@8391177      * credentials, vault_id, and recovery words all change.8401178      */8411179     suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {842-        installRootKey(generateRecoverableRootKey())1180+        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1181+            ?: return false1182+        installRootKey(generateRecoverableRootKey(), expectedEpoch)8431183     }8441184 8451185     /**@@ -863,8 +1203,15 @@8631203         if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {8641204             return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")8651205         }866-        if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")867-        if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1206+        val expectedEpoch = mutateState {1207+            when {1208+                derivedKey == null -> null1209+                decoyMode -> Long.MIN_VALUE1210+                else -> sessionEpoch1211+            }1212+        }1213+        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1214+        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")8681215         if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")8691216 8701217         val incoming = hexToRootKey(rootKeyHex)@@ -882,7 +1229,7 @@8821229         }8831230 8841231         val vaultId = com.peachpasswords.sync.computeVaultId(incoming)885-        return if (installRootKey(incoming)) {1232+        return if (installRootKey(incoming, expectedEpoch)) {8861233             AdoptVaultResult.Success(vaultId)8871234         } else {8881235             AdoptVaultResult.Error("JOIN_INSTALL_FAILED")@@ -912,7 +1259,7 @@9121259      * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds9131260      * [mutex].9141261      */915-    private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {1262+    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {9161263         // H-03: snapshot the live fields under stateLock so the success and the9171264         // rollback paths both see consistent prior/new state. Writing the new9181265         // rootKey/recovery fields is inside its own mutateState at the end.@@ -925,25 +1272,31 @@9251272             val priorRecoveryHash: String?,9261273             val priorRecoverySet: Boolean,9271274         )928-        val snap = mutateState {929-            val k = derivedKey ?: return false930-            if (decoyMode) return false931-            val priorRoot = rootKey ?: return false1275+        val snap = mutateIfCurrentEpoch(expectedEpoch) {1276+            val k = derivedKey ?: return@mutateIfCurrentEpoch null1277+            if (decoyMode) return@mutateIfCurrentEpoch null1278+            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null9321279             Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)933-        } as Snapshot? ?: return false1280+        } ?: run {1281+            VaultCrypto.zero(nextRootKey)1282+            return false1283+        }9341284         val priorSlot = store.loadSlotA() ?: run {1285+            VaultCrypto.zero(snap.key)9351286             VaultCrypto.zero(snap.priorRootKey)1287+            VaultCrypto.zero(nextRootKey)9361288             return false9371289         }9381290         val nextWords = rootKeyToWords(nextRootKey)9391291 9401292         return try {941-            takeVaultSnapshot("local_save")942-            mutateState {1293+            beforeDurableCommit()1294+            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1295+            if (mutateIfCurrentEpoch(expectedEpoch) {9431296                 rootKey = nextRootKey9441297                 recoveryWordHash = recoveryWordsHash(nextWords)9451298                 recoveryWordsSet = false946-            }1299+            } == null) throw IllegalStateException("stale_root_rotation")9471300 9481301             val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)9491302                 ?: throw IllegalStateException("recovery_escrow_failed")@@ -960,29 +1313,44 @@9601313                 recoveryEscrow = nextRecoveryEscrow,9611314                 rootKeyEscrow = nextRootKeyEscrow,9621315             )963-            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")964-            try {965-                writeMatchingNoiseSlotB(nextSlot)966-                // H-04: a root-key rotation re-seals slot A, so the duress slot967-                // (real or fake) must be re-padded to the new ciphertext length.968-                if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")969-                store.stampLocalMutation()970-            } catch (e: Exception) {971-                store.writeSlotA(priorSlot)972-                throw e973-            }974-            VaultCrypto.zero(snap.priorRootKey)975-            mutateState { publish() }1316+            val committed = withEpochCommit(expectedEpoch) {1317+                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1318+                try {1319+                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1320+                    // H-04: a root-key rotation re-seals slot A, so the duress slot1321+                    // (real or fake) must be re-padded to the new ciphertext length.1322+                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1323+                    store.stampLocalMutation()1324+                } catch (_: Exception) {1325+                    store.writeSlotA(priorSlot)1326+                    return@withEpochCommit false1327+                }1328+                true1329+            } ?: false1330+            if (!committed) throw IllegalStateException("storage_write_failed")1331+            mutateIfCurrentEpoch(expectedEpoch) { publish() }9761332             true9771333         } catch (_: Exception) {978-            mutateState {979-                rootKey = snap.priorRootKey1334+            val restored = mutateIfCurrentEpoch(expectedEpoch) {1335+                rootKey?.let(VaultCrypto::zero)1336+                rootKey = snap.priorRootKey.copyOf()9801337                 recoveryWordHash = snap.priorRecoveryHash9811338                 recoveryWordsSet = snap.priorRecoverySet9821339             }983-            VaultCrypto.zero(nextRootKey)984-            store.writeSlotA(priorSlot)1340+            if (restored != null) {1341+                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1342+            }9851343             false1344+        } finally {1345+            VaultCrypto.zero(snap.key)1346+            VaultCrypto.zero(snap.priorRootKey)1347+            // On success nextRootKey is owned by rootKey. On every other path1348+            // it is not, including a stale epoch after lock(); scrub it while1349+            // the local reference is still available.1350+            val stillOwnedBySession = mutateState {1351+                sessionEpoch == expectedEpoch && rootKey === nextRootKey1352+            }1353+            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)9861354         }9871355     }9881356 @@ -991,18 +1359,28 @@9911359      * (wrapped under a hardware KeyStore key on biometric enable). Null when9921360      * locked. Caller must [VaultCrypto.zero] it after use.9931361      */994-    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()1362+    fun currentVaultKey(): ByteArray? = mutateState {1363+        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1364+    }9951365 9961366     suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1367+        val expectedEpoch = currentEpoch()9971368         val slotA = store.loadSlotA() ?: return false9981369         var oldKey: ByteArray? = null9991370         var newKey: ByteArray? = null10001371         var plaintext: ByteArray? = null10011372         return try {10021373             oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1003-            val payload = VaultCodec.decode(1004-                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),1005-            )1374+            // Keep ownership of the decrypted vault plaintext until decoding has1375+            // finished.  Passing decrypt() inline made the byte array unreachable1376+            // before the finally block, so a successful or failed password change1377+            // could leave the full plaintext resident in the heap.1378+            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1379+            val payload = try {1380+                VaultCodec.decode(decrypted)1381+            } finally {1382+                VaultCrypto.zero(decrypted)1383+            }10061384             val newSalt = VaultCrypto.generateSalt()10071385             newKey = VaultCrypto.deriveKey(newPassword, newSalt)10081386             // Re-seal the existing payload under the new key/salt.@@ -1027,15 +1405,15 @@10271405             // old key in memory) would have the next save encrypt under the old10281406             // key while matching disk on the new one.10291407             val priorB = store.loadSlotB()1030-            if (!commitVaultRotation(slotA, priorB, next)) return false1408+            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false10311409             // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one10321410             // inside the same stateLock critical section, so a concurrent lock()10331411             // can't observe the in-between (zeroed but not yet reassigned).1034-            mutateState {1412+            if (mutateIfCurrentEpoch(expectedEpoch) {10351413                 oldKey?.let(VaultCrypto::zero); oldKey = null10361414                 derivedKey?.let(VaultCrypto::zero)10371415                 derivedKey = newKey; newKey = null1038-            }1416+            } == null) return false10391417             true10401418         } catch (_: Exception) {10411419             false@@ -1059,7 +1437,7 @@10591437     // ---- Entry CRUD -------------------------------------------------------10601438 10611439     suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1062-        requireUnlocked()1440+        val epoch = requireUnlocked()10631441         val stamp = VaultCrypto.monotonicEntryTimestamp()10641442         val now = System.currentTimeMillis()10651443         val entry = withDerivedLast4(draft.copy(@@ -1070,12 +1448,12 @@10701448         ))10711449         // H-03: capture rollback AND write entryList under stateLock so a10721450         // concurrent lock() cannot zero the session between the read and the write.1073-        val rollback = mutateState {1451+        val rollback = mutateCurrentSession(epoch) {10741452             val r = entryList.toMutableList()10751453             entryList.add(entry)10761454             r1077-        }1078-        persistOrRollback(rollback)1455+        } ?: throw IllegalStateException("vault_locked")1456+        persistOrRollback(rollback, epoch)10791457         entry10801458     }10811459 @@ -1089,7 +1467,7 @@10891467      * [addEntry]'s all-or-nothing behaviour.10901468      */10911469     suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1092-        requireUnlocked()1470+        val epoch = requireUnlocked()10931471         if (drafts.isEmpty()) return 010941472         val now = System.currentTimeMillis()10951473         val ready = drafts.map { draft ->@@ -1104,12 +1482,12 @@11041482             )11051483         }11061484         // H-03: capture rollback AND append every entry under stateLock.1107-        val rollback = mutateState {1485+        val rollback = mutateCurrentSession(epoch) {11081486             val r = entryList.toMutableList()11091487             entryList.addAll(ready)11101488             r1111-        }1112-        persistOrRollback(rollback)1489+        } ?: throw IllegalStateException("vault_locked")1490+        persistOrRollback(rollback, epoch)11131491         return drafts.size11141492     }11151493 @@ -1127,7 +1505,7 @@11271505      * deletions they already know about.11281506      */11291507     suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1130-        requireUnlocked()1508+        val epoch = requireUnlocked()11311509         val now = System.currentTimeMillis()11321510         val ready = newEntries.map { draft ->11331511             val stamp = VaultCrypto.monotonicEntryTimestamp()@@ -1147,7 +1525,7 @@11471525         // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-11481526         // superseded entries from the tombstone set instead.11491527         val incomingIds = ready.map { it.id }.toHashSet()1150-        val rollback = mutateState {1528+        val rollback = mutateCurrentSession(epoch) {11511529             val r = entryList.toMutableList()11521530             val replaced = entryList.map { entry ->11531531                 if (entry.deletedAt != null) return@map entry@@ -1157,19 +1535,18 @@11571535             }.filterNotNull()11581536             entryList = (replaced + ready).toMutableList()11591537             r1160-        }1161-        persistOrRollback(rollback)1538+        } ?: throw IllegalStateException("vault_locked")1539+        persistOrRollback(rollback, epoch)11621540         return ready.size11631541     }11641542 11651543     suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1166-        requireUnlocked()1544+        val epoch = requireUnlocked()11671545         // H-03: read entryList, compute the patched entry, and write it under a11681546         // single stateLock so a concurrent lock() cannot interleave between the11691547         // lookup and the assignment (would IndexOutOfBoundsException on a shrunk11701548         // list, or clobber the entry onto the wrong id after a relock).1171-        var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1172-        mutateState {1549+        val prepared = mutateCurrentSession(epoch) {11731550             val idx = entryList.indexOfFirst { it.id == patch.id }11741551             if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")11751552             val current = entryList[idx]@@ -1200,77 +1577,78 @@12001577             ))12011578             val rollback = entryList.toMutableList()12021579             entryList[idx] = updated1203-            prepared = updated to rollback1204-        }1205-        val (updated, rollback) = prepared!!1206-        persistOrRollback(rollback)1580+            updated to rollback1581+        } ?: throw IllegalStateException("vault_locked")1582+        val (updated, rollback) = prepared1583+        persistOrRollback(rollback, epoch)12071584         updated12081585     }12091586 12101587     /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */12111588     suspend fun deleteEntry(id: String) = mutex.withLock {1212-        requireUnlocked()1589+        val epoch = requireUnlocked()12131590         // H-03: lookup + tombstone write under stateLock so lock() cannot shrink12141591         // the list out from under the indexOfFirst.1215-        var rollback: MutableList<VaultEntry>? = null1216-        mutateState {1592+        val rollback = mutateCurrentSession(epoch) {12171593             val idx = entryList.indexOfFirst { it.id == id }12181594             if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")12191595             val current = entryList[idx]12201596             val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1221-            rollback = entryList.toMutableList()1597+            val prior = entryList.toMutableList()12221598             entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1223-        }1224-        persistOrRollback(rollback!!)1599+            prior1600+        } ?: throw IllegalStateException("vault_locked")1601+        persistOrRollback(rollback, epoch)12251602     }12261603 12271604     suspend fun restoreEntry(id: String) = mutex.withLock {1228-        requireUnlocked()1229-        var rollback: MutableList<VaultEntry>? = null1230-        mutateState {1605+        val epoch = requireUnlocked()1606+        val rollback = mutateCurrentSession(epoch) {12311607             val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }12321608             if (idx == -1) throw IllegalStateException("deleted entry not found")12331609             val current = entryList[idx]1234-            rollback = entryList.toMutableList()1610+            val prior = entryList.toMutableList()12351611             entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1236-        }1237-        persistOrRollback(rollback!!)1612+            prior1613+        } ?: throw IllegalStateException("vault_locked")1614+        persistOrRollback(rollback, epoch)12381615     }12391616 12401617     /** Permanent delete — no tombstone, no recovery. */12411618     suspend fun purgeEntry(id: String) = mutex.withLock {1242-        requireUnlocked()1243-        var rollback: MutableList<VaultEntry>? = null1244-        mutateState {1619+        val epoch = requireUnlocked()1620+        val rollback = mutateCurrentSession(epoch) {12451621             val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }12461622             if (idx == -1) throw IllegalStateException("deleted entry not found")1247-            rollback = entryList.toMutableList()1623+            val prior = entryList.toMutableList()12481624             entryList.removeAt(idx)1249-        }1250-        persistOrRollback(rollback!!)1625+            prior1626+        } ?: throw IllegalStateException("vault_locked")1627+        persistOrRollback(rollback, epoch)12511628     }12521629 12531630     /** Permanently empty the trash — hard-deletes every soft-deleted entry. */12541631     suspend fun purgeDeletedEntries(): Int = mutex.withLock {1255-        requireUnlocked()1632+        val epoch = requireUnlocked()12561633         // H-03: read+remove under stateLock so lock() can't reorder the remove.12571634         data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1258-        val r = mutateState {1635+        val r = mutateCurrentSession(epoch) {12591636             val rollback = entryList.toMutableList()12601637             val before = entryList.size12611638             entryList.removeAll { it.deletedAt != null }12621639             R(rollback, before - entryList.size)1263-        }1264-        if (r.removed > 0) persistOrRollback(r.rollback)1640+        } ?: throw IllegalStateException("vault_locked")1641+        if (r.removed > 0) persistOrRollback(r.rollback, epoch)12651642         r.removed12661643     }12671644 12681645     /** Browser parity: number of active entries that dedupe would move to trash. */12691646     suspend fun countDuplicateEntries(): Int = mutex.withLock {1270-        requireUnlocked()1647+        val epoch = requireUnlocked()12711648         // countDuplicateEntriesLocked reads entryList; H-03 fences the read so12721649         // lock() can't race the read with state writes.1273-        mutateState { countDuplicateEntriesLocked() }1650+        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1651+            ?: throw IllegalStateException("vault_locked")12741652     }12751653 12761654     /**@@ -1279,13 +1657,10 @@12791657      * not reshaped into a forensically distinct state.12801658      */12811659     suspend fun deduplicateEntries(): Int = mutex.withLock {1282-        requireUnlocked()1283-        // H-03: read the decoyMode flag and the entry list, then reassign1284-        // entryList, all under one stateLock.1285-        val decoy = mutateState { decoyMode }1286-        if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1660+        val epoch = requireUnlocked()12871661         data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1288-        val ready = mutateState {1662+        val ready = mutateCurrentSession(epoch) {1663+            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")12891664             val active = entryList.filter { it.deletedAt == null }12901665             val keepIds = mutableSetOf<String>()12911666             active.groupBy { it.duplicateKey() }.values.forEach { group ->@@ -1303,26 +1678,36 @@13031678                 }13041679             }.toMutableList()13051680             R(rollback, removed)1306-        }1307-        if (ready.removed > 0) persistOrRollback(ready.rollback)1681+        } ?: throw IllegalStateException("vault_locked")1682+        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)13081683         ready.removed13091684     }13101685 1311-    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1312-    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1313-    fun getDeletedEntries(): List<VaultEntry> =1314-        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1686+    fun getEntries(): List<VaultEntry> = mutateState {1687+        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1688+    }1689+    fun getEntry(id: String): VaultEntry? = mutateState {1690+        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1691+    }1692+    fun getDeletedEntries(): List<VaultEntry> = mutateState {1693+        if (!sessionDataAvailable) emptyList()1694+        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1695+    }13151696 13161697     /** Active entries, optionally narrowed to those carrying [tagFilter]. */13171698     fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1318-        val active = entryList.filter { it.deletedAt == null }1319-        if (tagFilter == null) return active1320-        val needle = tagFilter.trim().lowercase()1321-        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1699+        return mutateState {1700+            if (!sessionDataAvailable) return@mutateState emptyList()1701+            val active = entryList.filter { it.deletedAt == null }1702+            if (tagFilter == null) return@mutateState active1703+            val needle = tagFilter.trim().lowercase()1704+            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1705+        }13221706     }13231707 13241708     /** Sorted, de-duplicated tags across all active entries. */1325-    fun getDistinctTags(): List<String> =1709+    fun getDistinctTags(): List<String> = mutateState {1710+        if (!sessionDataAvailable) return@mutateState emptyList()13261711         entryList.asSequence()13271712             .filter { it.deletedAt == null }13281713             .flatMap { it.tags?.asSequence() ?: emptySequence() }@@ -1331,6 +1716,7 @@13311716             .distinct()13321717             .sorted()13331718             .toList()1719+    }13341720 13351721     /**13361722      * Distinct non-blank values for an identity field across active entries,@@ -1344,25 +1730,31 @@13441730             "email" -> { e -> e.email }13451731             else -> return emptyList()13461732         }1347-        return entryList.asSequence()1348-            .filter { it.deletedAt == null }1349-            .mapNotNull { select(it)?.trim() }1350-            .filter { it.isNotEmpty() }1351-            .groupingBy { it.lowercase() }1352-            .aggregate { _, acc: Pair<String, Int>?, value, _ ->1353-                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1354-            }1355-            .values1356-            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1357-            .map { it.first }1733+        return mutateState {1734+            if (!sessionDataAvailable) return@mutateState emptyList()1735+            entryList.asSequence()1736+                .filter { it.deletedAt == null }1737+                .mapNotNull { select(it)?.trim() }1738+                .filter { it.isNotEmpty() }1739+                .groupingBy { it.lowercase() }1740+                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1741+                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1742+                }1743+                .values1744+                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1745+                .map { it.first }1746+        }13581747     }13591748 1360-    fun getTombstones(): List<Tombstone> =1361-        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1749+    fun getTombstones(): List<Tombstone> = mutateState {1750+        if (!sessionDataAvailable) emptyList()1751+        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1752+    }13621753     fun getVaultVersion(): Long = store.version()13631754     fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots13641755 13651756     suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1757+        val expectedEpoch = currentEpoch()13661758         val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false13671759         val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false13681760         // H-04: snapshots now carry the duress slot too — restore preserves@@ -1381,7 +1773,8 @@13811773         // slot pair, otherwise a failed restore cannot be rolled back to the13821774         // pre-restore history. If the history write itself fails, refuse to13831775         // proceed — the alternative is a successful restore with no undo path.1384-        if (!takeVaultSnapshot("restore")) {1776+        beforeDurableCommit()1777+        if (!takeVaultSnapshot("restore", expectedEpoch)) {13851778             return false13861779         }13871780         // H-04: legacy snapshots without vault_d, or vault_d that fails to@@ -1393,8 +1786,8 @@13931786             val fakeSalt = VaultCrypto.generateSalt()13941787             StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))13951788         }1396-        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD)) {1397-            store.writeVaultHistory(priorHistory)1789+        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1790+            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }13981791             return false13991792         }14001793         lock()@@ -1419,22 +1812,25 @@14191812         priorA: StoredVault?,14201813         priorB: String?,14211814         priorD: StoredVault?,1815+        expectedEpoch: Long,14221816     ): Boolean {1423-        if (!store.writeSlotA(nextA)) return false1424-        // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1425-        // must trigger rollback; previously a throw escaped without restoring1426-        // the prior slot A and history.1427-        try {1428-            if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1429-            // H-04: duress slot is part of the restore triad. Failures here1430-            // roll A+B back so the at-rest state stays consistent.1431-            if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1432-            store.stampLocalMutation()1433-        } catch (_: Exception) {1434-            rollbackRestore(priorA, priorB, priorD)1435-            return false1436-        }1437-        return true1817+        return withEpochCommit(expectedEpoch) {1818+            if (!store.writeSlotA(nextA)) return@withEpochCommit false1819+            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1820+            // must trigger rollback; previously a throw escaped without restoring1821+            // the prior slot A and history.1822+            try {1823+                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1824+                // H-04: duress slot is part of the restore triad. Failures here1825+                // roll A+B back so the at-rest state stays consistent.1826+                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1827+                store.stampLocalMutation()1828+            } catch (_: Exception) {1829+                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1830+                return@withEpochCommit false1831+            }1832+            true1833+        } ?: false14381834     }14391835 14401836     // ---- Sync apply (does NOT advance the version) ------------------------@@ -1447,8 +1843,10 @@14471843     suspend fun applyMergedRemoteEntries(14481844         remoteEntries: List<VaultEntry>,14491845         remoteTombstones: List<Tombstone>,1846+        expectedEpoch: Long? = null,14501847     ): Int = mutex.withLock {1451-        requireUnlocked()1848+        val epoch = expectedEpoch ?: requireUnlocked()1849+        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 014521850         // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS14531851         // BEFORE any comparison against a local one. Without this a peer can14541852         // send a far-future modifiedAt and permanently pin its version of an@@ -1468,7 +1866,7 @@14681866         }14691867         // H-03: build the new entryList under stateLock so a concurrent lock()14701868         // cannot zero derivedKey/entryList between the byId read and the merge.1471-        val applied = mutateState {1869+        val applied = mutateCurrentSession(epoch) {14721870             var n = 014731871             val byId = entryList.associateBy { it.id }.toMutableMap()14741872 @@ -1492,27 +1890,48 @@14921890                 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }14931891             }14941892             n1495-        }1893+        } ?: return@withLock 014961894 14971895         if (applied > 0) {1498-            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1499-            mutateState { publish() }1896+            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)1897+            mutateIfCurrentEpoch(epoch) { publish() }15001898         }15011899         applied15021900     }15031901 15041902     suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1505-        // _settings is a StateFlow (atomic write), but publish under stateLock so1506-        // any concurrent lock() observes either the old or the new value — not1507-        // ordering with the in-memory entries flip.1508-        mutateState { _settings.value = settings }1509-        store.writeSettings(settings)1903+        // Require a fully live session before exposing a settings mutation.  The1904+        // epoch is captured with the read so a lock/new-session boundary cannot1905+        // let stale work publish into the replacement session.1906+        val epoch = requireUnlocked()1907+        val prior = mutateCurrentSession(epoch) {1908+            val old = _settings.value1909+            _settings.value = settings1910+            old1911+        } ?: throw IllegalStateException("vault_locked")1912+1913+        val committed = try {1914+            beforeDurableCommit()1915+            // The durable write is inside the same epoch fence as the in-memory1916+            // publication.  If lock() won first, this returns null and no stale1917+            // settings reach storage or the public StateFlow.1918+            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true1919+        } catch (_: Exception) {1920+            false1921+        }1922+        if (!committed) {1923+            // A live-session storage failure rolls back the public value.  A1924+            // stale epoch is already reset by lock()/the new session and must1925+            // not be allowed to overwrite that safe state.1926+            mutateIfCurrentEpoch(epoch) { _settings.value = prior }1927+            throw IllegalStateException("settings_write_failed")1928+        }15101929     }15111930 15121931     // ---- Recovery words (vault metadata) ----------------------------------15131932 15141933     /** True once the user has confirmed their recovery words for this vault. */1515-    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet1934+    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }15161935 15171936     /**15181937      * Record the BLAKE3 hash of the confirmed recovery words and mark them set.@@ -1520,28 +1939,32 @@15201939      * is not an entry mutation paired devices should sync against.15211940      */15221941     suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1523-        requireUnlocked()1942+        val epoch = requireUnlocked()15241943         // H-03: hash + escrow-related state fields set under stateLock so a15251944         // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.15261945         // M-04: the flags have to be live before the escrows are built — both seal15271946         // currentPayload() — so capture the prior pair and put it back if the write15281947         // never lands. Otherwise the session reports words set against a slot A15291948         // that still holds no escrow, and the recovery screen never offers a retry.1530-        val prior = mutateState {1949+        val prior = mutateCurrentSession(epoch) {15311950             val priorPair = recoveryWordHash to recoveryWordsSet15321951             recoveryWordHash = recoveryWordsHash(words)15331952             recoveryWordsSet = true15341953             priorPair1535-        }1954+        } ?: return@withLock false15361955         // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow15371956         // (cross-platform recovery of the sync root key) and Android's whole-payload15381957         // escrow (offline forgot-password restore of entries). Both derive their key15391958         // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.15401959         val persisted = runCatching {1541-            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1960+            persistSlotAWithEscrows(1961+                buildRecoveryEscrow(words),1962+                buildRootKeyEscrow(words),1963+                expectedEpoch = epoch,1964+            )15421965         }.getOrDefault(false)15431966         if (!persisted) {1544-            mutateState {1967+            mutateIfCurrentEpoch(epoch) {15451968                 recoveryWordHash = prior.first15461969                 recoveryWordsSet = prior.second15471970             }@@ -1551,8 +1974,10 @@15511974 15521975     /** Verify entered words against the stored recovery hash. */15531976     fun verifyRecoveryWords(words: List<String>): Boolean {1554-        val stored = recoveryWordHash ?: return false1555-        return stored == recoveryWordsHash(words)1977+        return mutateState {1978+            val stored = recoveryWordHash ?: return@mutateState false1979+            stored == recoveryWordsHash(words)1980+        }15561981     }15571982 15581983     // ---- Recovery escrow (forgot-password) --------------------------------@@ -1574,54 +1999,111 @@15741999      * Argon2id key — so this opens the escrowed payload directly.15752000      */15762001     suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2002+        val expectedEpoch = currentEpoch()15772003         val slotA = store.loadSlotA() ?: return false15782004         if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false15792005 15802006         // Prefer Android's whole-payload escrow: a full offline restore of entries,15812007         // root key, and settings. Present for Android-created vaults.1582-        slotA.recoveryEscrow?.let { escrow ->1583-            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1584-            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1585-            val keyBytes = recoveryKey.encoded1586-            try {1587-                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1588-                val payload = VaultCodec.decode(plaintext)1589-                // H-03: adoption is one atomic state-write — lock() cannot interleave.1590-                mutateState {1591-                    decoyMode = false1592-                    entryList = payload.entries.toMutableList()1593-                    duressConfigured = payload.duressConfigured1594-                    recoveryWordHash = payload.recoveryWordHash1595-                    recoveryWordsSet = payload.recoveryWordsSet1596-                    rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1597-                              else generateRecoverableRootKey()1598-                    _settings.value = store.loadSettings()2008+        val fullEscrow = slotA.recoveryEscrow2009+        if (fullEscrow != null) {2010+            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2011+            if (material != null) {2012+                var keyBytes: ByteArray? = null2013+                var plaintext: ByteArray? = null2014+                var candidateRoot: ByteArray? = null2015+                var stateCommitStarted = false2016+                try {2017+                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2018+                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2019+                    val payload = VaultCodec.decode(plaintext)2020+                    // Stage every fallible operation before touching live session2021+                    // fields.  In particular, a settings/provider/decode failure2022+                    // cannot leave entries, flags, or a root key resident while the2023+                    // public session remains locked.2024+                    val candidateSettings = store.loadSettings()2025+                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2026+                        val decodedRoot = unb64(payload.rootKeyB64)2027+                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2028+                            // Do not let a malformed authenticated payload abandon2029+                            // this decoded root-key candidate before the outer2030+                            // finally block owns it.2031+                            VaultCrypto.zero(decodedRoot)2032+                            throw IllegalArgumentException("invalid recovery root key")2033+                        }2034+                        decodedRoot2035+                    } else {2036+                        generateRecoverableRootKey()2037+                    }2038+                    stateCommitStarted = true2039+                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2040+                        clearSessionStateLocked()2041+                        entryList = payload.entries.toMutableList()2042+                        duressConfigured = payload.duressConfigured2043+                        recoveryWordHash = payload.recoveryWordHash2044+                        recoveryWordsSet = payload.recoveryWordsSet2045+                        rootKey = candidateRoot2046+                        candidateRoot = null2047+                        _settings.value = candidateSettings2048+                        publish()2049+                        // This is the authenticated recovery half-unlock boundary:2050+                        // only set it after every candidate field is installed.2051+                        sessionDataAvailable = true2052+                        _isUnlocked.value = false2053+                        true2054+                    }2055+                    if (adopted == true) return@withLock true2056+                } catch (_: Exception) {2057+                    // Wrong words, malformed payloads, provider failures, and2058+                    // settings failures all fall through to the browser-compatible2059+                    // root-key escrow.  If the state commit had started, invalidate2060+                    // that epoch so no partial candidate survives.2061+                    if (stateCommitStarted) {2062+                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2063+                    }2064+                } finally {2065+                    plaintext?.let(VaultCrypto::zero)2066+                    VaultCrypto.zero(material)2067+                    keyBytes?.let(VaultCrypto::zero)2068+                    candidateRoot?.let(VaultCrypto::zero)15992069                 }1600-                VaultCrypto.zero(plaintext)1601-                return true1602-            } catch (_: Exception) {1603-                // Wrong words against this escrow — fall through to the root-key escrow.1604-            } finally {1605-                VaultCrypto.zero(material)1606-                VaultCrypto.zero(keyBytes)16072070             }16082071         }16092072 16102073         // Browser-format vault (only the root-key escrow): recover the 32-byte root16112074         // key (sync identity) and reset the password; entries return via sync. This16122075         // is exactly the browser's recovery semantics (`messages.ts:312-347`).1613-        slotA.rootKeyEscrow?.let { escrow ->1614-            val rk = unsealRootKeyEscrow(words, escrow) ?: return false1615-            mutateState {1616-                decoyMode = false1617-                entryList = mutableListOf()1618-                duressConfigured = false1619-                recoveryWordHash = recoveryWordsHash(words)1620-                recoveryWordsSet = true1621-                rootKey = rk1622-                _settings.value = store.loadSettings()2076+        val rootEscrow = slotA.rootKeyEscrow2077+        if (rootEscrow != null) {2078+            var candidateRoot: ByteArray? = null2079+            var stateCommitStarted = false2080+            try {2081+                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2082+                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2083+                val candidateSettings = store.loadSettings()2084+                stateCommitStarted = true2085+                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2086+                    clearSessionStateLocked()2087+                    entryList = mutableListOf()2088+                    duressConfigured = false2089+                    recoveryWordHash = recoveryWordsHash(words)2090+                    recoveryWordsSet = true2091+                    rootKey = candidateRoot2092+                    candidateRoot = null2093+                    _settings.value = candidateSettings2094+                    publish()2095+                    sessionDataAvailable = true2096+                    _isUnlocked.value = false2097+                    true2098+                }2099+                if (adopted == true) return@withLock true2100+            } catch (_: Exception) {2101+                if (stateCommitStarted) {2102+                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2103+                }2104+            } finally {2105+                candidateRoot?.let(VaultCrypto::zero)16232106             }1624-            return true16252107         }16262108         return false16272109     }@@ -1639,10 +2121,11 @@16392121         words: List<String>,16402122         newPassword: String,16412123     ): Boolean = mutex.withLock {2124+        val expectedEpoch = currentEpoch()16422125         // H-03: read rootKey + entryList under stateLock before computing the new16432126         // plaintext, then re-publish + swap the derived key under stateLock so a16442127         // concurrent lock() cannot straddle the two fences.1645-        if (!mutateState { rootKey != null }) return false2128+        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false16462129         val priorA = store.loadSlotA() ?: return false16472130         var newKey: ByteArray? = null16482131         var plaintext: ByteArray? = null@@ -1668,16 +2151,16 @@16682151             // commit-or-keep-old-key contract so a partial write can't strand16692152             // the session on a key that no longer matches disk.16702153             val priorB = store.loadSlotB()1671-            if (!commitVaultRotation(priorA, priorB, next)) return false2154+            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false16722155             // H-03 (P1-2 codex): zero the old live key and install the new one16732156             // inside the same stateLock critical section so a concurrent lock()16742157             // can't observe the in-between.1675-            mutateState {2158+            if (mutateIfCurrentEpoch(expectedEpoch) {16762159                 derivedKey?.let(VaultCrypto::zero)16772160                 derivedKey = newKey; newKey = null16782161                 publish()16792162                 _isUnlocked.value = true1680-            }2163+            } == null) return false16812164             true16822165         } catch (_: Exception) {16832166             false@@ -1696,17 +2179,19 @@16962179     private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {16972180         if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null16982181         val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1699-        val salt = VaultCrypto.generateSalt()1700-        val recoveryKey = deriveRecoveryKey(material, salt)1701-        val keyBytes = recoveryKey.encoded1702-        val plaintext = VaultCodec.encode(currentPayload())2182+        var keyBytes: ByteArray? = null2183+        var plaintext: ByteArray? = null17032184         return try {2185+            val salt = VaultCrypto.generateSalt()2186+            val recoveryKey = deriveRecoveryKey(material, salt)2187+            keyBytes = recoveryKey.encoded2188+            plaintext = VaultCodec.encode(currentPayload())17042189             val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)17052190             RecoveryEscrow(salt = b64(salt), blob = b64(sealed))17062191         } finally {17072192             VaultCrypto.zero(material)1708-            VaultCrypto.zero(keyBytes)1709-            VaultCrypto.zero(plaintext)2193+            keyBytes?.let(VaultCrypto::zero)2194+            plaintext?.let(VaultCrypto::zero)17102195         }17112196     }17122197 @@ -1720,13 +2205,18 @@17202205      */17212206     private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {17222207         if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1723-        val rk = rootKey ?: return null17242208         val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1725-        val salt = VaultCrypto.generateSalt()1726-        val recoveryKey = deriveRecoveryKey(material, salt)1727-        val keyBytes = recoveryKey.encoded1728-        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2209+        val rk = mutateState { rootKey?.copyOf() } ?: run {2210+            VaultCrypto.zero(material)2211+            return null2212+        }2213+        var keyBytes: ByteArray? = null2214+        var plaintext: ByteArray? = null17292215         return try {2216+            val salt = VaultCrypto.generateSalt()2217+            val recoveryKey = deriveRecoveryKey(material, salt)2218+            keyBytes = recoveryKey.encoded2219+            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)17302220             // combined = nonce(12) || ciphertext || tag(16)17312221             val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)17322222             val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)@@ -1735,19 +2225,21 @@17352225             val ct = ctTag.copyOfRange(0, ctTag.size - 16)17362226             RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))17372227         } finally {2228+            VaultCrypto.zero(rk)17382229             VaultCrypto.zero(material)1739-            VaultCrypto.zero(keyBytes)1740-            VaultCrypto.zero(plaintext)2230+            keyBytes?.let(VaultCrypto::zero)2231+            plaintext?.let(VaultCrypto::zero)17412232         }17422233     }17432234 17442235     /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */17452236     private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {17462237         val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1747-        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1748-        val keyBytes = recoveryKey.encoded2238+        var keyBytes: ByteArray? = null17492239         var pt: ByteArray? = null17502240         return try {2241+            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2242+            keyBytes = recoveryKey.encoded17512243             val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)17522244             pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)17532245             hexToRootKey(String(pt, Charsets.US_ASCII))@@ -1758,7 +2250,7 @@17582250             // IKM-equivalent of the vault identity. material+keyBytes were17592251             // already zeroed; pt was leaking.17602252             VaultCrypto.zero(material)1761-            VaultCrypto.zero(keyBytes)2253+            keyBytes?.let(VaultCrypto::zero)17622254             pt?.let(VaultCrypto::zero)17632255         }17642256     }@@ -1772,45 +2264,67 @@17722264     private fun persistSlotAWithEscrows(17732265         recoveryEscrow: RecoveryEscrow?,17742266         rootKeyEscrow: RootKeyEscrow?,2267+        expectedEpoch: Long? = null,17752268     ): Boolean {1776-        val key = derivedKey ?: throw IllegalStateException("vault_locked")1777-        if (decoyMode) return false1778-        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1779-        val plaintext = VaultCodec.encode(currentPayload())1780-        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1781-        val next = prior.copy(1782-            blob = blob,1783-            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1784-            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1785-        )1786-        if (!store.writeSlotA(next)) return false2269+        val snapshot = snapshotForPersist(expectedEpoch)2270+            ?: throw IllegalStateException("vault_locked_or_stale")2271+        if (snapshot.decoy) {2272+            VaultCrypto.zero(snapshot.key)2273+            return false2274+        }17872275         try {1788-            writeMatchingNoiseSlotB(next)1789-            // H-04: escrow rebuild changes slot A's plaintext size, so re-pad1790-            // the duress slot too. A real decoy is left alone; a fake is1791-            // re-sized to slot A's new ciphertext length.1792-            if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")1793-        } catch (e: Exception) {1794-            store.writeSlotA(prior)1795-            throw e2276+            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2277+            val plaintext = VaultCodec.encode(snapshot.payload)2278+            val blob = try {2279+                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2280+            } finally {2281+                // The escrow path used to drop this reference before zeroing it;2282+                // keep it live through the fill on both success and failure.2283+                VaultCrypto.zero(plaintext)2284+            }2285+            val next = prior.copy(2286+                blob = blob,2287+                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2288+                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2289+            )2290+            beforeDurableCommit()2291+            return withEpochCommit(snapshot.epoch) {2292+                if (!store.writeSlotA(next)) return@withEpochCommit false2293+                try {2294+                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2295+                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2296+                    // the duress slot too. A real decoy is left alone; a fake is2297+                    // re-sized to slot A's new ciphertext length.2298+                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2299+                } catch (e: Exception) {2300+                    store.writeSlotA(prior)2301+                    throw e2302+                }2303+                true2304+            } ?: false2305+        } finally {2306+            VaultCrypto.zero(snapshot.key)17962307         }1797-        return true17982308     }17992309 18002310     // ---- Internals --------------------------------------------------------18012311 1802-    private fun requireUnlocked() {2312+    private fun requireUnlocked(): Long {18032313         // H-03: read derivedKey atomically with [lock]'s reset; without it,18042314         // [lock] from a background lifecycle event can run between this read18052315         // and the caller's first state touch.18062316         stateLock.lock()18072317         try {18082318             if (derivedKey == null) throw IllegalStateException("vault_locked")2319+            return sessionEpoch18092320         } finally {18102321             stateLock.unlock()18112322         }18122323     }18132324 2325+    /** A locked or half-adopted session must not expose secret-bearing fields. */2326+    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null2327+18142328     private fun countDuplicateEntriesLocked(): Int {18152329         val seen = mutableMapOf<String, VaultEntry>()18162330         var duplicates = 0@@ -1844,7 +2358,9 @@18442358      * that depend on the snapshot existing (notably [restoreVaultSnapshot])18452359      * must check the result and roll back if it does not.18462360      */1847-    private fun takeVaultSnapshot(reason: String): Boolean {2361+    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2362+        val epoch = expectedEpoch ?: currentEpoch()2363+        if (mutateIfCurrentEpoch(epoch) { true } != true) return false18482364         val slotA = store.loadSlotA() ?: return false18492365         val vaultA = VaultCodec.encodeSlot(slotA)18502366         val vaultB = store.loadSlotB() ?: slotA.blob@@ -1862,11 +2378,13 @@18622378         val history = store.loadVaultHistory()18632379         val latest = history.snapshots.firstOrNull()18642380         if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true1865-        val entryCount = if (derivedKey != null && !decoyMode) {1866-            bucketEntryCount(entryList.count { it.deletedAt == null })1867-        } else {1868-            null1869-        }2381+        val entryCount = mutateIfCurrentEpoch(epoch) {2382+            if (derivedKey != null && !decoyMode) {2383+                bucketEntryCount(entryList.count { it.deletedAt == null })2384+            } else {2385+                null2386+            }2387+        } ?: return false18702388         return try {18712389             val snapshot = VaultSnapshot(18722390                 id = UUID.randomUUID().toString(),@@ -1880,9 +2398,11 @@18802398             // P1-4 (codex): return the history-write boolean to callers so a18812399             // false return no longer leaves the snapshot out of history while18822400             // callers (notably restoreVaultSnapshot) proceed as if it succeeded.1883-            store.writeVaultHistory(1884-                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1885-            )2401+            withEpochCommit(epoch) {2402+                store.writeVaultHistory(2403+                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2404+                )2405+            } ?: false18862406         } catch (_: Exception) {18872407             false18882408         }@@ -1931,7 +2451,9 @@19312451     }19322452 19332453     /** The slot-A plaintext envelope for the current in-memory session. */1934-    private fun currentPayload(): VaultPayload =2454+    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }2455+2456+    private fun currentPayloadLocked(): VaultPayload =19352457         VaultPayload(19362458             entries = entryList.toList(),19372459             duressConfigured = duressConfigured,@@ -2010,17 +2532,29 @@20102532     }20112533 20122534     /** Re-seal slot A from the current in-memory state under the live key. */2013-    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {2014-        val key = derivedKey ?: throw IllegalStateException("vault_locked")2015-        var plaintext: ByteArray? = null2535+    private fun persistSlotA(2536+        bumpVersion: Boolean,2537+        snapshotReason: String? = "local_save",2538+        expectedEpoch: Long? = null,2539+    ): Boolean {2540+        val snapshot = snapshotForPersist(expectedEpoch)2541+            ?: throw IllegalStateException("vault_locked_or_stale")20162542         return try {20172543             // Decoy sessions persist to the decoy slot only — never touch the real slot A.2018-            if (decoyMode) {2544+            if (snapshot.decoy) {20192545                 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2020-                plaintext = VaultCodec.encode(currentPayload())2021-                val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2022-                plaintext = null2023-                return store.writeDecoySlot(prior.copy(blob = blob))2546+                val plaintext = VaultCodec.encode(snapshot.payload)2547+                val blob = try {2548+                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2549+                } finally {2550+                    // Keep the last reference alive until the zero, including the2551+                    // successful encrypt path (M-05).2552+                    VaultCrypto.zero(plaintext)2553+                }2554+                beforeDurableCommit()2555+                return withEpochCommit(snapshot.epoch) {2556+                    store.writeDecoySlot(prior.copy(blob = blob))2557+                } ?: false20242558             }20252559             val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")20262560             // H-01: captured BEFORE the noise write so a stamp failure can restore@@ -2028,33 +2562,40 @@20282562             // overwritten by writeMatchingNoiseSlotB below, so a stamp failure20292563             // leaves (new A, new B, old version) on disk — partial commit.20302564             val priorB = store.loadSlotB()2031-            if (snapshotReason != null) takeVaultSnapshot(snapshotReason)2032-            plaintext = VaultCodec.encode(currentPayload())2033-            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2034-            plaintext = null2035-            val next = prior.copy(blob = blob)2036-            if (!store.writeSlotA(next)) return false2037-            // Slot A committed — keep slot B size-matched, then advance the version2038-            // for local writes only. Restore both A and B if a later step fails.2039-            // P1-1 (codex): the noise write's boolean AND any thrown exception from2040-            // it must trigger rollback; previously the call sat outside the try.2041-            try {2042-                if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2043-                // H-04: duress slot stays present (real one untouched, fake one2044-                // re-padded to slot A's current size on every persist).2045-                if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2046-                if (bumpVersion) store.stampLocalMutation()2047-            } catch (e: Exception) {2048-                store.writeSlotA(prior)2049-                if (priorB != null) runCatching { store.writeSlotB(priorB) }2050-                throw e2565+            beforeDurableCommit()2566+            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2567+            val plaintext = VaultCodec.encode(snapshot.payload)2568+            val blob = try {2569+                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2570+            } finally {2571+                // Do not drop [plaintext] before fill(0): the serialized vault2572+                // contains every entry password and is the primary M-05 target.2573+                VaultCrypto.zero(plaintext)20512574             }2052-            true2575+            val next = prior.copy(blob = blob)2576+            // The epoch check and the entire A/B/D/version commit share one brief2577+            // stateLock critical section. lock() either wins before this block or2578+            // waits until the already-committed write is complete; it can never2579+            // clear the session between the check and the durable write.2580+            withEpochCommit(snapshot.epoch) {2581+                if (!store.writeSlotA(next)) return@withEpochCommit false2582+                try {2583+                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2584+                    // H-04: duress slot stays present (real one untouched, fake one2585+                    // re-padded to slot A's current size on every persist).2586+                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2587+                    if (bumpVersion) store.stampLocalMutation()2588+                } catch (e: Exception) {2589+                    store.writeSlotA(prior)2590+                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2591+                    throw e2592+                }2593+                true2594+            } ?: false20532595         } finally {2054-            // M-05: the full vault plaintext crosses this helper on every save.2055-            // Zeroing in finally covers both the success and the rollback/throw2056-            // paths so a partial commit can't leave a copy in GC.2057-            plaintext?.let(VaultCrypto::zero)2596+            // Snapshot keys are copies, so lock() may zero the live key while this2597+            // operation is still deriving/encrypting; always scrub our copy too.2598+            VaultCrypto.zero(snapshot.key)20582599         }20592600     }20602601 @@ -2069,24 +2610,28 @@20692610         priorA: StoredVault,20702611         priorB: String?,20712612         nextA: StoredVault,2613+        expectedEpoch: Long,20722614     ): Boolean {2073-        if (!store.writeSlotA(nextA)) return false2074-        // P1-1 (codex): the noise write's boolean AND any thrown exception from2075-        // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2076-        // escaped the helper and left slot A committed.2077-        try {2078-            if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2079-            // H-04: a password rotation changes slot A's plaintext size (new2080-            // salt → re-encrypted blob), so the duress slot must be re-padded2081-            // alongside. writeCompanionDuressSlot leaves a real decoy alone2082-            // and re-emits a fake of slot A's size otherwise.2083-            if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2084-            store.stampLocalMutation()2085-        } catch (_: Exception) {2086-            rollbackRotation(priorA, priorB)2087-            return false2088-        }2089-        return true2615+        beforeDurableCommit()2616+        return withEpochCommit(expectedEpoch) {2617+            if (!store.writeSlotA(nextA)) return@withEpochCommit false2618+            // P1-1 (codex): the noise write's boolean AND any thrown exception from2619+            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2620+            // escaped the helper and left slot A committed.2621+            try {2622+                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2623+                // H-04: a password rotation changes slot A's plaintext size (new2624+                // salt → re-encrypted blob), so the duress slot must be re-padded2625+                // alongside. writeCompanionDuressSlot leaves a real decoy alone2626+                // and re-emits a fake of slot A's size otherwise.2627+                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2628+                store.stampLocalMutation()2629+            } catch (_: Exception) {2630+                rollbackRotation(priorA, priorB)2631+                return@withEpochCommit false2632+            }2633+            true2634+        } ?: false20902635     }20912636 20922637     /**@@ -2106,16 +2651,19 @@21062651     }21072652 21082653     /** Persist a local mutation, rolling the in-memory list back on failure. */2109-    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {2654+    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {21102655         try {2111-            if (!persistSlotA(bumpVersion = true)) {2112-                mutateState { entryList = rollback }2656+            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2657+                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }21132658                 throw IllegalStateException("storage_write_failed")21142659             }21152660         } catch (e: Exception) {2116-            mutateState { entryList = rollback }2661+            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }21172662             throw e21182663         }2119-        mutateState { publish() }2664+        // A lock can win in the small interval after the durable commit and2665+        // before publication. The epoch check prevents pre-lock entries from2666+        // being republished into a newly locked (or newly unlocked) session.2667+        mutateIfCurrentEpoch(expectedEpoch) { publish() }21202668     }21212669 }

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+1 −0
@@ -92,6 +92,7 @@9292             override fun getAll() = devices9393             override fun get(id: String) = devices.firstOrNull { it.deviceId == id }9494             override fun upsert(device: PairedDevice) = Unit95+            override fun applyManifestBatch(batch: DeviceManifestRegistryBatch) = true9596         }9697 9798         val logs = capturePeachSyncLogs {

view file

app/src/test/java/com/peachpasswords/sync/RegistryTransactionTest.kt+41 −0
@@ -0,0 +1,41 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertFalse5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/** Pure failure-injection coverage for the real SharedPreferences transaction helper. */9+class RegistryTransactionTest {10+    @Test11+    fun failed_forward_commit_explicitly_restores_prior_state() {12+        var state = "prior"13+        val writes = mutableListOf<String>()14+15+        val applied = commitWithRollback("prior", "next", commit = { candidate ->16+            writes += candidate17+            state = candidate18+            candidate != "next"19+        })20+21+        assertFalse(applied)22+        assertEquals("prior", state)23+        assertEquals(listOf("next", "prior"), writes)24+    }25+26+    @Test27+    fun successful_forward_commit_does_not_run_rollback() {28+        var state = "prior"29+        val writes = mutableListOf<String>()30+31+        val applied = commitWithRollback("prior", "next", commit = { candidate ->32+            writes += candidate33+            state = candidate34+            true35+        })36+37+        assertTrue(applied)38+        assertEquals("next", state)39+        assertEquals(listOf("next"), writes)40+    }41+}

view file

app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+71 −0
@@ -38,6 +38,7 @@3838     private class FakeDeviceRegistry(3939         devices: List<PairedDevice> = emptyList(),4040     ) : PairedDeviceRegistry {41+        var failManifestCommit = false4142         private val byId = devices.associateBy { it.deviceId }.toMutableMap()4243         private val revocations = mutableListOf<DeviceRevocation>()4344         override fun getAll(): List<PairedDevice> = byId.values.toList()@@ -45,6 +46,20 @@4546         override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }4647         override fun remove(deviceId: String) { byId.remove(deviceId) }4748         override fun listRevocations(): List<DeviceRevocation> = revocations.toList()49+        override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {50+            if (failManifestCommit) return false51+            val nextRevocations = revocations.toMutableList()52+            val known = nextRevocations.map { it.deviceId }.toSet()53+            nextRevocations += batch.incomingRevocations.filterNot { known.contains(it.deviceId) }54+            val nextDevices = byId.toMutableMap()55+            batch.removals.forEach(nextDevices::remove)56+            batch.upserts.forEach { nextDevices[it.deviceId] = it }57+            revocations.clear()58+            revocations += nextRevocations59+            byId.clear()60+            byId.putAll(nextDevices)61+            return true62+        }4863         override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {4964             val known = revocations.map { it.deviceId }.toSet()5065             val fresh = incoming.filterNot { known.contains(it.deviceId) }@@ -318,6 +333,62 @@318333         assertEquals(meshRelay, admitted?.relayUrl)319334         assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)320335         assertTrue(pending.devices.isEmpty())336+        master.fill(0)337+    }338+339+    @Test fun failed_manifest_commit_restores_pending_before_trust_can_land() = runBlocking {340+        val repo = VaultRepository(FakeStorage())341+        repo.createVault("correct horse")342+        val master = repo.syncMasterKey()!!343+        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")344+        val paired = FakeDeviceRegistry(345+            listOf(346+                PairedDevice(347+                    deviceId = "extension-issuer",348+                    deviceName = "Peach Browser",349+                    deviceType = "extension",350+                    longTermPublicKeyHex = "11".repeat(32),351+                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),352+                    trustSource = "direct",353+                    pairedAt = 1L,354+                ),355+            ),356+        ).also { it.failManifestCommit = true }357+        val pending = FakePendingRegistry().also {358+            it.upsert(359+                PendingVouchedDevice(360+                    deviceId = "new-phone",361+                    deviceName = "June's Phone",362+                    deviceType = "android",363+                    longTermPublicKeyHex = "aa".repeat(32),364+                    signingPublicKeyHex = "bb".repeat(32),365+                    pairedAt = 44L,366+                    receivedAt = 45L,367+                    vouchedByDeviceId = "extension-issuer",368+                ),369+            )370+        }371+        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)372+        val manifest = buildSignedDeviceManifest(373+            issuerDeviceId = "extension-issuer",374+            devices = listOf(375+                ManifestDeviceEntry(376+                    deviceId = "new-phone",377+                    deviceName = "June's Phone",378+                    deviceType = "android",379+                    longTermPublicKeyHex = "aa".repeat(32),380+                    signingPublicKeyHex = "bb".repeat(32),381+                    relayUrl = meshRelay,382+                    pairedAt = 44L,383+                ),384+            ),385+            signingPrivateKey = issuerSigning.privateKey,386+        )387+388+        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })389+390+        assertNull("failed registry commit must not install trust", paired.get("new-phone"))391+        assertEquals("pending introduction is restored for retry", 1, pending.devices.size)321392         master.fill(0)322393     }323394     /**

view file

app/src/test/java/com/peachpasswords/sync/VaultSyncManifestEpochRaceTest.kt+166 −0
@@ -0,0 +1,166 @@1+package com.peachpasswords.sync2+3+import com.peachpasswords.vault.StoredVault4+import com.peachpasswords.vault.CreateVaultResult5+import com.peachpasswords.vault.VaultRepository6+import com.peachpasswords.vault.VaultSettings7+import com.peachpasswords.vault.VaultStorage8+import kotlinx.coroutines.runBlocking9+import org.junit.Assert.assertEquals10+import org.junit.Assert.assertFalse11+import org.junit.Assert.assertTrue12+import org.junit.Test13+import java.util.concurrent.CountDownLatch14+import java.util.concurrent.TimeUnit15+import java.util.concurrent.atomic.AtomicReference16+17+/** Regression for a stale signed manifest mutating only a prefix before relock. */18+class VaultSyncManifestEpochRaceTest {19+    private class Storage : VaultStorage {20+        private var slotA: StoredVault? = null21+        private var slotB: String? = null22+        private var version = 0L23+        private var settings = VaultSettings()24+        override fun loadSlotA() = slotA25+        override fun hasVault() = slotA != null26+        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }27+        override fun loadSlotB() = slotB28+        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }29+        override fun loadDecoySlot(): StoredVault? = null30+        override fun writeDecoySlot(slot: StoredVault): Boolean = true31+        override fun hasDecoySlot(): Boolean = false32+        override fun clearDecoySlot() = Unit33+        override fun version() = version34+        override fun stampLocalMutation(): Long { version += 1; return version }35+        override fun loadSettings() = settings36+        override fun writeSettings(settings: VaultSettings): Boolean { this.settings = settings; return true }37+        override fun clearVault() { slotA = null; slotB = null; version = 0; settings = VaultSettings() }38+    }39+40+    private class Registry(initial: List<PairedDevice>) : PairedDeviceRegistry {41+        private val devices = initial.associateBy { it.deviceId }.toMutableMap()42+        private val revocations = mutableListOf<DeviceRevocation>()43+        var batchCalls = 044+45+        override fun getAll(): List<PairedDevice> = devices.values.toList()46+        override fun get(id: String): PairedDevice? = devices[id]47+        override fun upsert(device: PairedDevice) { devices[device.deviceId] = device }48+        override fun remove(deviceId: String) { devices.remove(deviceId) }49+        override fun listRevocations(): List<DeviceRevocation> = revocations.toList()50+        override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {51+            batchCalls += 152+            batch.removals.forEach(devices::remove)53+            batch.upserts.forEach { devices[it.deviceId] = it }54+            revocations += batch.incomingRevocations55+            return true56+        }57+    }58+59+    private class Pending : PendingVouchedRegistry {60+        override fun getAll(): List<PendingVouchedDevice> = emptyList()61+        override fun upsert(device: PendingVouchedDevice) = Unit62+        override fun remove(id: String) = Unit63+    }64+65+    @Test66+    fun stale_manifest_is_rejected_as_a_whole_batch_after_lock_and_reunlock() = runBlocking {67+        val repo = VaultRepository(Storage())68+        assertTrue(repo.createVault("correct horse battery staple") is CreateVaultResult.Success)69+        repo.lock()70+71+        val master = repo.syncMasterKey() // locked, so derive the issuer from a fresh unlock below72+        assertEquals(null, master)73+        assertTrue(repo.unlock("correct horse battery staple") is com.peachpasswords.vault.UnlockResult.Success)74+        val syncMaster = repo.syncMasterKey()!!75+        val issuerSigning = deriveSyncSigningKeypair(syncMaster, "issuer")76+        val registry = Registry(77+            listOf(78+                PairedDevice(79+                    deviceId = "issuer",80+                    deviceName = "Issuer",81+                    deviceType = "android",82+                    longTermPublicKeyHex = "11".repeat(32),83+                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),84+                    trustSource = "direct",85+                    pairedAt = 1L,86+                ),87+                PairedDevice(88+                    deviceId = "old-device",89+                    deviceName = "Old",90+                    deviceType = "extension",91+                    longTermPublicKeyHex = "dd".repeat(32),92+                    signingPublicKeyHex = "ee".repeat(32),93+                    trustSource = "direct",94+                    pairedAt = 1L,95+                ),96+            ),97+        )98+        val adapter = VaultSyncAdapter(repo, "android-self", registry, Pending())99+        val manifest = buildSignedDeviceManifest(100+            issuerDeviceId = "issuer",101+            devices = listOf(102+                ManifestDeviceEntry(103+                    deviceId = "new-device",104+                    deviceName = "New",105+                    deviceType = "extension",106+                    longTermPublicKeyHex = "aa".repeat(32),107+                    signingPublicKeyHex = "bb".repeat(32),108+                    relayUrl = "wss://relay.peachpasswords.com",109+                    pairedAt = 2L,110+                ),111+            ),112+            signingPrivateKey = issuerSigning.privateKey,113+            revoked = listOf(114+                DeviceRevocation(115+                    deviceId = "old-device",116+                    longTermPublicKeyHex = "dd".repeat(32),117+                    revokedAt = System.currentTimeMillis(),118+                    revokedByDeviceId = "issuer",119+                ),120+            ),121+        )122+123+        val enteredFence = CountDownLatch(1)124+        val releaseFence = CountDownLatch(1)125+        try {126+            repo.beforeSessionEpochCommit = {127+                enteredFence.countDown()128+                check(releaseFence.await(5, TimeUnit.SECONDS)) { "manifest did not release" }129+            }130+            val applyFailure = AtomicReference<Throwable?>()131+            val applyDone = CountDownLatch(1)132+            Thread {133+                try {134+                    runBlocking {135+                        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })136+                    }137+                } catch (t: Throwable) {138+                    applyFailure.set(t)139+                } finally {140+                    applyDone.countDown()141+                }142+            }.start()143+            assertTrue(enteredFence.await(5, TimeUnit.SECONDS))144+145+            val relockDone = CountDownLatch(1)146+            Thread {147+                repo.lock()148+                runBlocking {149+                    assertTrue(repo.unlock("correct horse battery staple") is com.peachpasswords.vault.UnlockResult.Success)150+                }151+                relockDone.countDown()152+            }.start()153+            assertTrue(relockDone.await(5, TimeUnit.SECONDS))154+            releaseFence.countDown()155+            assertTrue(applyDone.await(5, TimeUnit.SECONDS))156+            assertEquals(null, applyFailure.get())157+            assertEquals("the stale epoch must never reach the registry", 0, registry.batchCalls)158+            assertTrue(registry.get("old-device") != null)159+            assertTrue(registry.get("new-device") == null)160+            assertTrue(registry.listRevocations().isEmpty())161+        } finally {162+            releaseFence.countDown()163+            repo.beforeSessionEpochCommit = {}164+        }165+    }166+}

view file

app/src/test/java/com/peachpasswords/vault/CodexRestoreAtomicityTest.kt+81 −0
@@ -8,6 +8,9 @@88 import org.junit.Assert.assertTrue99 import org.junit.Test1010 import java.util.Base6411+import java.util.concurrent.CountDownLatch12+import java.util.concurrent.TimeUnit13+import java.util.concurrent.atomic.AtomicReference1114 1215 /**1316  * C-01: a Codex restore must not touch the device's vault until the candidate@@ -22,6 +25,9 @@2225         var slotB: String? = null2326         var failSlotA = false2427         var failSlotB = false28+        var failNextSlotB = false29+        var failedSlotBEntered: CountDownLatch? = null30+        var releaseFailedSlotB: CountDownLatch? = null2531         private var ver: Long = 02632         private var settings = VaultSettings()2733         override fun loadSlotA() = slotA@@ -33,6 +39,14 @@3339         }3440         override fun loadSlotB() = slotB3541         override fun writeSlotB(blobBase64: String): Boolean {42+            if (failNextSlotB) {43+                failNextSlotB = false44+                failedSlotBEntered?.countDown()45+                check(releaseFailedSlotB?.await(5, TimeUnit.SECONDS) != false) {46+                    "test writer did not release failed slot-B write"47+                }48+                return false49+            }3650             if (failSlotB) return false3751             slotB = blobBase643852             return true@@ -137,5 +151,72 @@137151         }138152         storage.failSlotB = false139153         assertLocalVaultIntact(repo, storage, before)154+    }155+156+    @Test157+    fun partial_codex_write_holds_epoch_fence_through_rollback() = runBlocking {158+        val blob = sourceCodexBlob()159+        val (repo, storage) = existingVault()160+        val priorA = storage.slotA161+        val priorB = storage.slotB162+        val priorD = storage.decoy163+        val failedSlotBEntered = CountDownLatch(1)164+        val releaseFailedSlotB = CountDownLatch(1)165+        val rollbackComplete = CountDownLatch(1)166+        val releaseAfterRollback = CountDownLatch(1)167+        storage.failNextSlotB = true168+        storage.failedSlotBEntered = failedSlotBEntered169+        storage.releaseFailedSlotB = releaseFailedSlotB170+        repo.afterCodexRollback = {171+            rollbackComplete.countDown()172+            check(releaseAfterRollback.await(5, TimeUnit.SECONDS)) {173+                "test did not release post-rollback fence"174+            }175+        }176+177+        val restoreResult = AtomicReference<UnlockResult>()178+        val restoreDone = CountDownLatch(1)179+        val restoreThread = Thread {180+            restoreResult.set(runBlocking { repo.restoreFromCodex(blob, "source pass") })181+            restoreDone.countDown()182+        }183+        restoreThread.start()184+        assertTrue(185+            "Codex transaction did not reach the partial slot-B write",186+            failedSlotBEntered.await(5, TimeUnit.SECONDS),187+        )188+189+        // Start lock while the epoch-held Codex transaction is still blocked in190+        // its failed slot-B write. The lock must wait until A/B/D rollback has191+        // completed, otherwise it can advance the epoch and strand incoming A.192+        val lockStarted = CountDownLatch(1)193+        val lockDone = CountDownLatch(1)194+        val lockThread = Thread {195+            lockStarted.countDown()196+            repo.lock()197+            lockDone.countDown()198+        }199+        lockThread.start()200+        assertTrue("lock thread did not start", lockStarted.await(5, TimeUnit.SECONDS))201+202+        releaseFailedSlotB.countDown()203+        assertTrue("Codex rollback did not finish", rollbackComplete.await(5, TimeUnit.SECONDS))204+        assertEquals("rollback must restore prior slot A before lock completes", priorA, storage.slotA)205+        assertEquals("rollback must restore prior noise slot B before lock completes", priorB, storage.slotB)206+        assertEquals("rollback must restore prior duress slot before lock completes", priorD, storage.decoy)207+        assertTrue(208+            "lock must remain blocked until the epoch-held rollback returns",209+            !lockDone.await(0, TimeUnit.NANOSECONDS),210+        )211+        releaseAfterRollback.countDown()212+        assertTrue("Codex restore did not finish", restoreDone.await(5, TimeUnit.SECONDS))213+        assertTrue("lock did not finish", lockDone.await(5, TimeUnit.SECONDS))214+        restoreThread.join(5_000)215+        lockThread.join(5_000)216+217+        assertEquals(UnlockResult.Error("storage_write_failed"), restoreResult.get())218+        assertEquals("rollback must restore prior slot A", priorA, storage.slotA)219+        assertEquals("rollback must restore prior noise slot B", priorB, storage.slotB)220+        assertEquals("rollback must restore prior duress slot", priorD, storage.decoy)140221     }141222 }

view file

app/src/test/java/com/peachpasswords/vault/DuressSlotBTest.kt+9 −0
@@ -71,6 +71,15 @@7171         assertEquals("decoy vault is empty — no real entries leak", 0, repo.getEntries().size)7272     }7373 74+    @Test fun sibling_duress_tokens_cannot_replace_the_adopted_decoy_session() = runBlocking {75+        val repo = configuredRepo()76+        val first = repo.tryUnlockDuress(duressPass)!!77+        val sibling = repo.tryUnlockDuress(duressPass)!!78+        assertEquals(UnlockResult.Success, repo.commitDuressUnlock(first))79+        assertEquals(UnlockResult.Error("stale_unlock"), repo.commitDuressUnlock(sibling))80+        assertTrue(repo.isUnlocked.value)81+    }82+7483     @Test fun wrong_password_opens_neither() = runBlocking {7584         val repo = configuredRepo()7685         assertNull(repo.tryUnlockSlotA("totally-wrong"))

view file

app/src/test/java/com/peachpasswords/vault/HiddenSessionStateTest.kt+186 −3
@@ -1,11 +1,15 @@11 package com.peachpasswords.vault22 33 import kotlinx.coroutines.runBlocking4+import org.junit.Assert.assertEquals45 import org.junit.Assert.assertFalse56 import org.junit.Assert.assertNotNull67 import org.junit.Assert.assertNull78 import org.junit.Assert.assertTrue89 import org.junit.Test10+import java.util.concurrent.CountDownLatch11+import java.util.concurrent.TimeUnit12+import java.util.concurrent.atomic.AtomicReference913 1014 /**1115  * M-04: no operation may leave in-memory session state that disagrees with what@@ -19,7 +23,9 @@1923         var slotB: String? = null2024         var failWriteSlotA = false2125         private var ver: Long = 022-        private var settings = VaultSettings()26+        var settings = VaultSettings()27+        var failLoadSettings = false28+        var failWriteSettings = false2329         override fun loadSlotA() = slotA2430         override fun hasVault() = slotA != null2531         override fun writeSlotA(slot: StoredVault): Boolean {@@ -36,8 +42,15 @@3642         override fun clearDecoySlot() { decoy = null }3743         override fun version() = ver3844         override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }39-        override fun loadSettings() = settings40-        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }45+        override fun loadSettings(): VaultSettings {46+            if (failLoadSettings) throw IllegalStateException("settings_read_failed")47+            return settings48+        }49+        override fun writeSettings(s: VaultSettings): Boolean {50+            if (failWriteSettings) return false51+            settings = s52+            return true53+        }4154         override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }4255     }4356 @@ -73,11 +86,30 @@7386     @Test fun sync_accessors_are_null_when_locked() = runBlocking {7487         val repo = VaultRepository(FaultStorage())7588         repo.createVault("pw")89+        repo.addEntry(90+            VaultEntry(91+                id = "secret",92+                title = "Secret",93+                type = "login",94+                username = "u",95+                password = "p",96+                createdAt = 0,97+                modifiedAt = 0,98+            ),99+        )76100         assertNotNull(repo.syncMasterKey())77101         assertNotNull(repo.syncVaultId())78102         repo.lock()79103         assertNull(repo.syncMasterKey())80104         assertNull(repo.syncVaultId())105+        assertTrue(repo.getEntries().isEmpty())106+        assertNull(repo.getEntry("secret"))107+        assertTrue(repo.getDeletedEntries().isEmpty())108+        assertTrue(repo.getActiveEntries().isEmpty())109+        assertTrue(repo.getDistinctTags().isEmpty())110+        assertTrue(repo.getDistinctFieldValues("username").isEmpty())111+        assertTrue(repo.getTombstones().isEmpty())112+        assertNull(repo.currentVaultKey())81113     }82114 83115     // A recovery unlock installs a root key but no password key — the session is@@ -113,5 +145,156 @@113145         assertNull("a failed create must not retain a root key", repo.sessionRootKey())114146         assertNull(repo.syncMasterKey())115147         assertNull(repo.currentVaultKey())148+    }149+150+    @Test fun locked_settings_are_safe_default_and_cannot_be_mutated() = runBlocking {151+        val storage = FaultStorage()152+        val repo = VaultRepository(storage)153+        assertTrue(repo.createVault("pw") is CreateVaultResult.Success)154+        val requested = VaultSettings(accentColor = "blue", autoLockMinutes = 5)155+        repo.lock()156+157+        var failed = false158+        try {159+            repo.updateSettings(requested)160+        } catch (_: IllegalStateException) {161+            failed = true162+        }163+        assertTrue("locked settings writes must fail", failed)164+        assertEquals(VaultSettings(), repo.settings.value)165+        assertEquals("locked writes must not reach storage", VaultSettings(), storage.settings)166+    }167+168+    @Test fun stale_settings_write_cannot_publish_or_persist_after_lock() = runBlocking {169+        val storage = FaultStorage()170+        val repo = VaultRepository(storage)171+        assertTrue(repo.createVault("pw") is CreateVaultResult.Success)172+        val requested = VaultSettings(accentColor = "blue", autoLockMinutes = 5)173+        val paused = CountDownLatch(1)174+        val release = CountDownLatch(1)175+        val failure = AtomicReference<Throwable?>()176+        repo.beforeDurableCommit = {177+            paused.countDown()178+            check(release.await(5, TimeUnit.SECONDS)) { "settings writer did not release" }179+        }180+        val writer = Thread {181+            try {182+                runBlocking { repo.updateSettings(requested) }183+            } catch (t: Throwable) {184+                failure.set(t)185+            }186+        }187+        writer.start()188+        assertTrue("settings writer did not reach durable seam", paused.await(5, TimeUnit.SECONDS))189+        repo.lock()190+        assertEquals(VaultSettings(), repo.settings.value)191+        release.countDown()192+        writer.join(5_000)193+        assertFalse(writer.isAlive)194+        assertTrue("stale settings writer must fail closed", failure.get() is IllegalStateException)195+        assertEquals("stale settings must not persist", VaultSettings(), storage.settings)196+    }197+198+    @Test fun failed_full_recovery_settings_load_leaves_no_candidate_session() = runBlocking {199+        val storage = FaultStorage()200+        val repo = VaultRepository(storage)201+        assertTrue(repo.createVault("pw") is CreateVaultResult.Success)202+        repo.addEntry(VaultEntry(id = "secret", title = "Secret", type = "login", password = "p", createdAt = 0, modifiedAt = 0))203+        val key = repo.sessionRootKey()!!204+        val words = try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }205+        assertTrue(repo.setRecoveryWords(words))206+        repo.lock()207+        storage.failLoadSettings = true208+209+        assertFalse(repo.unlockWithRecoveryWords(words))210+        assertFalse(repo.isUnlocked.value)211+        assertNull(repo.sessionRootKey())212+        assertTrue(repo.getEntries().isEmpty())213+        assertFalse(repo.isRecoveryWordsSet())214+        assertEquals(VaultSettings(), repo.settings.value)215+    }216+217+    @Test fun invalid_recovery_root_key_leaves_no_candidate_session() = runBlocking {218+        val storage = FaultStorage()219+        val repo = VaultRepository(storage)220+        assertTrue(repo.createVault("pw") is CreateVaultResult.Success)221+        val root = repo.sessionRootKey()!!222+        val words = try { rootKeyToWords(root) } finally { VaultCrypto.zero(root) }223+224+        // Build an authenticated Android recovery escrow whose payload decodes but225+        // carries a one-byte root key. The failure must not publish a half-adopted226+        // candidate, and the implementation must scrub the rejected decoded bytes.227+        val material = wordsToRootKeyMaterial(words)228+        val salt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() }229+        val recoveryEscrow = try {230+            val recoveryKey = deriveRecoveryKey(material, salt)231+            val keyBytes = recoveryKey.encoded232+            try {233+                val escrowPlaintext = VaultCodec.encode(VaultPayload(rootKeyB64 = "AQ"))234+                try {235+                    val sealed = VaultCrypto.encrypt(escrowPlaintext, keyBytes, VaultCrypto.AAD_RECOVERY)236+                    try {237+                        RecoveryEscrow(238+                            salt = java.util.Base64.getEncoder().withoutPadding().encodeToString(salt),239+                            blob = java.util.Base64.getEncoder().withoutPadding().encodeToString(sealed),240+                        )241+                    } finally {242+                        VaultCrypto.zero(sealed)243+                    }244+                } finally {245+                    VaultCrypto.zero(escrowPlaintext)246+                }247+            } finally {248+                VaultCrypto.zero(keyBytes)249+            }250+        } finally {251+            VaultCrypto.zero(material)252+        }253+        storage.slotA = storage.slotA!!.copy(recoveryEscrow = recoveryEscrow, rootKeyEscrow = null)254+        repo.lock()255+256+        assertFalse(repo.unlockWithRecoveryWords(words))257+        assertFalse(repo.isUnlocked.value)258+        assertNull("invalid root key must not become a hidden session key", repo.sessionRootKey())259+        assertTrue(repo.getEntries().isEmpty())260+        assertFalse(repo.isRecoveryWordsSet())261+        assertEquals(VaultSettings(), repo.settings.value)262+    }263+264+    @Test fun failed_root_only_recovery_settings_load_leaves_no_candidate_session() = runBlocking {265+        val storage = FaultStorage()266+        val repo = VaultRepository(storage)267+        assertTrue(repo.createVault("pw") is CreateVaultResult.Success)268+        val key = repo.sessionRootKey()!!269+        val words = try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }270+        assertTrue(repo.setRecoveryWords(words))271+        storage.slotA = storage.slotA!!.copy(recoveryEscrow = null)272+        repo.lock()273+        storage.failLoadSettings = true274+275+        assertFalse(repo.unlockWithRecoveryWords(words))276+        assertFalse(repo.isUnlocked.value)277+        assertNull(repo.sessionRootKey())278+        assertTrue(repo.getEntries().isEmpty())279+        assertFalse(repo.isRecoveryWordsSet())280+        assertEquals(VaultSettings(), repo.settings.value)281+    }282+283+    @Test fun unlock_with_key_malformed_payload_is_error_not_wrong_password() = runBlocking {284+        val storage = FaultStorage()285+        val repo = VaultRepository(storage)286+        assertTrue(repo.createVault("pw") is CreateVaultResult.Success)287+        val slot = storage.slotA!!288+        val key = VaultCrypto.deriveKey("pw", java.util.Base64.getDecoder().decode(slot.salt))289+        try {290+            val malformed = VaultCrypto.encrypt("not-json".toByteArray(), key, VaultCrypto.AAD_SLOT_A)291+            storage.slotA = slot.copy(blob = java.util.Base64.getEncoder().withoutPadding().encodeToString(malformed))292+        } finally {293+            VaultCrypto.zero(key)294+        }295+        repo.lock()296+        val supplied = VaultCrypto.deriveKey("pw", java.util.Base64.getDecoder().decode(storage.slotA!!.salt))297+        val result = try { repo.unlockWithKey(supplied) } finally { VaultCrypto.zero(supplied) }298+        assertTrue("malformed authenticated payload must be an Error", result is UnlockResult.Error)116299     }117300 }

view file

app/src/test/java/com/peachpasswords/vault/SessionEpochRaceTest.kt+169 −0
@@ -0,0 +1,169 @@1+package com.peachpasswords.vault2+3+import kotlinx.coroutines.runBlocking4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertFalse6+import org.junit.Assert.assertTrue7+import org.junit.Test8+import java.util.concurrent.CountDownLatch9+import java.util.concurrent.TimeUnit10+import java.util.concurrent.atomic.AtomicReference11+12+/**13+ * Deterministic H-03 races. The storage hook pauses a writer after it has14+ * captured/encrypted session state but before the epoch-guarded durable commit.15+ * lock() must invalidate that work; a later unlock must not be overwritten by16+ * the stale writer's pre-lock snapshot.17+ */18+class SessionEpochRaceTest {19+20+    private class Storage : VaultStorage {21+        var slotA: StoredVault? = null22+        var slotB: String? = null23+        private var version = 0L24+        private var settings = VaultSettings()25+        override fun loadSlotA() = slotA26+        override fun hasVault() = slotA != null27+        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }28+        override fun loadSlotB() = slotB29+        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }30+        private var decoy: StoredVault? = null31+        override fun loadDecoySlot() = decoy32+        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }33+        override fun hasDecoySlot() = decoy != null34+        override fun clearDecoySlot() { decoy = null }35+        override fun version() = version36+        override fun stampLocalMutation(): Long { version += 1; return version }37+        override fun loadSettings() = settings38+        override fun writeSettings(settings: VaultSettings): Boolean { this.settings = settings; return true }39+        override fun clearVault() { slotA = null; slotB = null; decoy = null; version = 0; settings = VaultSettings() }40+    }41+42+    private fun login(title: String) = VaultEntry(43+        id = "",44+        title = title,45+        type = "login",46+        password = "secret",47+        createdAt = 0,48+        modifiedAt = 0,49+    )50+51+    @Test52+    fun lock_invalidates_save_paused_before_commit() = runBlocking {53+        val repo = VaultRepository(Storage())54+        assertTrue(repo.createVault("pass") is CreateVaultResult.Success)55+        repo.addEntry(login("before"))56+57+        val paused = CountDownLatch(1)58+        val release = CountDownLatch(1)59+        val failure = AtomicReference<Throwable?>()60+        repo.beforeDurableCommit = {61+            paused.countDown()62+            check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" }63+        }64+65+        val writer = Thread {66+            try {67+                runBlocking { repo.addEntry(login("must-not-persist")) }68+            } catch (t: Throwable) {69+                failure.set(t)70+            }71+        }72+        writer.start()73+        assertTrue("writer did not reach the pre-commit seam", paused.await(5, TimeUnit.SECONDS))74+75+        repo.lock()76+        assertFalse(repo.isUnlocked.value)77+        assertTrue(repo.getEntries().isEmpty())78+        assertEquals(null, repo.currentVaultKey())79+        release.countDown()80+        writer.join(5_000)81+        assertFalse("writer must not remain blocked", writer.isAlive)82+        assertTrue("stale writer should fail closed", failure.get() is IllegalStateException)83+84+        repo.beforeDurableCommit = {}85+        assertTrue(repo.unlock("pass") is UnlockResult.Success)86+        assertEquals(listOf("before"), repo.getEntries().map { it.title })87+    }88+89+    @Test90+    fun lock_invalidates_sync_apply_before_commit() = runBlocking {91+        val repo = VaultRepository(Storage())92+        assertTrue(repo.createVault("pass") is CreateVaultResult.Success)93+94+        val paused = CountDownLatch(1)95+        val release = CountDownLatch(1)96+        val failure = AtomicReference<Throwable?>()97+        repo.beforeDurableCommit = {98+            paused.countDown()99+            check(release.await(5, TimeUnit.SECONDS)) { "test sync did not release" }100+        }101+        val remote = VaultEntry(102+            id = "remote-race",103+            title = "must-not-persist",104+            type = "login",105+            password = "secret",106+            createdAt = 1,107+            modifiedAt = System.currentTimeMillis() + 1_000,108+        )109+        val writer = Thread {110+            try {111+                runBlocking { repo.applyMergedRemoteEntries(listOf(remote), emptyList()) }112+            } catch (t: Throwable) {113+                failure.set(t)114+            }115+        }116+        writer.start()117+        assertTrue(paused.await(5, TimeUnit.SECONDS))118+119+        repo.lock()120+        release.countDown()121+        writer.join(5_000)122+        assertFalse(writer.isAlive)123+        assertTrue("stale sync must fail closed or return no-op", failure.get() == null || failure.get() is IllegalStateException)124+125+        repo.beforeDurableCommit = {}126+        assertTrue(repo.unlock("pass") is UnlockResult.Success)127+        assertEquals(null, repo.getEntry(remote.id))128+    }129+130+    @Test131+    fun lock_before_state_mutation_cannot_repopulate_a_new_session() = runBlocking {132+        val repo = VaultRepository(Storage())133+        assertTrue(repo.createVault("pass") is CreateVaultResult.Success)134+        repo.addEntry(login("before"))135+136+        val paused = CountDownLatch(1)137+        val release = CountDownLatch(1)138+        val failure = AtomicReference<Throwable?>()139+        repo.beforeSessionStateMutation = {140+            paused.countDown()141+            check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" }142+        }143+144+        val writer = Thread {145+            try {146+                runBlocking { repo.addEntry(login("stale-state")) }147+            } catch (t: Throwable) {148+                failure.set(t)149+            }150+        }151+        writer.start()152+        assertTrue("writer did not reach the pre-state seam", paused.await(5, TimeUnit.SECONDS))153+154+        // First lock clears the old session. Re-unlock before the stale writer155+        // is released to prove it cannot mutate the newly-adopted generation.156+        repo.lock()157+        assertTrue(repo.getEntries().isEmpty())158+        assertTrue(repo.unlock("pass") is UnlockResult.Success)159+        assertEquals(listOf("before"), repo.getEntries().map { it.title })160+161+        release.countDown()162+        writer.join(5_000)163+        assertFalse("stale writer must finish", writer.isAlive)164+        assertTrue("stale writer must fail closed", failure.get() is IllegalStateException)165+        assertEquals(listOf("before"), repo.getEntries().map { it.title })166+167+        repo.beforeSessionStateMutation = {}168+    }169+}

view file

app/src/test/java/com/peachpasswords/vault/SlotAUnlockTest.kt+43 −0
@@ -66,4 +66,47 @@6666         repo.discardSlotAUnlock(derived) // zero without adopting6767         assertFalse("discarded unlock never adopts the session", repo.isUnlocked.value)6868     }69+70+    @Test fun sibling_tokens_cannot_replace_a_successfully_adopted_session() = runBlocking {71+        val repo = freshRepo()72+        repo.createVault("correct horse battery staple")73+        repo.lock()74+75+        val first = repo.tryUnlockSlotA("correct horse battery staple")!!76+        val sibling = repo.tryUnlockSlotA("correct horse battery staple")!!77+        assertEquals(UnlockResult.Success, repo.commitSlotAUnlock(first))78+        assertEquals(79+            "a token from the consumed epoch must not replace the adopted session",80+            UnlockResult.Error("stale_unlock"),81+            repo.commitSlotAUnlock(sibling),82+        )83+        assertTrue(repo.isUnlocked.value)84+    }85+86+    @Test fun an_old_token_cannot_resume_after_lock_and_new_unlock() = runBlocking {87+        val repo = freshRepo()88+        repo.createVault("correct horse battery staple")89+        repo.lock()90+91+        val old = repo.tryUnlockSlotA("correct horse battery staple")!!92+        repo.lock()93+        val current = repo.tryUnlockSlotA("correct horse battery staple")!!94+        assertEquals(UnlockResult.Success, repo.commitSlotAUnlock(current))95+        assertEquals(96+            "a paused old unlock must fail closed after lock/re-unlock",97+            UnlockResult.Error("stale_unlock"),98+            repo.commitSlotAUnlock(old),99+        )100+        assertTrue(repo.isUnlocked.value)101+    }102+103+    @Test fun a_token_is_one_shot_even_when_the_first_commit_succeeds() = runBlocking {104+        val repo = freshRepo()105+        repo.createVault("correct horse battery staple")106+        repo.lock()107+        val token = repo.tryUnlockSlotA("correct horse battery staple")!!108+        assertEquals(UnlockResult.Success, repo.commitSlotAUnlock(token))109+        assertEquals(UnlockResult.Error("unlock_token_consumed"), repo.commitSlotAUnlock(token))110+        assertTrue(repo.isUnlocked.value)111+    }69112 }

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+3 −0
@@ -66,6 +66,9 @@6666         val r = repo()6767         r.lock()6868         assertEquals(UnlockResult.WrongPassword, r.unlockWithKey(ByteArray(32) { 9 }))69+        assertFalse("failed staged biometric decode leaves no live session", r.isUnlocked.value)70+        assertTrue("failed staged biometric decode exposes no entries", r.getEntries().isEmpty())71+        assertNull("failed staged biometric decode exposes no key", r.currentVaultKey())6972     }7073 7174     @Test fun codex_blob_restores_on_another_repo() = runBlocking {

view file