VaultRepository.kt

133.7 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton2526/** Lenient parser for the Codex vault-blob wrapper only. */27private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829/** Omits null optional fields — used for browser-shaped duress slot export. */30private val BrowserSlotJson = Json { encodeDefaults = false }3132private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)33private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)3435/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */36private const val PASSWORD_HISTORY_MAX = 5037private const val VAULT_HISTORY_MAX = 53839// How long the transient-background-lock suppression stays valid. If the40// launched system Activity never reports the expected lifecycle stop (picker41// crashed, user backed out into another app, OEM killed the picker process),42// the flag expires and the next genuine background event locks the vault43// rather than consuming a stale suppression.44private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4546sealed interface UnlockResult {47    data object Success : UnlockResult48    data object WrongPassword : UnlockResult49    data class Error(val reason: String) : UnlockResult50}5152sealed interface CreateVaultResult {53    data object Success : CreateVaultResult54    data class Error(val reason: String) : CreateVaultResult55}5657sealed interface AdoptVaultResult {58    data class Success(val vaultId: String) : AdoptVaultResult59    data class Error(val reason: String) : AdoptVaultResult60}6162/**63 * In-memory vault session backed by [VaultStore]. Faithful port of the browser64 * `vault.ts` semantics that matter for correctness:65 *66 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched67 *    noise blob so a fresh install shows two similarly-shaped records.68 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a69 *    deletion always wins last-write-wins against an in-flight edit.70 *  - Local writes bump the version counter; sync-applied writes do NOT (so a71 *    sync round never cascades into another outbound sync).72 *  - add/update/delete roll back in-memory state if the disk write fails.73 *  - destroyVault never touches the Pro license (separate store).74 */75@Singleton76class VaultRepository @Inject constructor(77    private val store: VaultStorage,78) {79    private val mutex = Mutex()80    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend81    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync82    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so83    // lock() can serialize against an in-flight suspend op without blocking on84    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and85    // take [stateLock] only around the state-write portions. Acquisition order86    // is always mutex → stateLock.87    private val stateLock = ReentrantLock()88    private val rng = SecureRandom()8990    /**91     * Monotonic session generation.  [lock] advances this before clearing any92     * secret state; work that captured the previous generation is therefore93     * stale even if it is still running its (non-suspending) storage step.94     */95    private var sessionEpoch: Long = 0L9697    private fun nextSessionEpoch(epoch: Long): Long =98        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L99100    /** Test seam for deterministic "lock while waiting to commit" races. */101    @VisibleForTesting102    internal var beforeDurableCommit: () -> Unit = {}103104    /** Test seam after a failed Codex rollback, while the epoch fence is held. */105    @VisibleForTesting106    internal var afterCodexRollback: () -> Unit = {}107108    /**109     * Run [block] with [stateLock] held. Used to fence the small state-write110     * portions of suspend operations against [lock] and [requireUnlocked]111     * racing from another thread.112     */113    private inline fun <T> mutateState(block: () -> T): T {114        stateLock.lock()115        return try { block() } finally { stateLock.unlock() }116    }117118    /** Run a durable commit only while the session generation is still live. */119    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {120        stateLock.lock()121        return try {122            if (sessionEpoch != epoch) null else block()123        } finally {124            stateLock.unlock()125        }126    }127128    /** Test seam for a caller that must stage work before the epoch fence. */129    @VisibleForTesting130    internal var beforeSessionEpochCommit: () -> Unit = {}131132    /**133     * Test seam for the narrower H-03 race: a suspend mutation has authenticated134     * the session and staged its candidate state, but has not yet entered the135     * epoch-fenced in-memory write. Production callers leave this as a no-op.136     */137    @VisibleForTesting138    internal var beforeSessionStateMutation: () -> Unit = {}139140    /**141     * Run an external session-bound commit while the repository's epoch fence is142     * held. The callback is deliberately non-suspending: [lock] cannot advance143     * the epoch until the whole external batch has reached its linearization144     * point.145     */146    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {147        beforeSessionEpochCommit()148        return withEpochCommit(epoch, block)149    }150151    private fun currentEpoch(): Long = mutateState { sessionEpoch }152153    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =154        mutateState { if (sessionEpoch == epoch) block() else null }155156    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */157    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {158        beforeSessionStateMutation()159        return mutateIfCurrentEpoch(epoch, block)160    }161162    private data class PersistSnapshot(163        val epoch: Long,164        val key: ByteArray,165        val payload: VaultPayload,166        val decoy: Boolean,167    )168169    /** Copy all session material while fenced; callers own and must zero [key]. */170    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {171        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null172        val liveKey = derivedKey ?: return@mutateState null173        PersistSnapshot(174            epoch = sessionEpoch,175            key = liveKey.copyOf(),176            payload = currentPayloadLocked(),177            decoy = decoyMode,178        )179    }180181    private var derivedKey: ByteArray? = null182    private var rootKey: ByteArray? = null183    private var entryList: MutableList<VaultEntry> = mutableListOf()184    private var duressConfigured: Boolean = false185    private var recoveryWordHash: String? = null186    private var recoveryWordsSet: Boolean = false187    /** True when an authenticated vault payload is available for local reads. */188    private var sessionDataAvailable: Boolean = false189    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */190    private var decoyMode: Boolean = false191192    private val _isUnlocked = MutableStateFlow(false)193    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()194195    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())196    /** Active (non-deleted) entryList. */197    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()198199    private val _settings = MutableStateFlow(VaultSettings())200    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()201202    /** Clear every in-memory session field while [stateLock] is held. */203    private fun clearSessionStateLocked() {204        derivedKey?.let(VaultCrypto::zero)205        rootKey?.let(VaultCrypto::zero)206        derivedKey = null207        rootKey = null208        entryList = mutableListOf()209        duressConfigured = false210        recoveryWordHash = null211        recoveryWordsSet = false212        sessionDataAvailable = false213        decoyMode = false214        _settings.value = VaultSettings()215        _entries.value = emptyList()216        _isUnlocked.value = false217    }218219    fun hasVault(): Boolean = store.hasVault()220221    // ---- Codex (printed paper backup) -------------------------------------222223    /**224     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the225     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's226     * A/B naming where slot B is the duress decoy); the noise slot is local-only227     * and never crosses devices. Restorable on any device that has the master228     * password. Null when no vault exists.229     */230    fun codexVaultBlobBase64(): String? {231        val slotA = store.loadSlotA() ?: return null232        // H-04: duress slot is always present after createVault (real or fake),233        // so the wrapper can rely on it being loadable.234        val slotD = store.loadDecoySlot() ?: return null235        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""236        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))237    }238239    /**240     * Replace the local vault from a Codex blob, then unlock with [password].241     * Writes slot A (and a size-matched noise slot B) before unlocking so the242     * restored vault is at rest exactly as a freshly-created one would be.243     */244    /** A slot is stored either as a JSON string (browser) or inline object (Android). */245    private fun JsonElement?.asSlotJson(): String? = when {246        this == null || this is JsonNull -> null247        this is JsonPrimitive && isString -> content248        else -> toString()249    }250251    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {252        val expectedEpoch = currentEpoch()253        var candidateKey: ByteArray? = null254        var plaintext: ByteArray? = null255        return try {256            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)257            // Parsed as JSON, not matched with a regex. The wrapper is258            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which259            // generates most real Codexes — writes each slot as a JSON260            // *string*, with "b" present. A regex looking for an object261            // literal under "a" at the very end of the wrapper matches262            // neither of those, so every browser-generated Codex failed here263            // with "invalid_codex_blob" after decoding perfectly.264            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject265                ?: return UnlockResult.Error("invalid_codex_blob")266            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")267            // A Codex printed from the browser carries a browser-shaped slot,268            // which is the common case — most Codexes are printed there. It is269            // translated to Android's shape (see BrowserSlot) rather than270            // rejected. A translation failure is a wrong password, not a bad271            // blob: the codes decoded fine, the password just didn't open them.272            val slotA = VaultCodec.decodeSlot(slotJson)273                ?: BrowserSlot.parse(slotJson)?.let {274                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword275                }276                ?: return UnlockResult.Error("invalid_codex_blob")277            // C-01: authenticate the candidate ENTIRELY in memory before touching278            // storage. VaultCodec.decodeSlot is structural — an Android-shaped279            // Codex parses under ANY password — so writing slot A first and280            // unlocking afterwards destroyed the device's existing vault every281            // time the user mistyped the password.282            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))283            plaintext = decryptSlotABlob(slotA, candidateKey)284            // LV-1: a Codex printed from a vault sealed before NFC normalization285            // opens only under the raw password bytes. unlockLocked used to supply286            // this fallback; authenticating up front here has to keep it, or those287            // Codexes become unrestorable.288            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {289                VaultCrypto.zero(candidateKey)290                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))291                plaintext = decryptSlotABlob(slotA, candidateKey)292            }293            if (plaintext == null) return UnlockResult.WrongPassword294            val payload = try {295                VaultCodec.decode(plaintext)296            } catch (_: Exception) {297                return UnlockResult.Error("invalid_codex_blob")298            }299300            val priorA = store.loadSlotA()301            val priorB = store.loadSlotB()302            val priorD = store.loadDecoySlot()303            // Carry the real duress slot across when the Codex has one — the304            // browser writes "b" as its duress-shaped slot, and dropping it305            // here would silently destroy a configured decoy.306            // H-04: write browser B into the duress slot (not the noise slot),307            // translating browser-shaped slots to Android's StoredVault shape.308            val codexDuressJson = wrapper["b"].asSlotJson()309            val codexDuress: StoredVault? = codexDuressJson?.let {310                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->311                    // H-04: pass-through — the browser duress slot is sealed312                    // under the duress passphrase, not the main password. Trying313                    // to decrypt with the slot-A password fails the GCM tag and314                    // silently drops a real decoy. Re-encode the browser hex315                    // fields as Android base64 salt/blob so tryUnlockDuress can316                    // still open it with the duress passphrase.317                    browserDuressPassthrough(b)318                }319            }320321            beforeDurableCommit()322            val committed = runCatching {323                withEpochCommit(expectedEpoch) {324                    try {325                        // Keep every slot write, including the inverse, under the326                        // same epoch fence. Releasing stateLock after a partial327                        // write and reacquiring it for rollback lets lock() advance328                        // the epoch in between and strand the incoming slot A.329                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")330                        // Noise slot is always size-matched to slot A — local-only,331                        // never written from a Codex.332                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")333                        // H-04: duress slot always present; real one from the Codex or334                        // a fake the size of slot A.335                        val duressOk = if (codexDuress != null) {336                            store.writeDecoySlot(codexDuress)337                        } else {338                            writeFakeDuressSlot(slotA)339                        }340                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")341                        store.stampLocalMutation()342                        true343                    } catch (_: Exception) {344                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))345                        // Keep the fence held through the final rollback346                        // observation. A concurrent lock() may queue here, but347                        // cannot invalidate the restored A/B/D triad.348                        afterCodexRollback()349                        false350                    }351                } ?: false352            }.getOrDefault(false)353            if (!committed) {354                return UnlockResult.Error("storage_write_failed")355            }356357            val ownedKey = candidateKey!!358            candidateKey = null // the session owns the key from here on359            adoptSlotA(ownedKey, payload, expectedEpoch)360        } catch (e: Exception) {361            // A raw kotlinx parse error ("Unexpected JSON token at offset N")362            // reaching the user says nothing about WHICH json failed — the363            // wrapper, or the payload decrypted out of it. Codex screens are364            // FLAG_SECURE so this can't be screenshotted either. Debug builds365            // log the structure around the failure; alphanumerics are masked so366            // no ciphertext or vault content lands in a log.367            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))368            UnlockResult.Error(e.message ?: "restore_failed")369        } finally {370            plaintext?.let(VaultCrypto::zero)371            candidateKey?.let(VaultCrypto::zero)372        }373    }374375    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */376    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =377        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()378379    /**380     * Undo a partially-committed Codex restore while [stateLock] is already held.381     * Every step is attempted independently so one failure cannot strand the382     * others. Callers that need epoch validation must enter through383     * [withEpochCommit] first; this helper deliberately does not reacquire the384     * lock, avoiding a reentrant gap between a failed write and its inverse.385     * With no prior vault the accurate inverse is having none at all, rather386     * than a half-written slot A that [hasVault] would report as a real vault.387     */388    private fun rollbackRestoreLocked(389        priorA: StoredVault?,390        priorB: String?,391        priorD: StoredVault? = null,392    ): List<String> {393        val failures = mutableListOf<String>()394        if (priorA == null) {395            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }396        } else {397            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }398                .onFailure { failures += "writeSlotA: $it" }399            if (priorB != null) {400                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }401                    .onFailure { failures += "writeSlotB: $it" }402            }403            // H-04: restore the prior duress slot — a half-committed restore that404            // left the duress record on the Codex side and slot A on the prior405            // side would put the at-rest pair in a state that never existed.406            if (priorD != null) {407                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }408                    .onFailure { failures += "writeDecoySlot: $it" }409            } else {410                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }411            }412        }413        return failures414    }415416    private fun reportRollbackFailures(failures: List<String>) {417        // Log is a throwing stub off-device, and a failed log must never mask418        // the rollback result the caller is about to report.419        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }420    }421422    /** Debug-only: structure of a failed Codex restore, with content masked. */423    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {424        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)425        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()426        val window = offset?.let {427            val from = (it - 40).coerceAtLeast(0)428            val to = (it + 40).coerceAtMost(wrapperJson.length)429            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")430        }431        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()432        val slot = wrapper?.get("a")433        buildString {434            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)435            append(" | blobBase64=").append(vaultBlobBase64.length)436            append(" wrapperChars=").append(wrapperJson.length)437            append(" wrapperParses=").append(wrapper != null)438            append(" keys=").append(wrapper?.keys)439            append(" slotAKind=").append(440                when {441                    slot == null -> "absent"442                    slot is JsonPrimitive && slot.isString -> "string"443                    slot is JsonObject -> "object:" + slot.keys444                    else -> slot::class.simpleName445                },446            )447            if (window != null) append(" masked@").append(offset).append("=").append(window)448        }449    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }450451    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {452        val expectedEpoch = currentEpoch()453        var key: ByteArray? = null454        return try {455            val salt = VaultCrypto.generateSalt()456            key = VaultCrypto.deriveKey(password, salt)457            if (mutateIfCurrentEpoch(expectedEpoch) {458                entryList = mutableListOf()459                duressConfigured = false460                recoveryWordHash = null461                recoveryWordsSet = false462                decoyMode = false463                sessionDataAvailable = false464                rootKey = generateRecoverableRootKey()465            } == null) return CreateVaultResult.Error("stale_create")466            // M-04: the fields above have to be live before sealSlotA, since it467            // seals currentPayload(). That stages a root key the session would468            // otherwise keep after a failed create — [lock] discards the whole469            // staged session on every non-success exit below.470            val slotA = sealSlotA(key!!, salt)471            // P1-3 (codex): every later step must roll the slot pair back if it472            // fails — leaving a half-built vault is worse than refusing to make it.473            beforeDurableCommit()474            val created = withEpochCommit(expectedEpoch) {475                if (!store.writeSlotA(slotA)) return@withEpochCommit false476                try {477                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")478                    // H-04: duress slot must be present from the start so its479                    // existence on disk is independent of duressConfigured.480                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")481                    store.stampLocalMutation()482                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")483                    true484                } catch (_: Exception) {485                    // Half-built vault on disk is worse than no vault — refuse to486                    // create if any step past slot A fails.487                    store.clearVault()488                    false489                }490            } ?: false491            if (!created) {492                lock()493                return CreateVaultResult.Error("storage_write_failed")494            }495            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")496            if (mutateIfCurrentEpoch(expectedEpoch) {497                derivedKey = sessionKey498                key = null499                publish()500                _isUnlocked.value = true501                sessionDataAvailable = true502                sessionEpoch = nextSessionEpoch(sessionEpoch)503            } == null) return CreateVaultResult.Error("stale_create")504            CreateVaultResult.Success505        } catch (e: Exception) {506            lock()507            CreateVaultResult.Error(e.message ?: "create_failed")508        } finally {509            key?.let(VaultCrypto::zero)510        }511    }512513    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {514        val expectedEpoch = currentEpoch()515        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")516        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")517        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {518            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")519        }520        var adoptedRootKey: ByteArray? = null521        var nextDerivedKey: ByteArray? = null522        var sessionRootKey: ByteArray? = null523        return try {524            adoptedRootKey = hexToRootKey(rootKeyHex)525            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {526                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")527            }528            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)529            val salt = VaultCrypto.generateSalt()530            nextDerivedKey = VaultCrypto.deriveKey(password, salt)531            sessionRootKey = adoptedRootKey.copyOf()532            val payload = VaultPayload(533                entries = emptyList(),534                duressConfigured = false,535                rootKeyB64 = b64(sessionRootKey),536                recoveryWordHash = null,537                recoveryWordsSet = false,538            )539            val plaintext = VaultCodec.encode(payload)540            val blob = try {541                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))542            } finally {543                VaultCrypto.zero(plaintext)544            }545            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)546            // P1-3 (codex): same partial-commit guard as createVault — any failure547            // past slot A wipes the half-built vault and refuses adoption.548            beforeDurableCommit()549            val created = withEpochCommit(expectedEpoch) {550                if (!store.writeSlotA(slotA)) return@withEpochCommit false551                try {552                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")553                    // H-04: duress slot must be present from the start so its554                    // existence on disk is independent of duressConfigured.555                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")556                    store.stampLocalMutation()557                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")558                    true559                } catch (_: Exception) {560                    store.clearVault()561                    false562                }563            } ?: false564            if (!created) return AdoptVaultResult.Error("storage_write_failed")565566            val adopted = mutateIfCurrentEpoch(expectedEpoch) {567                val previousDerivedKey = derivedKey568                val previousRootKey = rootKey569                derivedKey = nextDerivedKey570                rootKey = sessionRootKey571                nextDerivedKey = null572                sessionRootKey = null573                entryList = mutableListOf()574                duressConfigured = false575                recoveryWordHash = null576                recoveryWordsSet = false577                decoyMode = false578                sessionDataAvailable = true579                publish()580                _isUnlocked.value = true581                sessionEpoch = nextSessionEpoch(sessionEpoch)582                previousDerivedKey to previousRootKey583            }584            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")585            val (previousDerivedKey, previousRootKey) = adopted586            previousDerivedKey?.let(VaultCrypto::zero)587            previousRootKey?.let(VaultCrypto::zero)588            AdoptVaultResult.Success(vaultId)589        } catch (e: Exception) {590            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")591        } finally {592            adoptedRootKey?.let(VaultCrypto::zero)593            nextDerivedKey?.let(VaultCrypto::zero)594            sessionRootKey?.let(VaultCrypto::zero)595        }596    }597598    suspend fun unlock(password: String): UnlockResult = mutex.withLock {599        unlockLocked(password, currentEpoch())600    }601602    /** Unlock body without acquiring the mutex — callers must already hold it. */603    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {604        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")605        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))606        val payload = try {607            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)608            try {609                VaultCodec.decode(plaintext)610            } finally {611                VaultCrypto.zero(plaintext)612            }613        } catch (_: Exception) {614            VaultCrypto.zero(key)615            // LV-1: a vault sealed before NFC normalization with a non-NFC616            // password derives a different key now. If the supplied password is617            // not already in NFC form, retry with the raw (un-normalized) bytes.618            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {619                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)620                if (legacy != null) return legacy621            }622            return UnlockResult.WrongPassword623        }624        return adoptSlotA(key, payload, expectedEpoch)625    }626627    /**628     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw629     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed630     * under the NFC-normalized key — so every future unlock takes the normal631     * path. Returns null when the raw key also fails (genuinely wrong password)632     * OR when any step of the heal commits fails (the original slot A is restored633     * so disk and session stay aligned).634     */635    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {636        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))637        var newKey: ByteArray? = null638        return try {639            val payload = try {640                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)641                try {642                    VaultCodec.decode(plaintext)643                } finally {644                    VaultCrypto.zero(plaintext)645                }646            } catch (_: Exception) {647                return null648            }649            // Heal: re-seal slot A under the NFC key (mirrors changePassword's650            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.651            val newSalt = VaultCrypto.generateSalt()652            newKey = VaultCrypto.deriveKey(password, newSalt)653            val plaintext = VaultCodec.encode(payload)654            val blob = try {655                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))656            } finally {657                VaultCrypto.zero(plaintext)658            }659            val next = StoredVault(660                version = slotA.version,661                salt = b64(newSalt),662                blob = blob,663                recoveryEscrow = slotA.recoveryEscrow,664                rootKeyEscrow = slotA.rootKeyEscrow,665            )666            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,667            // or stampLocalMutation must restore the prior slot A AND slot B so disk668            // and the eventual session key never disagree on the wrapping.669            val priorB = store.loadSlotB()670            beforeDurableCommit()671            val healed = withEpochCommit(expectedEpoch) {672                if (!store.writeSlotA(next)) {673                    false674                } else try {675                    if (!writeMatchingNoiseSlotB(next)) {676                        runCatching { store.writeSlotA(slotA) }677                        if (priorB != null) runCatching { store.writeSlotB(priorB) }678                        false679                    } else if (!writeCompanionDuressSlot(next)) {680                        runCatching { store.writeSlotA(slotA) }681                        if (priorB != null) runCatching { store.writeSlotB(priorB) }682                        false683                    } else {684                        store.stampLocalMutation()685                        true686                    }687                } catch (_: Exception) {688                    runCatching { store.writeSlotA(slotA) }689                    if (priorB != null) runCatching { store.writeSlotB(priorB) }690                    false691                }692            } ?: false693            if (healed) {694                VaultCrypto.zero(rawKey!!)695                rawKey = null696                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot697                newKey = null698                result699            } else {700                VaultCrypto.zero(newKey!!)701                newKey = null702                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key703                rawKey = null704                result705            }706        } finally {707            // Cover codec, encryption, storage, and stale-epoch failures before708            // either candidate key can be transferred into the live session.709            rawKey?.let(VaultCrypto::zero)710            newKey?.let(VaultCrypto::zero)711        }712    }713714    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */715    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {716        return try {717            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they718            // can throw after the fields above are live. There is no prior session719            // worth restoring — adoption has already replaced it — so a failed720            // adoption discards everything rather than leaving entries and keys721            // resident behind _isUnlocked=false.722            val adopted = mutateIfCurrentEpoch(expectedEpoch) {723                derivedKey = key724                decoyMode = false725                entryList = payload.entries.toMutableList()726                duressConfigured = payload.duressConfigured727                recoveryWordHash = payload.recoveryWordHash728                recoveryWordsSet = payload.recoveryWordsSet729                sessionDataAvailable = true730                _settings.value = store.loadSettings()731                ensureRootKey(payload)732                autoPurgeExpiredTrash()733                publish()734                _isUnlocked.value = true735                // A successful adoption starts a new generation. A sibling736                // SlotAUnlock captured in the prior epoch will fail closed at737                // the next commit instead of replacing this session.738                sessionEpoch = nextSessionEpoch(sessionEpoch)739            }740            if (adopted == null) {741                VaultCrypto.zero(key)742                UnlockResult.Error("stale_unlock")743            } else {744                UnlockResult.Success745            }746        } catch (e: Exception) {747            // Do not call lock() here: a concurrent newer session may already748            // own this repository. Only invalidate the failed epoch's fields.749            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }750            VaultCrypto.zero(key)751            UnlockResult.Error(e.message ?: "unlock_failed")752        }753    }754755    // ---- Constant-time unlock (MF-5) --------------------------------------756757    /**758     * A slot-A unlock that has been derived but not yet adopted. Holds the live759     * Argon2id key and decoded payload so the caller can derive several candidates760     * concurrently and commit only the winner. The key is owned by this token until761     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.762     */763    class SlotAUnlock internal constructor(764        key: ByteArray,765        internal val payload: VaultPayload,766        internal val epoch: Long,767    ) {768        private var ownedKey: ByteArray? = key769        private var consumed = false770771        /** Transfer the key exactly once to the adopter. */772        internal fun takeKey(): ByteArray? = synchronized(this) {773            if (consumed) return@synchronized null774            consumed = true775            ownedKey.also { ownedKey = null }776        }777778        /** Discard an uncommitted token without touching a transferred key. */779        internal fun discard() = synchronized(this) {780            if (consumed) return@synchronized781            consumed = true782            ownedKey?.let(VaultCrypto::zero)783            ownedKey = null784        }785    }786787    /**788     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.789     * Pure computation (no mutex), so it can run concurrently with other candidate790     * derivations before the caller decides which to commit (MF-5). Returns null on791     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).792     */793    fun tryUnlockSlotA(password: String): SlotAUnlock? {794        val epoch = currentEpoch()795        val slotA = store.loadSlotA() ?: return null796        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))797        return try {798            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)799            val payload = try {800                VaultCodec.decode(plaintext)801            } finally {802                VaultCrypto.zero(plaintext)803            }804            SlotAUnlock(key, payload, epoch)805        } catch (_: Exception) {806            VaultCrypto.zero(key)807            null808        }809    }810811    /** Adopt a previously-derived [SlotAUnlock] as the live session. */812    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {813        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")814        adoptSlotA(key, unlock.payload, unlock.epoch)815    }816817    /** Zero a derived [SlotAUnlock] that will not be adopted. */818    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()819820    // ---- Duress slot B (MF-6) ---------------------------------------------821822    /**823     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key824     * and decoded decoy payload so it can be derived concurrently with slot A and825     * only committed if the duress passphrase was the one entered (MF-5/MF-6).826     */827    class DuressUnlock internal constructor(828        key: ByteArray,829        internal val payload: VaultPayload,830        internal val epoch: Long,831    ) {832        private var ownedKey: ByteArray? = key833        private var consumed = false834835        internal fun takeKey(): ByteArray? = synchronized(this) {836            if (consumed) return@synchronized null837            consumed = true838            ownedKey.also { ownedKey = null }839        }840841        internal fun discard() = synchronized(this) {842            if (consumed) return@synchronized843            consumed = true844            ownedKey?.let(VaultCrypto::zero)845            ownedKey = null846        }847    }848849    /**850     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the851     * session. Success means the entered password is the duress passphrase that keys852     * slot B — indistinguishable from the real slot A at the lock screen. Pure853     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].854     * Returns null when no duress slot exists or the password is not the duress one.855     */856    fun tryUnlockDuress(password: String): DuressUnlock? {857        val epoch = currentEpoch()858        val slot = store.loadDecoySlot() ?: return null859        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))860        return try {861            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)862            val payload = try {863                VaultCodec.decode(plaintext)864            } finally {865                VaultCrypto.zero(plaintext)866            }867            DuressUnlock(key, payload, epoch)868        } catch (_: Exception) {869            VaultCrypto.zero(key)870            null871        }872    }873874    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */875    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {876        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")877        if (currentEpoch() != unlock.epoch) {878            VaultCrypto.zero(key)879            return@withLock UnlockResult.Error("stale_unlock")880        }881        return try {882            val adopted = mutateIfCurrentEpoch(unlock.epoch) {883                derivedKey = key884                decoyMode = true885                entryList = unlock.payload.entries.toMutableList()886                duressConfigured = false887                recoveryWordHash = unlock.payload.recoveryWordHash888                recoveryWordsSet = unlock.payload.recoveryWordsSet889                sessionDataAvailable = true890                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)891                          else VaultCrypto.generateRootKey()892                _settings.value = store.loadSettings()893                publish()894                _isUnlocked.value = true895                sessionEpoch = nextSessionEpoch(sessionEpoch)896            }897            if (adopted == null) {898                VaultCrypto.zero(key)899                UnlockResult.Error("stale_unlock")900            } else {901                UnlockResult.Success902            }903        } catch (e: Exception) {904            // A payload/root/settings failure can occur after the candidate key905            // has been assigned to derivedKey. Tear down only this epoch so a906            // newer session is not disturbed, and scrub the token reference on907            // every error path.908            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }909            VaultCrypto.zero(key)910            UnlockResult.Error(e.message ?: "decoy_unlock_failed")911        }912    }913914    /** Zero a derived [DuressUnlock] that will not be adopted. */915    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()916917    /**918     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an919     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The920     * passphrase is never stored — only its ability to decrypt slot B proves it.921     * Requires the real vault to be unlocked and not itself a decoy session.922     */923    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {924        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().925        val expectedEpoch = mutateState {926            if (derivedKey != null && !decoyMode) sessionEpoch else null927        } ?: return false928        // Reject a duress passphrase that also opens the real vault: it would make the929        // lock screen ambiguous (both slots would decrypt for the same input).930        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }931        var key: ByteArray? = null932        var basePlaintext: ByteArray? = null933        return try {934            val salt = VaultCrypto.generateSalt()935            key = VaultCrypto.deriveKey(duressPassphrase, salt)936            // An empty decoy vault with its own fresh sync root key — nothing real leaks.937            val decoyRoot = VaultCrypto.generateRootKey()938            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }939            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)940            basePlaintext = VaultCodec.encode(decoyPayload)941            // H-04: pad the duress plaintext to slot A's ciphertext length so the942            // duress blob matches slot A's blob on disk. VaultCodec.decode strips943            // trailing NULs, so the extra padding survives encrypt but never944            // reaches the JSON parser.945            // ponytail: only re-pads at configuration time — if slot A grows past946            // its initial bucket (entries cross 4096/16384/etc.), the real decoy947            // stays at the smaller bucket. Re-padding on every persist requires948            // keeping the duress passphrase in memory, which we don't.949            val slotA = store.loadSlotA()950            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0951            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!952            val blob = try {953                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))954            } finally {955                VaultCrypto.zero(plaintext)956                // Padding creates a second array; scrub the original encoded957                // payload too rather than dropping its last reference untouched.958                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)959                basePlaintext = null960                key?.let(VaultCrypto::zero)961                key = null962            }963            beforeDurableCommit()964            val ok = withEpochCommit(expectedEpoch) {965                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))966            } ?: false967            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {968                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }969            } else if (ok) {970                return false971            }972            ok973        } catch (_: Exception) {974            false975        } finally {976            // Cover failures before the encrypt block is entered (for example a977            // codec/copy exception) as well as any early return after key derivation.978            key?.let(VaultCrypto::zero)979            basePlaintext?.let(VaultCrypto::zero)980        }981    }982983984    /**985     * Export the decoy slot as browser-shaped JSON for adoption snapshots.986     *987     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all988     * lowercase hex — identical to the browser's `duress_slot` wire format — so989     * any receiver (browser or Android) can install it via the existing990     * passthrough. Returns null when duress is not configured, no decoy exists,991     * or any conversion failure occurs. Never decrypts.992     */993    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {994        if (!duressConfigured) return null995        val slot = store.loadDecoySlot() ?: return null996        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()997    }998999    /**1000     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1001     *1002     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1003     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1004     * byte arrays are zeroed in finally.1005     */1006    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1007        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1008        // serialized — kotlinx-serialization's encodeDefaults = false would1009        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1010        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1011        // silently dropping the duress slot on Android→browser adoptions.1012        var salt: ByteArray? = null1013        var blob: ByteArray? = null1014        var nonce: ByteArray? = null1015        var ctAndTag: ByteArray? = null1016        var ct: ByteArray? = null1017        var tag: ByteArray? = null1018        try {1019            salt = unb64(slot.salt)1020            blob = unb64(slot.blob)1021            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1022            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1023            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1024            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1025            val obj = JsonObject(mapOf(1026                "version" to JsonPrimitive(1),1027                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1028                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1029                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1030                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1031            ))1032            return obj.toString()1033        } finally {1034            salt?.let(VaultCrypto::zero)1035            blob?.let(VaultCrypto::zero)1036            nonce?.let(VaultCrypto::zero)1037            ctAndTag?.let(VaultCrypto::zero)1038            ct?.let(VaultCrypto::zero)1039            tag?.let(VaultCrypto::zero)1040        }1041    }10421043    /**1044     * Install a browser-shaped duress slot received through adoption.1045     *1046     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1047     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1048     * writes the result to the decoy slot. Epoch-fenced like the Codex1049     * restore path so a concurrent lock() cannot strand the write.1050     *1051     * Returns false on invalid input, no live session, or write failure.1052     */1053    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1054        val b = BrowserSlot.parse(json) ?: return false1055        var stored: StoredVault? = null1056        var salt: ByteArray? = null1057        var nonce: ByteArray? = null1058        var ct: ByteArray? = null1059        var tag: ByteArray? = null1060        var blob: ByteArray? = null1061        try {1062            salt = unhex(b.salt)1063            nonce = unhex(b.nonce)1064            ct = unhex(b.ciphertext)1065            tag = unhex(b.tag)1066            blob = ByteArray(nonce.size + ct.size + tag.size).also {1067                System.arraycopy(nonce, 0, it, 0, nonce.size)1068                System.arraycopy(ct, 0, it, nonce.size, ct.size)1069                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1070            }1071            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1072            val expectedEpoch = sessionEpoch1073            beforeDurableCommit()1074            withEpochCommit(expectedEpoch) {1075                store.writeDecoySlot(stored)1076            } ?: false1077        } catch (_: Exception) {1078            false1079        } finally {1080            salt?.let(VaultCrypto::zero)1081            nonce?.let(VaultCrypto::zero)1082            ct?.let(VaultCrypto::zero)1083            tag?.let(VaultCrypto::zero)1084            blob?.let(VaultCrypto::zero)1085        }1086    }10871088    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1089    suspend fun clearDuress(): Boolean = mutex.withLock {1090        // H-04: presence-indistinguishable — replace the real decoy with a fake1091        // blob of slot A's size rather than removing the duress record. The1092        // record stays on disk at the same length, so an at-rest observer1093        // cannot tell "duress cleared" from "no duress ever configured".1094        val expectedEpoch = currentEpoch()1095        val replaced = withEpochCommit(expectedEpoch) {1096            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1097                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1098        } ?: false1099        if (!replaced) return@withLock false1100        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1101        // duressConfigured write so a concurrent lock() cannot land between them.1102        if (!isSessionEpochCurrent(expectedEpoch)) return false1103        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1104        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1105            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1106        }1107        true1108    }11091110    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1111    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1112        var current: Throwable? = error1113        while (current != null) {1114            if (current is javax.crypto.AEADBadTagException) return true1115            // Some Android providers expose a GCM tag failure as the parent1116            // BadPaddingException rather than the AEAD-specific subtype.  A1117            // short ciphertext is an IllegalBlockSizeException and therefore1118            // remains an internal/malformed-payload Error below.1119            if (current is javax.crypto.BadPaddingException) return true1120            current = current.cause1121        }1122        return false1123    }11241125    /** Unlock with an already-derived AES-256 key (biometric path). */1126    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1127        val expectedEpoch = currentEpoch()1128        val slotA = try {1129            store.loadSlotA()1130        } catch (_: Exception) {1131            return@withLock UnlockResult.Error("unlock_key_storage_failed")1132        } ?: return@withLock UnlockResult.Error("no_vault")1133        // Keep the caller-owned key separate from the staged session key. The1134        // biometric caller always zeroes its input in a finally block; adoption1135        // must therefore own a copy and use the same all-or-nothing path as a1136        // password unlock rather than publishing a partially decoded session.1137        var stagedKey: ByteArray? = key.copyOf()1138        return try {1139            val encrypted = try {1140                unb64(slotA.blob)1141            } catch (_: Exception) {1142                return@withLock UnlockResult.Error("invalid_vault_blob")1143            }1144            // A GCM record must include nonce + authentication tag.  Providers1145            // differ on whether a shorter record is reported as BadPadding or1146            // AEADBadTag; reject it before decrypt so malformed storage cannot be1147            // misreported as a user-entered wrong key.1148            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1149                return@withLock UnlockResult.Error("invalid_vault_blob")1150            }1151            val plaintext = try {1152                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1153            } catch (e: Exception) {1154                if (isGcmAuthenticationFailure(e)) {1155                    return@withLock UnlockResult.WrongPassword1156                }1157                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1158            }1159            val payload = try {1160                VaultCodec.decode(plaintext)1161            } catch (_: Exception) {1162                return@withLock UnlockResult.Error("invalid_vault_payload")1163            } finally {1164                VaultCrypto.zero(plaintext)1165            }1166            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1167            val result = adoptSlotA(candidate, payload, expectedEpoch)1168            if (result is UnlockResult.Success) stagedKey = null1169            result1170        } finally {1171            stagedKey?.let(VaultCrypto::zero)1172        }1173    }11741175    fun lock() {1176        // H-03: must observe derivedKey/entryList atomically with any in-flight1177        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1178        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1179        // guards just the brief state reset. Advancing the epoch first invalidates1180        // every operation that captured this session before it reaches a later1181        // durable commit or publication.1182        stateLock.lock()1183        try {1184            sessionEpoch = nextSessionEpoch(sessionEpoch)1185            clearSessionStateLocked()1186        } finally {1187            stateLock.unlock()1188        }1189    }11901191    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11921193    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1194    // is a stub off-device).1195    @VisibleForTesting1196    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11971198    /**1199     * Call immediately before launching a system Activity (a SAF file1200     * picker/save dialog, a share sheet) that briefly takes the whole process1201     * out of the foreground as an expected part of an in-app flow — not a1202     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1203     * two apart on its own (it fires for both), and without this, exporting or1204     * importing a file silently locked and wiped the vault mid-flow the moment1205     * the picker took over, before its result ever reached the app.1206     *1207     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1208     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1209     */1210    fun expectTransientBackground() {1211        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1212    }12131214    /**1215     * Consumed once by the next background-lock check; see [expectTransientBackground].1216     * Returns false (and clears the flag) if the suppression has expired or was1217     * never set — a genuine background event must always lock.1218     */1219    fun consumeTransientBackgroundSuppression(): Boolean {1220        val until = suppressNextBackgroundLockUntil1221        val active = until != 0L && nowMs() < until1222        suppressNextBackgroundLockUntil = 0L1223        return active1224    }12251226    // ---- Sync identity ----------------------------------------------------12271228    /**1229     * The vault's stable sync master key (a copy). Caller must zero it. Backs1230     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1231     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1232     */1233    /**1234     * HOLE 5 — the duress vault never syncs.1235     *1236     * [commitDuressUnlock] gives the decoy session a root key so its own codex1237     * and recovery paths work, which means (unlike the browser, where the decoy1238     * path simply has no root key) the sync surface has to refuse explicitly.1239     * Without this the decoy vault would present a second, different vault_id1240     * to a paired device, and a coerced user opening it would make sync visibly1241     * fail on their other devices — a duress indicator, which is the one thing1242     * a duress vault must never be.1243     */1244    // M-04: the "null when locked" half of the contract was documented but never1245    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1246    // [unlockWithRecoveryWords], which installs a root key with no password key1247    // yet) and in a create that failed after staging. Sync must fail closed1248    // there, so the authoritative unlocked flag gates it, not just decoyMode.1249    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12501251    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1252        if (decoyMode || !_isUnlocked.value) null1253        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1254    }12551256    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1257        mutateState { sessionEpoch == epoch }12581259    internal fun unlockedSessionEpoch(): Long? = mutateState {1260        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1261    }12621263    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12641265    /**1266     * The current session's root key, decoy included. For local-only uses that1267     * must behave identically in a decoy session — Codex backup and recovery1268     * words — because a feature that errors under duress is itself a duress1269     * tell. In a decoy session this is the DECOY's root key (see1270     * [commitDuressUnlock]), never the real vault's, so neither surface can1271     * leak the real vault. Anything that leaves the device must use1272     * [syncMasterKey] instead.1273     */1274    fun sessionRootKey(): ByteArray? = mutateState {1275        if (!sessionDataAvailable) null else rootKey?.copyOf()1276    }12771278    /** Stable per-vault id two paired devices must share to sync. Null when1279     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1280    fun syncVaultId(): String? = mutateState {1281        if (decoyMode || !_isUnlocked.value) null1282        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1283    }12841285    /**1286     * Browser parity (MV-8): rotate the sync identity by replacing the stable1287     * root key. Entries remain under the master-password key, while sync1288     * credentials, vault_id, and recovery words all change.1289     */1290    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1291        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1292            ?: return false1293        installRootKey(generateRecoverableRootKey(), expectedEpoch)1294    }12951296    /**1297     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1298     * keeping every local entry.1299     *1300     * Sync identity (vault id, signing keys) derives from the root key, and1301     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1302     * created vaults can never reconcile. Taking the other side's root key is1303     * what makes them one vault; from the next sync on, both entry sets merge.1304     *1305     * Entries survive because they are sealed under the password-derived key,1306     * not the root key — the same property [rotateSyncIdentity] relies on.1307     *1308     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1309     * this device's previous recovery words stop working for good, and its new1310     * words are the source vault's words, because words are a deterministic1311     * encoding of the root key.1312     */1313    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1314        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1315            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1316        }1317        val expectedEpoch = mutateState {1318            when {1319                derivedKey == null -> null1320                decoyMode -> Long.MIN_VALUE1321                else -> sessionEpoch1322            }1323        }1324        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1325        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1326        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13271328        val incoming = hexToRootKey(rootKeyHex)1329        if (incoming.size != VaultCrypto.KEY_SIZE) {1330            VaultCrypto.zero(incoming)1331            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1332        }1333        // Refuse before committing rather than leave this device holding a vault1334        // whose recovery words cannot be typed back. generateRecoverableRootKey1335        // screens its own keys for this; a key arriving from a peer has not been.1336        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1337        if (!roundTrips) {1338            VaultCrypto.zero(incoming)1339            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1340        }13411342        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1343        return if (installRootKey(incoming, expectedEpoch)) {1344            AdoptVaultResult.Success(vaultId)1345        } else {1346            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1347        }1348    }13491350    /**1351     * The recovery words [rootKeyHex] will produce once joined, or null if it is1352     * not a usable root key. Lets the join flow show the user the words that are1353     * about to replace theirs *before* anything is committed.1354     */1355    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1356        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1357        val bytes = hexToRootKey(rootKeyHex)1358        return try {1359            val words = rootKeyToWords(bytes)1360            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1361        } catch (_: Exception) {1362            null1363        } finally {1364            VaultCrypto.zero(bytes)1365        }1366    }13671368    /**1369     * Swap the vault's root key, keeping every entry, and re-escrow under the1370     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1371     * [mutex].1372     */1373    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1374        // H-03: snapshot the live fields under stateLock so the success and the1375        // rollback paths both see consistent prior/new state. Writing the new1376        // rootKey/recovery fields is inside its own mutateState at the end.1377        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1378        // — holding live references would let a zero-on-failure operation also1379        // wipe the session's still-live key arrays.1380        data class Snapshot(1381            val key: ByteArray,1382            val priorRootKey: ByteArray,1383            val priorRecoveryHash: String?,1384            val priorRecoverySet: Boolean,1385        )1386        val snap = mutateIfCurrentEpoch(expectedEpoch) {1387            val k = derivedKey ?: return@mutateIfCurrentEpoch null1388            if (decoyMode) return@mutateIfCurrentEpoch null1389            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1390            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1391        } ?: run {1392            VaultCrypto.zero(nextRootKey)1393            return false1394        }1395        val priorSlot = store.loadSlotA() ?: run {1396            VaultCrypto.zero(snap.key)1397            VaultCrypto.zero(snap.priorRootKey)1398            VaultCrypto.zero(nextRootKey)1399            return false1400        }1401        val nextWords = rootKeyToWords(nextRootKey)14021403        return try {1404            beforeDurableCommit()1405            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1406            if (mutateIfCurrentEpoch(expectedEpoch) {1407                rootKey = nextRootKey1408                recoveryWordHash = recoveryWordsHash(nextWords)1409                recoveryWordsSet = false1410            } == null) throw IllegalStateException("stale_root_rotation")14111412            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1413                ?: throw IllegalStateException("recovery_escrow_failed")1414            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1415                ?: throw IllegalStateException("root_escrow_failed")1416            val plaintext = VaultCodec.encode(currentPayload())1417            val blob = try {1418                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1419            } finally {1420                VaultCrypto.zero(plaintext)1421            }1422            val nextSlot = priorSlot.copy(1423                blob = blob,1424                recoveryEscrow = nextRecoveryEscrow,1425                rootKeyEscrow = nextRootKeyEscrow,1426            )1427            val committed = withEpochCommit(expectedEpoch) {1428                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1429                try {1430                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1431                    // H-04: a root-key rotation re-seals slot A, so the duress slot1432                    // (real or fake) must be re-padded to the new ciphertext length.1433                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1434                    store.stampLocalMutation()1435                } catch (_: Exception) {1436                    store.writeSlotA(priorSlot)1437                    return@withEpochCommit false1438                }1439                true1440            } ?: false1441            if (!committed) throw IllegalStateException("storage_write_failed")1442            mutateIfCurrentEpoch(expectedEpoch) { publish() }1443            true1444        } catch (_: Exception) {1445            val restored = mutateIfCurrentEpoch(expectedEpoch) {1446                rootKey?.let(VaultCrypto::zero)1447                rootKey = snap.priorRootKey.copyOf()1448                recoveryWordHash = snap.priorRecoveryHash1449                recoveryWordsSet = snap.priorRecoverySet1450            }1451            if (restored != null) {1452                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1453            }1454            false1455        } finally {1456            VaultCrypto.zero(snap.key)1457            VaultCrypto.zero(snap.priorRootKey)1458            // On success nextRootKey is owned by rootKey. On every other path1459            // it is not, including a stale epoch after lock(); scrub it while1460            // the local reference is still available.1461            val stillOwnedBySession = mutateState {1462                sessionEpoch == expectedEpoch && rootKey === nextRootKey1463            }1464            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1465        }1466    }14671468    /**1469     * A copy of the live Argon2id-derived vault key, for biometric escrow only1470     * (wrapped under a hardware KeyStore key on biometric enable). Null when1471     * locked. Caller must [VaultCrypto.zero] it after use.1472     */1473    fun currentVaultKey(): ByteArray? = mutateState {1474        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1475    }14761477    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1478        val expectedEpoch = currentEpoch()1479        val slotA = store.loadSlotA() ?: return false1480        var oldKey: ByteArray? = null1481        var newKey: ByteArray? = null1482        var plaintext: ByteArray? = null1483        return try {1484            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1485            // Keep ownership of the decrypted vault plaintext until decoding has1486            // finished.  Passing decrypt() inline made the byte array unreachable1487            // before the finally block, so a successful or failed password change1488            // could leave the full plaintext resident in the heap.1489            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1490            val payload = try {1491                VaultCodec.decode(decrypted)1492            } finally {1493                VaultCrypto.zero(decrypted)1494            }1495            val newSalt = VaultCrypto.generateSalt()1496            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1497            // Re-seal the existing payload under the new key/salt.1498            plaintext = VaultCodec.encode(payload)1499            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1500            // The escrow seals the slot-A PLAINTEXT, which a password change does not1501            // alter — only the wrapping changes — so carry it forward; rebuilding the1502            // StoredVault from scratch would silently drop it.1503            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1504            // content that a password change does not alter — only the slot-A1505            // wrapping rotates. The root-key escrow staying stable across password1506            // changes matches the browser (`vault.ts:363-364`).1507            val next = StoredVault(1508                version = slotA.version,1509                salt = b64(newSalt),1510                blob = blob,1511                recoveryEscrow = slotA.recoveryEscrow,1512                rootKeyEscrow = slotA.rootKeyEscrow,1513            )1514            // H-01: durably commit slot A + size-matched noise + version stamp1515            // before swapping the session key. A partial commit (new A on disk,1516            // old key in memory) would have the next save encrypt under the old1517            // key while matching disk on the new one.1518            val priorB = store.loadSlotB()1519            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1520            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1521            // inside the same stateLock critical section, so a concurrent lock()1522            // can't observe the in-between (zeroed but not yet reassigned).1523            if (mutateIfCurrentEpoch(expectedEpoch) {1524                oldKey?.let(VaultCrypto::zero); oldKey = null1525                derivedKey?.let(VaultCrypto::zero)1526                derivedKey = newKey; newKey = null1527            } == null) return false1528            true1529        } catch (_: Exception) {1530            false1531        } finally {1532            oldKey?.let(VaultCrypto::zero)1533            newKey?.let(VaultCrypto::zero)1534            plaintext?.let(VaultCrypto::zero)1535        }1536    }15371538    /**1539     * Irreversible wipe. Locks the session and clears every vault key. The Pro1540     * license is in a separate store and is preserved by construction.1541     */1542    suspend fun destroyVault() = mutex.withLock {1543        lock()1544        store.clearVault()1545        store.clearDecoySlot()1546    }15471548    // ---- Entry CRUD -------------------------------------------------------15491550    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1551        val epoch = requireUnlocked()1552        val stamp = VaultCrypto.monotonicEntryTimestamp()1553        val now = System.currentTimeMillis()1554        val entry = withDerivedLast4(draft.copy(1555            id = draft.id.ifBlank { UUID.randomUUID().toString() },1556            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1557            modifiedAt = stamp,1558            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1559        ))1560        // H-03: capture rollback AND write entryList under stateLock so a1561        // concurrent lock() cannot zero the session between the read and the write.1562        val rollback = mutateCurrentSession(epoch) {1563            val r = entryList.toMutableList()1564            entryList.add(entry)1565            r1566        } ?: throw IllegalStateException("vault_locked")1567        persistOrRollback(rollback, epoch)1568        entry1569    }15701571    /**1572     * Insert many entries with a single encrypt-and-write at the end.1573     *1574     * Importing a real vault through [addEntry] was quadratic: every entry took1575     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1576     * restore did 806 full-vault writes and hung the app long enough to ANR.1577     * Rolls back to the pre-import list if the one write fails, matching1578     * [addEntry]'s all-or-nothing behaviour.1579     */1580    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1581        val epoch = requireUnlocked()1582        if (drafts.isEmpty()) return 01583        val now = System.currentTimeMillis()1584        val ready = drafts.map { draft ->1585            val stamp = VaultCrypto.monotonicEntryTimestamp()1586            withDerivedLast4(1587                draft.copy(1588                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1589                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1590                    modifiedAt = stamp,1591                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1592                ),1593            )1594        }1595        // H-03: capture rollback AND append every entry under stateLock.1596        val rollback = mutateCurrentSession(epoch) {1597            val r = entryList.toMutableList()1598            entryList.addAll(ready)1599            r1600        } ?: throw IllegalStateException("vault_locked")1601        persistOrRollback(rollback, epoch)1602        return drafts.size1603    }16041605    /**1606     * M-01: replace the whole active set with [newEntries] in one write.1607     *1608     * Import's REPLACE mode used to soft-delete every current entry one durable1609     * write at a time (swallowing each failure) and then batch-add, so a failed1610     * add left the old entries stranded in Trash with nothing restored. Staging1611     * the entire next list in memory and committing it through1612     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1613     * pre-import list — active entries included — is restored.1614     *1615     * Existing tombstones are preserved so paired devices still see the1616     * deletions they already know about.1617     */1618    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1619        val epoch = requireUnlocked()1620        val now = System.currentTimeMillis()1621        val ready = newEntries.map { draft ->1622            val stamp = VaultCrypto.monotonicEntryTimestamp()1623            withDerivedLast4(1624                draft.copy(1625                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1626                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1627                    modifiedAt = stamp,1628                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1629                ),1630            )1631        }1632        // M-03: an incoming entry that shares an id with an existing live entry1633        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1634        // the old one and then appending the new one would leave the list with1635        // two entries of the same id — the tombstoned one would be returned by1636        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1637        // superseded entries from the tombstone set instead.1638        val incomingIds = ready.map { it.id }.toHashSet()1639        val rollback = mutateCurrentSession(epoch) {1640            val r = entryList.toMutableList()1641            val replaced = entryList.map { entry ->1642                if (entry.deletedAt != null) return@map entry1643                if (entry.id in incomingIds) return@map null1644                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1645                entry.copy(deletedAt = stamp, modifiedAt = stamp)1646            }.filterNotNull()1647            entryList = (replaced + ready).toMutableList()1648            r1649        } ?: throw IllegalStateException("vault_locked")1650        persistOrRollback(rollback, epoch)1651        return ready.size1652    }16531654    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1655        val epoch = requireUnlocked()1656        // H-03: read entryList, compute the patched entry, and write it under a1657        // single stateLock so a concurrent lock() cannot interleave between the1658        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1659        // list, or clobber the entry onto the wrong id after a relock).1660        val prepared = mutateCurrentSession(epoch) {1661            val idx = entryList.indexOfFirst { it.id == patch.id }1662            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1663            val current = entryList[idx]1664            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1665            // Only refresh passwordChangedAt when the password value actually changes.1666            val passwordChanged = patch.password != null && patch.password != current.password1667            // Capture the outgoing password (newest first) so the user can reveal/copy/1668            // restore it later. History is computed authoritatively from `current`, not1669            // the patch — updateEntry is a full-entry replace, so trusting patch's value1670            // would let a stale form clobber the real history. Record the age the old1671            // password carried, skip an empty prior, and skip an immediate exact dupe1672            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1673            val priorPassword = current.password1674            val shouldCapture = passwordChanged &&1675                !priorPassword.isNullOrEmpty() &&1676                current.passwordHistory?.firstOrNull()?.password != priorPassword1677            val nextHistory = if (shouldCapture) {1678                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1679                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1680            } else {1681                current.passwordHistory1682            }1683            val updated = withDerivedLast4(patch.copy(1684                createdAt = current.createdAt,1685                modifiedAt = stamp,1686                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1687                passwordHistory = nextHistory,1688            ))1689            val rollback = entryList.toMutableList()1690            entryList[idx] = updated1691            updated to rollback1692        } ?: throw IllegalStateException("vault_locked")1693        val (updated, rollback) = prepared1694        persistOrRollback(rollback, epoch)1695        updated1696    }16971698    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1699    suspend fun deleteEntry(id: String) = mutex.withLock {1700        val epoch = requireUnlocked()1701        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1702        // the list out from under the indexOfFirst.1703        val rollback = mutateCurrentSession(epoch) {1704            val idx = entryList.indexOfFirst { it.id == id }1705            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1706            val current = entryList[idx]1707            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1708            val prior = entryList.toMutableList()1709            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1710            prior1711        } ?: throw IllegalStateException("vault_locked")1712        persistOrRollback(rollback, epoch)1713    }17141715    suspend fun restoreEntry(id: String) = mutex.withLock {1716        val epoch = requireUnlocked()1717        val rollback = mutateCurrentSession(epoch) {1718            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1719            if (idx == -1) throw IllegalStateException("deleted entry not found")1720            val current = entryList[idx]1721            val prior = entryList.toMutableList()1722            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1723            prior1724        } ?: throw IllegalStateException("vault_locked")1725        persistOrRollback(rollback, epoch)1726    }17271728    /** Permanent delete — no tombstone, no recovery. */1729    suspend fun purgeEntry(id: String) = mutex.withLock {1730        val epoch = requireUnlocked()1731        val rollback = mutateCurrentSession(epoch) {1732            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1733            if (idx == -1) throw IllegalStateException("deleted entry not found")1734            val prior = entryList.toMutableList()1735            entryList.removeAt(idx)1736            prior1737        } ?: throw IllegalStateException("vault_locked")1738        persistOrRollback(rollback, epoch)1739    }17401741    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1742    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1743        val epoch = requireUnlocked()1744        // H-03: read+remove under stateLock so lock() can't reorder the remove.1745        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1746        val r = mutateCurrentSession(epoch) {1747            val rollback = entryList.toMutableList()1748            val before = entryList.size1749            entryList.removeAll { it.deletedAt != null }1750            R(rollback, before - entryList.size)1751        } ?: throw IllegalStateException("vault_locked")1752        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1753        r.removed1754    }17551756    /** Browser parity: number of active entries that dedupe would move to trash. */1757    suspend fun countDuplicateEntries(): Int = mutex.withLock {1758        val epoch = requireUnlocked()1759        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1760        // lock() can't race the read with state writes.1761        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1762            ?: throw IllegalStateException("vault_locked")1763    }17641765    /**1766     * Browser parity: soft-delete duplicate entries, keeping the most recently1767     * modified member of each group. Refuse in decoy mode so the duress vault is1768     * not reshaped into a forensically distinct state.1769     */1770    suspend fun deduplicateEntries(): Int = mutex.withLock {1771        val epoch = requireUnlocked()1772        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1773        val ready = mutateCurrentSession(epoch) {1774            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1775            val active = entryList.filter { it.deletedAt == null }1776            val keepIds = mutableSetOf<String>()1777            active.groupBy { it.duplicateKey() }.values.forEach { group ->1778                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1779            }1780            val rollback = entryList.toMutableList()1781            var removed = 01782            entryList = entryList.map { entry ->1783                if (entry.deletedAt == null && entry.id !in keepIds) {1784                    removed++1785                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1786                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1787                } else {1788                    entry1789                }1790            }.toMutableList()1791            R(rollback, removed)1792        } ?: throw IllegalStateException("vault_locked")1793        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1794        ready.removed1795    }17961797    fun getEntries(): List<VaultEntry> = mutateState {1798        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1799    }1800    fun getEntry(id: String): VaultEntry? = mutateState {1801        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1802    }1803    fun getDeletedEntries(): List<VaultEntry> = mutateState {1804        if (!sessionDataAvailable) emptyList()1805        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1806    }18071808    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1809    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1810        return mutateState {1811            if (!sessionDataAvailable) return@mutateState emptyList()1812            val active = entryList.filter { it.deletedAt == null }1813            if (tagFilter == null) return@mutateState active1814            val needle = tagFilter.trim().lowercase()1815            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1816        }1817    }18181819    /** Sorted, de-duplicated tags across all active entries. */1820    fun getDistinctTags(): List<String> = mutateState {1821        if (!sessionDataAvailable) return@mutateState emptyList()1822        entryList.asSequence()1823            .filter { it.deletedAt == null }1824            .flatMap { it.tags?.asSequence() ?: emptySequence() }1825            .map { it.trim().lowercase() }1826            .filter { it.isNotEmpty() }1827            .distinct()1828            .sorted()1829            .toList()1830    }18311832    /**1833     * Distinct non-blank values for an identity field across active entries,1834     * most-frequently-used first. Supports "username" and "email"; unknown field1835     * names return an empty list. Values keep original casing (deduped1836     * case-insensitively) so a suggestion matches what the user typed.1837     */1838    fun getDistinctFieldValues(field: String): List<String> {1839        val select: (VaultEntry) -> String? = when (field) {1840            "username" -> { e -> e.username }1841            "email" -> { e -> e.email }1842            else -> return emptyList()1843        }1844        return mutateState {1845            if (!sessionDataAvailable) return@mutateState emptyList()1846            entryList.asSequence()1847                .filter { it.deletedAt == null }1848                .mapNotNull { select(it)?.trim() }1849                .filter { it.isNotEmpty() }1850                .groupingBy { it.lowercase() }1851                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1852                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1853                }1854                .values1855                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1856                .map { it.first }1857        }1858    }18591860    fun getTombstones(): List<Tombstone> = mutateState {1861        if (!sessionDataAvailable) emptyList()1862        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1863    }1864    fun getVaultVersion(): Long = store.version()1865    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18661867    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1868        val expectedEpoch = currentEpoch()1869        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1870        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1871        // H-04: snapshots now carry the duress slot too — restore preserves1872        // the at-rest triad (A/B/D), not just the A/B pair.1873        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1874        val priorA = store.loadSlotA()1875        val priorB = store.loadSlotB()1876        val priorD = store.loadDecoySlot()1877        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1878        // slot write — the old code took this snapshot, then stranded it on1879        // every failure, polluting history with a "restore" entry that never1880        // restored anything. The history below is rolled back together with1881        // the slots if the commit fails.1882        val priorHistory = store.loadVaultHistory()1883        // P1-4 (codex): the recovery snapshot must land before we commit the1884        // slot pair, otherwise a failed restore cannot be rolled back to the1885        // pre-restore history. If the history write itself fails, refuse to1886        // proceed — the alternative is a successful restore with no undo path.1887        beforeDurableCommit()1888        if (!takeVaultSnapshot("restore", expectedEpoch)) {1889            return false1890        }1891        // H-04: legacy snapshots without vault_d, or vault_d that fails to1892        // decode, fall back to a fake duress of slot A's size — presence-1893        // indistinguishable from any other freshly-restored vault.1894        val nextD: StoredVault = restoredD ?: run {1895            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161896            val fakeBlob = ByteArray(target).also(rng::nextBytes)1897            val fakeSalt = VaultCrypto.generateSalt()1898            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1899        }1900        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1901            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1902            return false1903        }1904        lock()1905        true1906    }19071908    /**1909     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1910     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1911     * (a restore preserves the duress-shaped blob from history), not a fresh1912     * noise record. Any failure rolls back so the at-rest pair never holds a1913     * restored slot A against a pre-restore slot B. Caller is responsible for1914     * restoring the pre-snapshot history on a false return.1915     *1916     * H-04: duress slot is now part of the atomic triad — restored last so1917     * a duress-write failure rolls the A/B pair back too.1918     */1919    private fun commitRestoreAtomic(1920        nextA: StoredVault,1921        nextB: String,1922        nextD: StoredVault,1923        priorA: StoredVault?,1924        priorB: String?,1925        priorD: StoredVault?,1926        expectedEpoch: Long,1927    ): Boolean {1928        return withEpochCommit(expectedEpoch) {1929            if (!store.writeSlotA(nextA)) return@withEpochCommit false1930            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1931            // must trigger rollback; previously a throw escaped without restoring1932            // the prior slot A and history.1933            try {1934                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1935                // H-04: duress slot is part of the restore triad. Failures here1936                // roll A+B back so the at-rest state stays consistent.1937                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1938                store.stampLocalMutation()1939            } catch (_: Exception) {1940                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1941                return@withEpochCommit false1942            }1943            true1944        } ?: false1945    }19461947    // ---- Sync apply (does NOT advance the version) ------------------------19481949    /**1950     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1951     * WITHOUT bumping the version counter, so a sync round cannot cascade into1952     * another outbound sync. Returns the number of locally-applied changes.1953     */1954    suspend fun applyMergedRemoteEntries(1955        remoteEntries: List<VaultEntry>,1956        remoteTombstones: List<Tombstone>,1957        expectedEpoch: Long? = null,1958    ): Int = mutex.withLock {1959        val epoch = expectedEpoch ?: requireUnlocked()1960        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01961        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1962        // BEFORE any comparison against a local one. Without this a peer can1963        // send a far-future modifiedAt and permanently pin its version of an1964        // entry against every honest local edit. The browser clamps in the1965        // matching place (vault.ts applyMergedRemoteEntries).1966        val mergeNow = System.currentTimeMillis()1967        val clamped = remoteEntries.map { entry ->1968            entry.copy(1969                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1970                deletedAt = entry.deletedAt?.let {1971                    SyncLimits.clampSyncTimestamp(it, mergeNow)1972                },1973            )1974        }1975        val tombMap = remoteTombstones.associate {1976            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1977        }1978        // H-03: build the new entryList under stateLock so a concurrent lock()1979        // cannot zero derivedKey/entryList between the byId read and the merge.1980        val applied = mutateCurrentSession(epoch) {1981            var n = 01982            val byId = entryList.associateBy { it.id }.toMutableMap()19831984            for (remote in clamped) {1985                val tomb = tombMap[remote.id]1986                if (tomb != null && tomb >= remote.modifiedAt) continue1987                val local = byId[remote.id]1988                if (local == null) {1989                    entryList.add(remote); byId[remote.id] = remote; n++1990                } else if (remote.modifiedAt > local.modifiedAt) {1991                    val i = entryList.indexOfFirst { it.id == local.id }1992                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1993                }1994            }19951996            for ((id, deletedAt) in tombMap) {1997                val local = byId[id] ?: continue1998                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1999                if (deletedAt < local.modifiedAt) continue2000                val i = entryList.indexOfFirst { it.id == id }2001                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2002            }2003            n2004        } ?: return@withLock 020052006        if (applied > 0) {2007            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2008            mutateIfCurrentEpoch(epoch) { publish() }2009        }2010        applied2011    }20122013    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2014        // Require a fully live session before exposing a settings mutation.  The2015        // epoch is captured with the read so a lock/new-session boundary cannot2016        // let stale work publish into the replacement session.2017        val epoch = requireUnlocked()2018        val prior = mutateCurrentSession(epoch) {2019            val old = _settings.value2020            _settings.value = settings2021            old2022        } ?: throw IllegalStateException("vault_locked")20232024        val committed = try {2025            beforeDurableCommit()2026            // The durable write is inside the same epoch fence as the in-memory2027            // publication.  If lock() won first, this returns null and no stale2028            // settings reach storage or the public StateFlow.2029            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2030        } catch (_: Exception) {2031            false2032        }2033        if (!committed) {2034            // A live-session storage failure rolls back the public value.  A2035            // stale epoch is already reset by lock()/the new session and must2036            // not be allowed to overwrite that safe state.2037            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2038            throw IllegalStateException("settings_write_failed")2039        }2040    }20412042    // ---- Recovery words (vault metadata) ----------------------------------20432044    /** True once the user has confirmed their recovery words for this vault. */2045    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20462047    /**2048     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2049     * Persisted without bumping the version — recovery metadata is local-only and2050     * is not an entry mutation paired devices should sync against.2051     */2052    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2053        val epoch = requireUnlocked()2054        // H-03: hash + escrow-related state fields set under stateLock so a2055        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2056        // M-04: the flags have to be live before the escrows are built — both seal2057        // currentPayload() — so capture the prior pair and put it back if the write2058        // never lands. Otherwise the session reports words set against a slot A2059        // that still holds no escrow, and the recovery screen never offers a retry.2060        val prior = mutateCurrentSession(epoch) {2061            val priorPair = recoveryWordHash to recoveryWordsSet2062            recoveryWordHash = recoveryWordsHash(words)2063            recoveryWordsSet = true2064            priorPair2065        } ?: return@withLock false2066        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2067        // (cross-platform recovery of the sync root key) and Android's whole-payload2068        // escrow (offline forgot-password restore of entries). Both derive their key2069        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2070        val persisted = runCatching {2071            persistSlotAWithEscrows(2072                buildRecoveryEscrow(words),2073                buildRootKeyEscrow(words),2074                expectedEpoch = epoch,2075            )2076        }.getOrDefault(false)2077        if (!persisted) {2078            mutateIfCurrentEpoch(epoch) {2079                recoveryWordHash = prior.first2080                recoveryWordsSet = prior.second2081            }2082        }2083        persisted2084    }20852086    /** Verify entered words against the stored recovery hash. */2087    fun verifyRecoveryWords(words: List<String>): Boolean {2088        return mutateState {2089            val stored = recoveryWordHash ?: return@mutateState false2090            stored == recoveryWordsHash(words)2091        }2092    }20932094    // ---- Recovery escrow (forgot-password) --------------------------------20952096    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2097    fun hasRecoveryEscrow(): Boolean =2098        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20992100    /**2101     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2102     * the master password. On success the slot-A plaintext sealed in the escrow is2103     * adopted as the live session (entries, rootKey, settings), but no Argon2id2104     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2105     * new password and finish unlocking. Returns false when there is no escrow, the2106     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2107     *2108     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2109     * detached vault key to return here — slot A is wrapped under the rotating2110     * Argon2id key — so this opens the escrowed payload directly.2111     */2112    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2113        val expectedEpoch = currentEpoch()2114        val slotA = store.loadSlotA() ?: return false2115        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21162117        // Prefer Android's whole-payload escrow: a full offline restore of entries,2118        // root key, and settings. Present for Android-created vaults.2119        val fullEscrow = slotA.recoveryEscrow2120        if (fullEscrow != null) {2121            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2122            if (material != null) {2123                var keyBytes: ByteArray? = null2124                var plaintext: ByteArray? = null2125                var candidateRoot: ByteArray? = null2126                var stateCommitStarted = false2127                try {2128                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2129                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2130                    val payload = VaultCodec.decode(plaintext)2131                    // Stage every fallible operation before touching live session2132                    // fields.  In particular, a settings/provider/decode failure2133                    // cannot leave entries, flags, or a root key resident while the2134                    // public session remains locked.2135                    val candidateSettings = store.loadSettings()2136                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2137                        val decodedRoot = unb64(payload.rootKeyB64)2138                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2139                            // Do not let a malformed authenticated payload abandon2140                            // this decoded root-key candidate before the outer2141                            // finally block owns it.2142                            VaultCrypto.zero(decodedRoot)2143                            throw IllegalArgumentException("invalid recovery root key")2144                        }2145                        decodedRoot2146                    } else {2147                        generateRecoverableRootKey()2148                    }2149                    stateCommitStarted = true2150                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2151                        clearSessionStateLocked()2152                        entryList = payload.entries.toMutableList()2153                        duressConfigured = payload.duressConfigured2154                        recoveryWordHash = payload.recoveryWordHash2155                        recoveryWordsSet = payload.recoveryWordsSet2156                        rootKey = candidateRoot2157                        candidateRoot = null2158                        _settings.value = candidateSettings2159                        publish()2160                        // This is the authenticated recovery half-unlock boundary:2161                        // only set it after every candidate field is installed.2162                        sessionDataAvailable = true2163                        _isUnlocked.value = false2164                        true2165                    }2166                    if (adopted == true) return@withLock true2167                } catch (_: Exception) {2168                    // Wrong words, malformed payloads, provider failures, and2169                    // settings failures all fall through to the browser-compatible2170                    // root-key escrow.  If the state commit had started, invalidate2171                    // that epoch so no partial candidate survives.2172                    if (stateCommitStarted) {2173                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2174                    }2175                } finally {2176                    plaintext?.let(VaultCrypto::zero)2177                    VaultCrypto.zero(material)2178                    keyBytes?.let(VaultCrypto::zero)2179                    candidateRoot?.let(VaultCrypto::zero)2180                }2181            }2182        }21832184        // Browser-format vault (only the root-key escrow): recover the 32-byte root2185        // key (sync identity) and reset the password; entries return via sync. This2186        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2187        val rootEscrow = slotA.rootKeyEscrow2188        if (rootEscrow != null) {2189            var candidateRoot: ByteArray? = null2190            var stateCommitStarted = false2191            try {2192                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2193                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2194                val candidateSettings = store.loadSettings()2195                stateCommitStarted = true2196                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2197                    clearSessionStateLocked()2198                    entryList = mutableListOf()2199                    duressConfigured = false2200                    recoveryWordHash = recoveryWordsHash(words)2201                    recoveryWordsSet = true2202                    rootKey = candidateRoot2203                    candidateRoot = null2204                    _settings.value = candidateSettings2205                    publish()2206                    sessionDataAvailable = true2207                    _isUnlocked.value = false2208                    true2209                }2210                if (adopted == true) return@withLock true2211            } catch (_: Exception) {2212                if (stateCommitStarted) {2213                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2214                }2215            } finally {2216                candidateRoot?.let(VaultCrypto::zero)2217            }2218        }2219        return false2220    }22212222    /**2223     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2224     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2225     * words, mark the session fully unlocked, and publish. Returns false if called2226     * without a prior successful recovery unlock (no in-memory root key).2227     *2228     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2229     * into one step, since there is no detached vault key here.2230     */2231    suspend fun resetPasswordWithRecoveryWords(2232        words: List<String>,2233        newPassword: String,2234    ): Boolean = mutex.withLock {2235        val expectedEpoch = currentEpoch()2236        // H-03: read rootKey + entryList under stateLock before computing the new2237        // plaintext, then re-publish + swap the derived key under stateLock so a2238        // concurrent lock() cannot straddle the two fences.2239        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2240        val priorA = store.loadSlotA() ?: return false2241        var newKey: ByteArray? = null2242        var plaintext: ByteArray? = null2243        return try {2244            val newSalt = VaultCrypto.generateSalt()2245            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2246            plaintext = VaultCodec.encode(currentPayload())2247            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2248            // Rebuild both escrows against the recovered session so their salts rotate2249            // with the password reset; fall back to the prior escrows if rebuild fails2250            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2251            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2252            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2253            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2254            val next = StoredVault(2255                version = priorA.version,2256                salt = b64(newSalt),2257                blob = blob,2258                recoveryEscrow = freshRecovery,2259                rootKeyEscrow = freshRootKey,2260            )2261            // H-01: see changePassword — both rotations share the same2262            // commit-or-keep-old-key contract so a partial write can't strand2263            // the session on a key that no longer matches disk.2264            val priorB = store.loadSlotB()2265            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2266            // H-03 (P1-2 codex): zero the old live key and install the new one2267            // inside the same stateLock critical section so a concurrent lock()2268            // can't observe the in-between.2269            if (mutateIfCurrentEpoch(expectedEpoch) {2270                derivedKey?.let(VaultCrypto::zero)2271                derivedKey = newKey; newKey = null2272                publish()2273                _isUnlocked.value = true2274            } == null) return false2275            true2276        } catch (_: Exception) {2277            false2278        } finally {2279            newKey?.let(VaultCrypto::zero)2280            plaintext?.let(VaultCrypto::zero)2281        }2282    }22832284    /**2285     * Seal the current slot-A plaintext under a key derived from the 12 BIP392286     * recovery [words] (Android offline-restore escrow). IKM is the browser's2287     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2288     * Returns null for a checksum-invalid phrase.2289     */2290    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2291        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2292        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2293        var keyBytes: ByteArray? = null2294        var plaintext: ByteArray? = null2295        return try {2296            val salt = VaultCrypto.generateSalt()2297            val recoveryKey = deriveRecoveryKey(material, salt)2298            keyBytes = recoveryKey.encoded2299            plaintext = VaultCodec.encode(currentPayload())2300            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2301            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2302        } finally {2303            VaultCrypto.zero(material)2304            keyBytes?.let(VaultCrypto::zero)2305            plaintext?.let(VaultCrypto::zero)2306        }2307    }23082309    /**2310     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2311     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2312     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2313     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2314     * the same root key on the browser. Returns null with no in-memory root key or2315     * a checksum-invalid phrase.2316     */2317    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2318        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2319        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2320        val rk = mutateState { rootKey?.copyOf() } ?: run {2321            VaultCrypto.zero(material)2322            return null2323        }2324        var keyBytes: ByteArray? = null2325        var plaintext: ByteArray? = null2326        return try {2327            val salt = VaultCrypto.generateSalt()2328            val recoveryKey = deriveRecoveryKey(material, salt)2329            keyBytes = recoveryKey.encoded2330            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2331            // combined = nonce(12) || ciphertext || tag(16)2332            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2333            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2334            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2335            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2336            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2337            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2338        } finally {2339            VaultCrypto.zero(rk)2340            VaultCrypto.zero(material)2341            keyBytes?.let(VaultCrypto::zero)2342            plaintext?.let(VaultCrypto::zero)2343        }2344    }23452346    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2347    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2348        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2349        var keyBytes: ByteArray? = null2350        var pt: ByteArray? = null2351        return try {2352            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2353            keyBytes = recoveryKey.encoded2354            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2355            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2356            hexToRootKey(String(pt, Charsets.US_ASCII))2357        } catch (_: Exception) {2358            null2359        } finally {2360            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2361            // IKM-equivalent of the vault identity. material+keyBytes were2362            // already zeroed; pt was leaking.2363            VaultCrypto.zero(material)2364            keyBytes?.let(VaultCrypto::zero)2365            pt?.let(VaultCrypto::zero)2366        }2367    }23682369    /**2370     * Re-seal slot A from the current in-memory state under the live key, carrying2371     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2372     * in decoy mode (recovery is never offered for the decoy) and persists without2373     * a version bump, like the recovery-words metadata write it replaces.2374     */2375    private fun persistSlotAWithEscrows(2376        recoveryEscrow: RecoveryEscrow?,2377        rootKeyEscrow: RootKeyEscrow?,2378        expectedEpoch: Long? = null,2379    ): Boolean {2380        val snapshot = snapshotForPersist(expectedEpoch)2381            ?: throw IllegalStateException("vault_locked_or_stale")2382        if (snapshot.decoy) {2383            VaultCrypto.zero(snapshot.key)2384            return false2385        }2386        try {2387            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2388            val plaintext = VaultCodec.encode(snapshot.payload)2389            val blob = try {2390                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2391            } finally {2392                // The escrow path used to drop this reference before zeroing it;2393                // keep it live through the fill on both success and failure.2394                VaultCrypto.zero(plaintext)2395            }2396            val next = prior.copy(2397                blob = blob,2398                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2399                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2400            )2401            beforeDurableCommit()2402            return withEpochCommit(snapshot.epoch) {2403                if (!store.writeSlotA(next)) return@withEpochCommit false2404                try {2405                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2406                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2407                    // the duress slot too. A real decoy is left alone; a fake is2408                    // re-sized to slot A's new ciphertext length.2409                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2410                } catch (e: Exception) {2411                    store.writeSlotA(prior)2412                    throw e2413                }2414                true2415            } ?: false2416        } finally {2417            VaultCrypto.zero(snapshot.key)2418        }2419    }24202421    // ---- Internals --------------------------------------------------------24222423    private fun requireUnlocked(): Long {2424        // H-03: read derivedKey atomically with [lock]'s reset; without it,2425        // [lock] from a background lifecycle event can run between this read2426        // and the caller's first state touch.2427        stateLock.lock()2428        try {2429            if (derivedKey == null) throw IllegalStateException("vault_locked")2430            return sessionEpoch2431        } finally {2432            stateLock.unlock()2433        }2434    }24352436    /** A locked or half-adopted session must not expose secret-bearing fields. */2437    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24382439    private fun countDuplicateEntriesLocked(): Int {2440        val seen = mutableMapOf<String, VaultEntry>()2441        var duplicates = 02442        for (entry in entryList.filter { it.deletedAt == null }) {2443            val key = entry.duplicateKey()2444            val existing = seen[key]2445            if (existing == null) {2446                seen[key] = entry2447            } else {2448                duplicates++2449                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2450                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2451                if (entryStamp > existingStamp) seen[key] = entry2452            }2453        }2454        return duplicates2455    }24562457    private fun VaultEntry.duplicateKey(): String {2458        if (type == "note") return "note|${title.lowercase()}"2459        val passkeyData = passkey2460        if (passkeyData != null) {2461            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2462        }2463        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2464    }24652466    /**2467     * Capture the current slot pair (and metadata) into the history list.2468     * Returns true when the history write landed, false when it didn't — callers2469     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2470     * must check the result and roll back if it does not.2471     */2472    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2473        val epoch = expectedEpoch ?: currentEpoch()2474        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2475        val slotA = store.loadSlotA() ?: return false2476        val vaultA = VaultCodec.encodeSlot(slotA)2477        val vaultB = store.loadSlotB() ?: slotA.blob2478        // H-04: capture the duress slot so a restore preserves the at-rest2479        // triad, not just the A/B pair. The duress slot is always present2480        // (real or fake), so loadDecoySlot() returning null is a logic error2481        // — fall through to a fake duress in that case rather than silently2482        // losing the record from the snapshot.2483        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2484            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162485            val fakeBlob = ByteArray(target).also(rng::nextBytes)2486            val fakeSalt = VaultCrypto.generateSalt()2487            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2488        }2489        val history = store.loadVaultHistory()2490        val latest = history.snapshots.firstOrNull()2491        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2492        val entryCount = mutateIfCurrentEpoch(epoch) {2493            if (derivedKey != null && !decoyMode) {2494                bucketEntryCount(entryList.count { it.deletedAt == null })2495            } else {2496                null2497            }2498        } ?: return false2499        return try {2500            val snapshot = VaultSnapshot(2501                id = UUID.randomUUID().toString(),2502                savedAt = System.currentTimeMillis(),2503                reason = reason,2504                vault_a = vaultA,2505                vault_b = vaultB,2506                vault_d = vaultD,2507                entryCount = entryCount,2508            )2509            // P1-4 (codex): return the history-write boolean to callers so a2510            // false return no longer leaves the snapshot out of history while2511            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2512            withEpochCommit(epoch) {2513                store.writeVaultHistory(2514                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2515                )2516            } ?: false2517        } catch (_: Exception) {2518            false2519        }2520    }25212522    private fun bucketEntryCount(n: Int): Int = when {2523        n <= 0 -> 02524        n <= 10 -> 102525        n <= 25 -> 252526        n <= 50 -> 502527        n <= 100 -> 1002528        n <= 250 -> 2502529        n <= 500 -> 5002530        else -> ((n + 499) / 500) * 5002531    }25322533    /**2534     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2535     * so it is stored explicitly and survives the sync wire format rather than being2536     * re-derived at render time. Non-card entries pass through unchanged.2537     *2538     * M-03: never overwrite a stored last4 with null. A card kept only as last42539     * (e.g. after a PeachScript import that had no PAN) must keep that value;2540     * re-deriving from an absent cardNumber would silently zero the field.2541     */2542    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2543        if (!entry.isCard()) return entry2544        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2545        val derived = if (digits.length >= 4) digits.takeLast(4) else null2546        return entry.copy(last4 = derived ?: entry.last4)2547    }25482549    /**2550     * On unlock, hard-delete trashed entries older than the retention window.2551     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2552     * (the app's "Never" option), so nothing is purged. Persists without a2553     * version bump and without touching the publish state (caller publishes).2554     */2555    private fun autoPurgeExpiredTrash() {2556        val days = _settings.value.trashRetentionDays ?: return2557        if (days <= 0) return2558        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2559        val before = entryList.size2560        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2561        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2562    }25632564    /** The slot-A plaintext envelope for the current in-memory session. */2565    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25662567    private fun currentPayloadLocked(): VaultPayload =2568        VaultPayload(2569            entries = entryList.toList(),2570            duressConfigured = duressConfigured,2571            rootKeyB64 = rootKey?.let(::b64) ?: "",2572            recoveryWordHash = recoveryWordHash,2573            recoveryWordsSet = recoveryWordsSet,2574        )25752576    /**2577     * Adopt the payload's root key, lazily minting one for legacy payloads.2578     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2579     * a root key is not a mutation paired devices should sync against.2580     */2581    private fun ensureRootKey(payload: VaultPayload) {2582        if (payload.rootKeyB64.isNotBlank()) {2583            rootKey = unb64(payload.rootKeyB64)2584            return2585        }2586        rootKey = generateRecoverableRootKey()2587        runCatching { persistSlotA(bumpVersion = false) }2588    }25892590    private fun publish() {2591        _entries.value = entryList.filter { it.deletedAt == null }2592    }25932594    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2595        var plaintext = VaultCodec.encode(currentPayload())2596        return try {2597            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2598            StoredVault(version = 1, salt = b64(salt), blob = blob)2599        } finally {2600            // M-05: full vault plaintext is allocated here once per createVault.2601            // Zeroing in finally keeps the transient copy off the heap after the2602            // caller either succeeds or rolls back via lock().2603            VaultCrypto.zero(plaintext)2604        }2605    }26062607    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2608        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2609        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2610        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2611        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2612        val noise = ByteArray(approxBytes).also(rng::nextBytes)2613        return store.writeSlotB(b64(noise))2614    }26152616    /** H-04: the ciphertext length slot A is using right now, so the duress2617     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2618     *  length equals ciphertext length, so this also matches slot A's plaintext. */2619    private fun matchingCiphertextLength(slotA: StoredVault): Int =2620        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26212622    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2623     *  bytes. Replaces a real decoy when the user clears duress, and is the2624     *  initial duress slot when no decoy is configured — so the record's2625     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2626     *  matches slot A's blob length. */2627    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2628        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162629        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2630        val fakeSalt = VaultCrypto.generateSalt()2631        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2632    }26332634    /** H-04: keep the duress record presence- and size-indistinguishable from2635     *  slot A on every persist. A real decoy is left alone (it was padded at2636     *  setDuressPassphrase time, and we don't have the duress key here to2637     *  re-pad on entry growth — that's an accepted residual tell, see the2638     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2639     *  A's size. */2640    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2641        val isConfigured = mutateState { duressConfigured }2642        return if (isConfigured) true else writeFakeDuressSlot(slotA)2643    }26442645    /**2646     * H-04: convert a browser-shaped duress slot to Android StoredVault2647     * without decrypting. The browser seals slot "b" under the duress2648     * passphrase; decrypting with the main password silently drops it.2649     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2650     * the salt from hex to base64.2651     */2652    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2653        val salt = unhex(b.salt)2654        val nonce = unhex(b.nonce)2655        val ct = unhex(b.ciphertext)2656        val tag = unhex(b.tag)2657        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2658            System.arraycopy(nonce, 0, it, 0, nonce.size)2659            System.arraycopy(ct, 0, it, nonce.size, ct.size)2660            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2661        }2662        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2663    }26642665    /** Re-seal slot A from the current in-memory state under the live key. */2666    private fun persistSlotA(2667        bumpVersion: Boolean,2668        snapshotReason: String? = "local_save",2669        expectedEpoch: Long? = null,2670    ): Boolean {2671        val snapshot = snapshotForPersist(expectedEpoch)2672            ?: throw IllegalStateException("vault_locked_or_stale")2673        return try {2674            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2675            if (snapshot.decoy) {2676                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2677                val plaintext = VaultCodec.encode(snapshot.payload)2678                val blob = try {2679                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2680                } finally {2681                    // Keep the last reference alive until the zero, including the2682                    // successful encrypt path (M-05).2683                    VaultCrypto.zero(plaintext)2684                }2685                beforeDurableCommit()2686                return withEpochCommit(snapshot.epoch) {2687                    store.writeDecoySlot(prior.copy(blob = blob))2688                } ?: false2689            }2690            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2691            // H-01: captured BEFORE the noise write so a stamp failure can restore2692            // the prior slot B alongside the prior slot A. Slot B is always2693            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2694            // leaves (new A, new B, old version) on disk — partial commit.2695            val priorB = store.loadSlotB()2696            beforeDurableCommit()2697            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2698            val plaintext = VaultCodec.encode(snapshot.payload)2699            val blob = try {2700                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2701            } finally {2702                // Do not drop [plaintext] before fill(0): the serialized vault2703                // contains every entry password and is the primary M-05 target.2704                VaultCrypto.zero(plaintext)2705            }2706            val next = prior.copy(blob = blob)2707            // The epoch check and the entire A/B/D/version commit share one brief2708            // stateLock critical section. lock() either wins before this block or2709            // waits until the already-committed write is complete; it can never2710            // clear the session between the check and the durable write.2711            withEpochCommit(snapshot.epoch) {2712                if (!store.writeSlotA(next)) return@withEpochCommit false2713                try {2714                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2715                    // H-04: duress slot stays present (real one untouched, fake one2716                    // re-padded to slot A's current size on every persist).2717                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2718                    if (bumpVersion) store.stampLocalMutation()2719                } catch (e: Exception) {2720                    store.writeSlotA(prior)2721                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2722                    throw e2723                }2724                true2725            } ?: false2726        } finally {2727            // Snapshot keys are copies, so lock() may zero the live key while this2728            // operation is still deriving/encrypting; always scrub our copy too.2729            VaultCrypto.zero(snapshot.key)2730        }2731    }27322733    /**2734     * H-01: durably commit a slot-A rotation by writing slot A, its2735     * size-matched noise slot B, and the local-mutation stamp in order. Any2736     * failure rolls the previously-committed steps back so the at-rest pair2737     * matches what was there before the call. Returns true only when every2738     * step succeeded — callers must NOT swap their session key on false.2739     */2740    private fun commitVaultRotation(2741        priorA: StoredVault,2742        priorB: String?,2743        nextA: StoredVault,2744        expectedEpoch: Long,2745    ): Boolean {2746        beforeDurableCommit()2747        return withEpochCommit(expectedEpoch) {2748            if (!store.writeSlotA(nextA)) return@withEpochCommit false2749            // P1-1 (codex): the noise write's boolean AND any thrown exception from2750            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2751            // escaped the helper and left slot A committed.2752            try {2753                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2754                // H-04: a password rotation changes slot A's plaintext size (new2755                // salt → re-encrypted blob), so the duress slot must be re-padded2756                // alongside. writeCompanionDuressSlot leaves a real decoy alone2757                // and re-emits a fake of slot A's size otherwise.2758                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2759                store.stampLocalMutation()2760            } catch (_: Exception) {2761                rollbackRotation(priorA, priorB)2762                return@withEpochCommit false2763            }2764            true2765        } ?: false2766    }27672768    /**2769     * Best-effort undo of the writes [commitVaultRotation] may have committed.2770     * Each step runs independently — a failed rollback must not mask the2771     * original commit failure. Log is a throwing stub off-device.2772     */2773    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2774        val failures = mutableListOf<String>()2775        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2776            .onFailure { failures += "writeSlotA: $it" }2777        if (priorB != null) {2778            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2779                .onFailure { failures += "writeSlotB: $it" }2780        }2781        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2782    }27832784    /** Persist a local mutation, rolling the in-memory list back on failure. */2785    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2786        try {2787            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2788                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2789                throw IllegalStateException("storage_write_failed")2790            }2791        } catch (e: Exception) {2792            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2793            throw e2794        }2795        // A lock can win in the small interval after the durable commit and2796        // before publication. The epoch check prevents pre-lock entries from2797        // being republished into a newly locked (or newly unlocked) session.2798        mutateIfCurrentEpoch(expectedEpoch) { publish() }2799    }2800}