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
↓ peach-android-4bbb8467.tar.gzatlas at this save
files changed23 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 −0app/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"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 = 200Lapp/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()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 {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 }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 {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, soapp/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()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 {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 {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 }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 }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 {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+}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 /**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+}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 }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"))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 }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+}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 }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 {