VaultRepository.kt

133.2 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        var salt: ByteArray? = null1008        var blob: ByteArray? = null1009        var nonce: ByteArray? = null1010        var ct: ByteArray? = null1011        var tag: ByteArray? = null1012        try {1013            salt = unb64(slot.salt)1014            blob = unb64(slot.blob)1015            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1016            val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1017            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1018            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1019            val browserSlot = BrowserSlotA(1020                version = slot.version,1021                salt = salt.joinToString("") { "%02x".format(it) },1022                nonce = nonce.joinToString("") { "%02x".format(it) },1023                ciphertext = ct.joinToString("") { "%02x".format(it) },1024                tag = tag.joinToString("") { "%02x".format(it) },1025            )1026            return BrowserSlotJson.encodeToString(BrowserSlotA.serializer(), browserSlot)1027        } finally {1028            salt?.let(VaultCrypto::zero)1029            blob?.let(VaultCrypto::zero)1030            nonce?.let(VaultCrypto::zero)1031            ct?.let(VaultCrypto::zero)1032            tag?.let(VaultCrypto::zero)1033        }1034    }10351036    /**1037     * Install a browser-shaped duress slot received through adoption.1038     *1039     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1040     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1041     * writes the result to the decoy slot. Epoch-fenced like the Codex1042     * restore path so a concurrent lock() cannot strand the write.1043     *1044     * Returns false on invalid input, no live session, or write failure.1045     */1046    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1047        val b = BrowserSlot.parse(json) ?: return false1048        var stored: StoredVault? = null1049        var salt: ByteArray? = null1050        var nonce: ByteArray? = null1051        var ct: ByteArray? = null1052        var tag: ByteArray? = null1053        var blob: ByteArray? = null1054        try {1055            salt = unhex(b.salt)1056            nonce = unhex(b.nonce)1057            ct = unhex(b.ciphertext)1058            tag = unhex(b.tag)1059            blob = ByteArray(nonce.size + ct.size + tag.size).also {1060                System.arraycopy(nonce, 0, it, 0, nonce.size)1061                System.arraycopy(ct, 0, it, nonce.size, ct.size)1062                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1063            }1064            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1065            val expectedEpoch = sessionEpoch1066            beforeDurableCommit()1067            withEpochCommit(expectedEpoch) {1068                store.writeDecoySlot(stored)1069            } ?: false1070        } catch (_: Exception) {1071            false1072        } finally {1073            salt?.let(VaultCrypto::zero)1074            nonce?.let(VaultCrypto::zero)1075            ct?.let(VaultCrypto::zero)1076            tag?.let(VaultCrypto::zero)1077            blob?.let(VaultCrypto::zero)1078        }1079    }10801081    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1082    suspend fun clearDuress(): Boolean = mutex.withLock {1083        // H-04: presence-indistinguishable — replace the real decoy with a fake1084        // blob of slot A's size rather than removing the duress record. The1085        // record stays on disk at the same length, so an at-rest observer1086        // cannot tell "duress cleared" from "no duress ever configured".1087        val expectedEpoch = currentEpoch()1088        val replaced = withEpochCommit(expectedEpoch) {1089            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1090                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1091        } ?: false1092        if (!replaced) return@withLock false1093        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1094        // duressConfigured write so a concurrent lock() cannot land between them.1095        if (!isSessionEpochCurrent(expectedEpoch)) return false1096        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1097        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1098            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1099        }1100        true1101    }11021103    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1104    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1105        var current: Throwable? = error1106        while (current != null) {1107            if (current is javax.crypto.AEADBadTagException) return true1108            // Some Android providers expose a GCM tag failure as the parent1109            // BadPaddingException rather than the AEAD-specific subtype.  A1110            // short ciphertext is an IllegalBlockSizeException and therefore1111            // remains an internal/malformed-payload Error below.1112            if (current is javax.crypto.BadPaddingException) return true1113            current = current.cause1114        }1115        return false1116    }11171118    /** Unlock with an already-derived AES-256 key (biometric path). */1119    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1120        val expectedEpoch = currentEpoch()1121        val slotA = try {1122            store.loadSlotA()1123        } catch (_: Exception) {1124            return@withLock UnlockResult.Error("unlock_key_storage_failed")1125        } ?: return@withLock UnlockResult.Error("no_vault")1126        // Keep the caller-owned key separate from the staged session key. The1127        // biometric caller always zeroes its input in a finally block; adoption1128        // must therefore own a copy and use the same all-or-nothing path as a1129        // password unlock rather than publishing a partially decoded session.1130        var stagedKey: ByteArray? = key.copyOf()1131        return try {1132            val encrypted = try {1133                unb64(slotA.blob)1134            } catch (_: Exception) {1135                return@withLock UnlockResult.Error("invalid_vault_blob")1136            }1137            // A GCM record must include nonce + authentication tag.  Providers1138            // differ on whether a shorter record is reported as BadPadding or1139            // AEADBadTag; reject it before decrypt so malformed storage cannot be1140            // misreported as a user-entered wrong key.1141            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1142                return@withLock UnlockResult.Error("invalid_vault_blob")1143            }1144            val plaintext = try {1145                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1146            } catch (e: Exception) {1147                if (isGcmAuthenticationFailure(e)) {1148                    return@withLock UnlockResult.WrongPassword1149                }1150                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1151            }1152            val payload = try {1153                VaultCodec.decode(plaintext)1154            } catch (_: Exception) {1155                return@withLock UnlockResult.Error("invalid_vault_payload")1156            } finally {1157                VaultCrypto.zero(plaintext)1158            }1159            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1160            val result = adoptSlotA(candidate, payload, expectedEpoch)1161            if (result is UnlockResult.Success) stagedKey = null1162            result1163        } finally {1164            stagedKey?.let(VaultCrypto::zero)1165        }1166    }11671168    fun lock() {1169        // H-03: must observe derivedKey/entryList atomically with any in-flight1170        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1171        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1172        // guards just the brief state reset. Advancing the epoch first invalidates1173        // every operation that captured this session before it reaches a later1174        // durable commit or publication.1175        stateLock.lock()1176        try {1177            sessionEpoch = nextSessionEpoch(sessionEpoch)1178            clearSessionStateLocked()1179        } finally {1180            stateLock.unlock()1181        }1182    }11831184    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11851186    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1187    // is a stub off-device).1188    @VisibleForTesting1189    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11901191    /**1192     * Call immediately before launching a system Activity (a SAF file1193     * picker/save dialog, a share sheet) that briefly takes the whole process1194     * out of the foreground as an expected part of an in-app flow — not a1195     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1196     * two apart on its own (it fires for both), and without this, exporting or1197     * importing a file silently locked and wiped the vault mid-flow the moment1198     * the picker took over, before its result ever reached the app.1199     *1200     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1201     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1202     */1203    fun expectTransientBackground() {1204        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1205    }12061207    /**1208     * Consumed once by the next background-lock check; see [expectTransientBackground].1209     * Returns false (and clears the flag) if the suppression has expired or was1210     * never set — a genuine background event must always lock.1211     */1212    fun consumeTransientBackgroundSuppression(): Boolean {1213        val until = suppressNextBackgroundLockUntil1214        val active = until != 0L && nowMs() < until1215        suppressNextBackgroundLockUntil = 0L1216        return active1217    }12181219    // ---- Sync identity ----------------------------------------------------12201221    /**1222     * The vault's stable sync master key (a copy). Caller must zero it. Backs1223     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1224     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1225     */1226    /**1227     * HOLE 5 — the duress vault never syncs.1228     *1229     * [commitDuressUnlock] gives the decoy session a root key so its own codex1230     * and recovery paths work, which means (unlike the browser, where the decoy1231     * path simply has no root key) the sync surface has to refuse explicitly.1232     * Without this the decoy vault would present a second, different vault_id1233     * to a paired device, and a coerced user opening it would make sync visibly1234     * fail on their other devices — a duress indicator, which is the one thing1235     * a duress vault must never be.1236     */1237    // M-04: the "null when locked" half of the contract was documented but never1238    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1239    // [unlockWithRecoveryWords], which installs a root key with no password key1240    // yet) and in a create that failed after staging. Sync must fail closed1241    // there, so the authoritative unlocked flag gates it, not just decoyMode.1242    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12431244    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1245        if (decoyMode || !_isUnlocked.value) null1246        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1247    }12481249    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1250        mutateState { sessionEpoch == epoch }12511252    internal fun unlockedSessionEpoch(): Long? = mutateState {1253        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1254    }12551256    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12571258    /**1259     * The current session's root key, decoy included. For local-only uses that1260     * must behave identically in a decoy session — Codex backup and recovery1261     * words — because a feature that errors under duress is itself a duress1262     * tell. In a decoy session this is the DECOY's root key (see1263     * [commitDuressUnlock]), never the real vault's, so neither surface can1264     * leak the real vault. Anything that leaves the device must use1265     * [syncMasterKey] instead.1266     */1267    fun sessionRootKey(): ByteArray? = mutateState {1268        if (!sessionDataAvailable) null else rootKey?.copyOf()1269    }12701271    /** Stable per-vault id two paired devices must share to sync. Null when1272     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1273    fun syncVaultId(): String? = mutateState {1274        if (decoyMode || !_isUnlocked.value) null1275        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1276    }12771278    /**1279     * Browser parity (MV-8): rotate the sync identity by replacing the stable1280     * root key. Entries remain under the master-password key, while sync1281     * credentials, vault_id, and recovery words all change.1282     */1283    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1284        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1285            ?: return false1286        installRootKey(generateRecoverableRootKey(), expectedEpoch)1287    }12881289    /**1290     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1291     * keeping every local entry.1292     *1293     * Sync identity (vault id, signing keys) derives from the root key, and1294     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1295     * created vaults can never reconcile. Taking the other side's root key is1296     * what makes them one vault; from the next sync on, both entry sets merge.1297     *1298     * Entries survive because they are sealed under the password-derived key,1299     * not the root key — the same property [rotateSyncIdentity] relies on.1300     *1301     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1302     * this device's previous recovery words stop working for good, and its new1303     * words are the source vault's words, because words are a deterministic1304     * encoding of the root key.1305     */1306    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1307        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1308            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1309        }1310        val expectedEpoch = mutateState {1311            when {1312                derivedKey == null -> null1313                decoyMode -> Long.MIN_VALUE1314                else -> sessionEpoch1315            }1316        }1317        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1318        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1319        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13201321        val incoming = hexToRootKey(rootKeyHex)1322        if (incoming.size != VaultCrypto.KEY_SIZE) {1323            VaultCrypto.zero(incoming)1324            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1325        }1326        // Refuse before committing rather than leave this device holding a vault1327        // whose recovery words cannot be typed back. generateRecoverableRootKey1328        // screens its own keys for this; a key arriving from a peer has not been.1329        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1330        if (!roundTrips) {1331            VaultCrypto.zero(incoming)1332            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1333        }13341335        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1336        return if (installRootKey(incoming, expectedEpoch)) {1337            AdoptVaultResult.Success(vaultId)1338        } else {1339            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1340        }1341    }13421343    /**1344     * The recovery words [rootKeyHex] will produce once joined, or null if it is1345     * not a usable root key. Lets the join flow show the user the words that are1346     * about to replace theirs *before* anything is committed.1347     */1348    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1349        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1350        val bytes = hexToRootKey(rootKeyHex)1351        return try {1352            val words = rootKeyToWords(bytes)1353            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1354        } catch (_: Exception) {1355            null1356        } finally {1357            VaultCrypto.zero(bytes)1358        }1359    }13601361    /**1362     * Swap the vault's root key, keeping every entry, and re-escrow under the1363     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1364     * [mutex].1365     */1366    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1367        // H-03: snapshot the live fields under stateLock so the success and the1368        // rollback paths both see consistent prior/new state. Writing the new1369        // rootKey/recovery fields is inside its own mutateState at the end.1370        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1371        // — holding live references would let a zero-on-failure operation also1372        // wipe the session's still-live key arrays.1373        data class Snapshot(1374            val key: ByteArray,1375            val priorRootKey: ByteArray,1376            val priorRecoveryHash: String?,1377            val priorRecoverySet: Boolean,1378        )1379        val snap = mutateIfCurrentEpoch(expectedEpoch) {1380            val k = derivedKey ?: return@mutateIfCurrentEpoch null1381            if (decoyMode) return@mutateIfCurrentEpoch null1382            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1383            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1384        } ?: run {1385            VaultCrypto.zero(nextRootKey)1386            return false1387        }1388        val priorSlot = store.loadSlotA() ?: run {1389            VaultCrypto.zero(snap.key)1390            VaultCrypto.zero(snap.priorRootKey)1391            VaultCrypto.zero(nextRootKey)1392            return false1393        }1394        val nextWords = rootKeyToWords(nextRootKey)13951396        return try {1397            beforeDurableCommit()1398            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1399            if (mutateIfCurrentEpoch(expectedEpoch) {1400                rootKey = nextRootKey1401                recoveryWordHash = recoveryWordsHash(nextWords)1402                recoveryWordsSet = false1403            } == null) throw IllegalStateException("stale_root_rotation")14041405            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1406                ?: throw IllegalStateException("recovery_escrow_failed")1407            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1408                ?: throw IllegalStateException("root_escrow_failed")1409            val plaintext = VaultCodec.encode(currentPayload())1410            val blob = try {1411                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1412            } finally {1413                VaultCrypto.zero(plaintext)1414            }1415            val nextSlot = priorSlot.copy(1416                blob = blob,1417                recoveryEscrow = nextRecoveryEscrow,1418                rootKeyEscrow = nextRootKeyEscrow,1419            )1420            val committed = withEpochCommit(expectedEpoch) {1421                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1422                try {1423                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1424                    // H-04: a root-key rotation re-seals slot A, so the duress slot1425                    // (real or fake) must be re-padded to the new ciphertext length.1426                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1427                    store.stampLocalMutation()1428                } catch (_: Exception) {1429                    store.writeSlotA(priorSlot)1430                    return@withEpochCommit false1431                }1432                true1433            } ?: false1434            if (!committed) throw IllegalStateException("storage_write_failed")1435            mutateIfCurrentEpoch(expectedEpoch) { publish() }1436            true1437        } catch (_: Exception) {1438            val restored = mutateIfCurrentEpoch(expectedEpoch) {1439                rootKey?.let(VaultCrypto::zero)1440                rootKey = snap.priorRootKey.copyOf()1441                recoveryWordHash = snap.priorRecoveryHash1442                recoveryWordsSet = snap.priorRecoverySet1443            }1444            if (restored != null) {1445                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1446            }1447            false1448        } finally {1449            VaultCrypto.zero(snap.key)1450            VaultCrypto.zero(snap.priorRootKey)1451            // On success nextRootKey is owned by rootKey. On every other path1452            // it is not, including a stale epoch after lock(); scrub it while1453            // the local reference is still available.1454            val stillOwnedBySession = mutateState {1455                sessionEpoch == expectedEpoch && rootKey === nextRootKey1456            }1457            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1458        }1459    }14601461    /**1462     * A copy of the live Argon2id-derived vault key, for biometric escrow only1463     * (wrapped under a hardware KeyStore key on biometric enable). Null when1464     * locked. Caller must [VaultCrypto.zero] it after use.1465     */1466    fun currentVaultKey(): ByteArray? = mutateState {1467        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1468    }14691470    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1471        val expectedEpoch = currentEpoch()1472        val slotA = store.loadSlotA() ?: return false1473        var oldKey: ByteArray? = null1474        var newKey: ByteArray? = null1475        var plaintext: ByteArray? = null1476        return try {1477            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1478            // Keep ownership of the decrypted vault plaintext until decoding has1479            // finished.  Passing decrypt() inline made the byte array unreachable1480            // before the finally block, so a successful or failed password change1481            // could leave the full plaintext resident in the heap.1482            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1483            val payload = try {1484                VaultCodec.decode(decrypted)1485            } finally {1486                VaultCrypto.zero(decrypted)1487            }1488            val newSalt = VaultCrypto.generateSalt()1489            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1490            // Re-seal the existing payload under the new key/salt.1491            plaintext = VaultCodec.encode(payload)1492            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1493            // The escrow seals the slot-A PLAINTEXT, which a password change does not1494            // alter — only the wrapping changes — so carry it forward; rebuilding the1495            // StoredVault from scratch would silently drop it.1496            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1497            // content that a password change does not alter — only the slot-A1498            // wrapping rotates. The root-key escrow staying stable across password1499            // changes matches the browser (`vault.ts:363-364`).1500            val next = StoredVault(1501                version = slotA.version,1502                salt = b64(newSalt),1503                blob = blob,1504                recoveryEscrow = slotA.recoveryEscrow,1505                rootKeyEscrow = slotA.rootKeyEscrow,1506            )1507            // H-01: durably commit slot A + size-matched noise + version stamp1508            // before swapping the session key. A partial commit (new A on disk,1509            // old key in memory) would have the next save encrypt under the old1510            // key while matching disk on the new one.1511            val priorB = store.loadSlotB()1512            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1513            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1514            // inside the same stateLock critical section, so a concurrent lock()1515            // can't observe the in-between (zeroed but not yet reassigned).1516            if (mutateIfCurrentEpoch(expectedEpoch) {1517                oldKey?.let(VaultCrypto::zero); oldKey = null1518                derivedKey?.let(VaultCrypto::zero)1519                derivedKey = newKey; newKey = null1520            } == null) return false1521            true1522        } catch (_: Exception) {1523            false1524        } finally {1525            oldKey?.let(VaultCrypto::zero)1526            newKey?.let(VaultCrypto::zero)1527            plaintext?.let(VaultCrypto::zero)1528        }1529    }15301531    /**1532     * Irreversible wipe. Locks the session and clears every vault key. The Pro1533     * license is in a separate store and is preserved by construction.1534     */1535    suspend fun destroyVault() = mutex.withLock {1536        lock()1537        store.clearVault()1538        store.clearDecoySlot()1539    }15401541    // ---- Entry CRUD -------------------------------------------------------15421543    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1544        val epoch = requireUnlocked()1545        val stamp = VaultCrypto.monotonicEntryTimestamp()1546        val now = System.currentTimeMillis()1547        val entry = withDerivedLast4(draft.copy(1548            id = draft.id.ifBlank { UUID.randomUUID().toString() },1549            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1550            modifiedAt = stamp,1551            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1552        ))1553        // H-03: capture rollback AND write entryList under stateLock so a1554        // concurrent lock() cannot zero the session between the read and the write.1555        val rollback = mutateCurrentSession(epoch) {1556            val r = entryList.toMutableList()1557            entryList.add(entry)1558            r1559        } ?: throw IllegalStateException("vault_locked")1560        persistOrRollback(rollback, epoch)1561        entry1562    }15631564    /**1565     * Insert many entries with a single encrypt-and-write at the end.1566     *1567     * Importing a real vault through [addEntry] was quadratic: every entry took1568     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1569     * restore did 806 full-vault writes and hung the app long enough to ANR.1570     * Rolls back to the pre-import list if the one write fails, matching1571     * [addEntry]'s all-or-nothing behaviour.1572     */1573    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1574        val epoch = requireUnlocked()1575        if (drafts.isEmpty()) return 01576        val now = System.currentTimeMillis()1577        val ready = drafts.map { draft ->1578            val stamp = VaultCrypto.monotonicEntryTimestamp()1579            withDerivedLast4(1580                draft.copy(1581                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1582                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1583                    modifiedAt = stamp,1584                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1585                ),1586            )1587        }1588        // H-03: capture rollback AND append every entry under stateLock.1589        val rollback = mutateCurrentSession(epoch) {1590            val r = entryList.toMutableList()1591            entryList.addAll(ready)1592            r1593        } ?: throw IllegalStateException("vault_locked")1594        persistOrRollback(rollback, epoch)1595        return drafts.size1596    }15971598    /**1599     * M-01: replace the whole active set with [newEntries] in one write.1600     *1601     * Import's REPLACE mode used to soft-delete every current entry one durable1602     * write at a time (swallowing each failure) and then batch-add, so a failed1603     * add left the old entries stranded in Trash with nothing restored. Staging1604     * the entire next list in memory and committing it through1605     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1606     * pre-import list — active entries included — is restored.1607     *1608     * Existing tombstones are preserved so paired devices still see the1609     * deletions they already know about.1610     */1611    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1612        val epoch = requireUnlocked()1613        val now = System.currentTimeMillis()1614        val ready = newEntries.map { draft ->1615            val stamp = VaultCrypto.monotonicEntryTimestamp()1616            withDerivedLast4(1617                draft.copy(1618                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1619                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1620                    modifiedAt = stamp,1621                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1622                ),1623            )1624        }1625        // M-03: an incoming entry that shares an id with an existing live entry1626        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1627        // the old one and then appending the new one would leave the list with1628        // two entries of the same id — the tombstoned one would be returned by1629        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1630        // superseded entries from the tombstone set instead.1631        val incomingIds = ready.map { it.id }.toHashSet()1632        val rollback = mutateCurrentSession(epoch) {1633            val r = entryList.toMutableList()1634            val replaced = entryList.map { entry ->1635                if (entry.deletedAt != null) return@map entry1636                if (entry.id in incomingIds) return@map null1637                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1638                entry.copy(deletedAt = stamp, modifiedAt = stamp)1639            }.filterNotNull()1640            entryList = (replaced + ready).toMutableList()1641            r1642        } ?: throw IllegalStateException("vault_locked")1643        persistOrRollback(rollback, epoch)1644        return ready.size1645    }16461647    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1648        val epoch = requireUnlocked()1649        // H-03: read entryList, compute the patched entry, and write it under a1650        // single stateLock so a concurrent lock() cannot interleave between the1651        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1652        // list, or clobber the entry onto the wrong id after a relock).1653        val prepared = mutateCurrentSession(epoch) {1654            val idx = entryList.indexOfFirst { it.id == patch.id }1655            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1656            val current = entryList[idx]1657            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1658            // Only refresh passwordChangedAt when the password value actually changes.1659            val passwordChanged = patch.password != null && patch.password != current.password1660            // Capture the outgoing password (newest first) so the user can reveal/copy/1661            // restore it later. History is computed authoritatively from `current`, not1662            // the patch — updateEntry is a full-entry replace, so trusting patch's value1663            // would let a stale form clobber the real history. Record the age the old1664            // password carried, skip an empty prior, and skip an immediate exact dupe1665            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1666            val priorPassword = current.password1667            val shouldCapture = passwordChanged &&1668                !priorPassword.isNullOrEmpty() &&1669                current.passwordHistory?.firstOrNull()?.password != priorPassword1670            val nextHistory = if (shouldCapture) {1671                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1672                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1673            } else {1674                current.passwordHistory1675            }1676            val updated = withDerivedLast4(patch.copy(1677                createdAt = current.createdAt,1678                modifiedAt = stamp,1679                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1680                passwordHistory = nextHistory,1681            ))1682            val rollback = entryList.toMutableList()1683            entryList[idx] = updated1684            updated to rollback1685        } ?: throw IllegalStateException("vault_locked")1686        val (updated, rollback) = prepared1687        persistOrRollback(rollback, epoch)1688        updated1689    }16901691    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1692    suspend fun deleteEntry(id: String) = mutex.withLock {1693        val epoch = requireUnlocked()1694        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1695        // the list out from under the indexOfFirst.1696        val rollback = mutateCurrentSession(epoch) {1697            val idx = entryList.indexOfFirst { it.id == id }1698            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1699            val current = entryList[idx]1700            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1701            val prior = entryList.toMutableList()1702            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1703            prior1704        } ?: throw IllegalStateException("vault_locked")1705        persistOrRollback(rollback, epoch)1706    }17071708    suspend fun restoreEntry(id: String) = mutex.withLock {1709        val epoch = requireUnlocked()1710        val rollback = mutateCurrentSession(epoch) {1711            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1712            if (idx == -1) throw IllegalStateException("deleted entry not found")1713            val current = entryList[idx]1714            val prior = entryList.toMutableList()1715            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1716            prior1717        } ?: throw IllegalStateException("vault_locked")1718        persistOrRollback(rollback, epoch)1719    }17201721    /** Permanent delete — no tombstone, no recovery. */1722    suspend fun purgeEntry(id: String) = mutex.withLock {1723        val epoch = requireUnlocked()1724        val rollback = mutateCurrentSession(epoch) {1725            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1726            if (idx == -1) throw IllegalStateException("deleted entry not found")1727            val prior = entryList.toMutableList()1728            entryList.removeAt(idx)1729            prior1730        } ?: throw IllegalStateException("vault_locked")1731        persistOrRollback(rollback, epoch)1732    }17331734    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1735    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1736        val epoch = requireUnlocked()1737        // H-03: read+remove under stateLock so lock() can't reorder the remove.1738        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1739        val r = mutateCurrentSession(epoch) {1740            val rollback = entryList.toMutableList()1741            val before = entryList.size1742            entryList.removeAll { it.deletedAt != null }1743            R(rollback, before - entryList.size)1744        } ?: throw IllegalStateException("vault_locked")1745        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1746        r.removed1747    }17481749    /** Browser parity: number of active entries that dedupe would move to trash. */1750    suspend fun countDuplicateEntries(): Int = mutex.withLock {1751        val epoch = requireUnlocked()1752        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1753        // lock() can't race the read with state writes.1754        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1755            ?: throw IllegalStateException("vault_locked")1756    }17571758    /**1759     * Browser parity: soft-delete duplicate entries, keeping the most recently1760     * modified member of each group. Refuse in decoy mode so the duress vault is1761     * not reshaped into a forensically distinct state.1762     */1763    suspend fun deduplicateEntries(): Int = mutex.withLock {1764        val epoch = requireUnlocked()1765        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1766        val ready = mutateCurrentSession(epoch) {1767            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1768            val active = entryList.filter { it.deletedAt == null }1769            val keepIds = mutableSetOf<String>()1770            active.groupBy { it.duplicateKey() }.values.forEach { group ->1771                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1772            }1773            val rollback = entryList.toMutableList()1774            var removed = 01775            entryList = entryList.map { entry ->1776                if (entry.deletedAt == null && entry.id !in keepIds) {1777                    removed++1778                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1779                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1780                } else {1781                    entry1782                }1783            }.toMutableList()1784            R(rollback, removed)1785        } ?: throw IllegalStateException("vault_locked")1786        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1787        ready.removed1788    }17891790    fun getEntries(): List<VaultEntry> = mutateState {1791        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1792    }1793    fun getEntry(id: String): VaultEntry? = mutateState {1794        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1795    }1796    fun getDeletedEntries(): List<VaultEntry> = mutateState {1797        if (!sessionDataAvailable) emptyList()1798        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1799    }18001801    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1802    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1803        return mutateState {1804            if (!sessionDataAvailable) return@mutateState emptyList()1805            val active = entryList.filter { it.deletedAt == null }1806            if (tagFilter == null) return@mutateState active1807            val needle = tagFilter.trim().lowercase()1808            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1809        }1810    }18111812    /** Sorted, de-duplicated tags across all active entries. */1813    fun getDistinctTags(): List<String> = mutateState {1814        if (!sessionDataAvailable) return@mutateState emptyList()1815        entryList.asSequence()1816            .filter { it.deletedAt == null }1817            .flatMap { it.tags?.asSequence() ?: emptySequence() }1818            .map { it.trim().lowercase() }1819            .filter { it.isNotEmpty() }1820            .distinct()1821            .sorted()1822            .toList()1823    }18241825    /**1826     * Distinct non-blank values for an identity field across active entries,1827     * most-frequently-used first. Supports "username" and "email"; unknown field1828     * names return an empty list. Values keep original casing (deduped1829     * case-insensitively) so a suggestion matches what the user typed.1830     */1831    fun getDistinctFieldValues(field: String): List<String> {1832        val select: (VaultEntry) -> String? = when (field) {1833            "username" -> { e -> e.username }1834            "email" -> { e -> e.email }1835            else -> return emptyList()1836        }1837        return mutateState {1838            if (!sessionDataAvailable) return@mutateState emptyList()1839            entryList.asSequence()1840                .filter { it.deletedAt == null }1841                .mapNotNull { select(it)?.trim() }1842                .filter { it.isNotEmpty() }1843                .groupingBy { it.lowercase() }1844                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1845                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1846                }1847                .values1848                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1849                .map { it.first }1850        }1851    }18521853    fun getTombstones(): List<Tombstone> = mutateState {1854        if (!sessionDataAvailable) emptyList()1855        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1856    }1857    fun getVaultVersion(): Long = store.version()1858    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18591860    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1861        val expectedEpoch = currentEpoch()1862        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1863        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1864        // H-04: snapshots now carry the duress slot too — restore preserves1865        // the at-rest triad (A/B/D), not just the A/B pair.1866        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1867        val priorA = store.loadSlotA()1868        val priorB = store.loadSlotB()1869        val priorD = store.loadDecoySlot()1870        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1871        // slot write — the old code took this snapshot, then stranded it on1872        // every failure, polluting history with a "restore" entry that never1873        // restored anything. The history below is rolled back together with1874        // the slots if the commit fails.1875        val priorHistory = store.loadVaultHistory()1876        // P1-4 (codex): the recovery snapshot must land before we commit the1877        // slot pair, otherwise a failed restore cannot be rolled back to the1878        // pre-restore history. If the history write itself fails, refuse to1879        // proceed — the alternative is a successful restore with no undo path.1880        beforeDurableCommit()1881        if (!takeVaultSnapshot("restore", expectedEpoch)) {1882            return false1883        }1884        // H-04: legacy snapshots without vault_d, or vault_d that fails to1885        // decode, fall back to a fake duress of slot A's size — presence-1886        // indistinguishable from any other freshly-restored vault.1887        val nextD: StoredVault = restoredD ?: run {1888            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161889            val fakeBlob = ByteArray(target).also(rng::nextBytes)1890            val fakeSalt = VaultCrypto.generateSalt()1891            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1892        }1893        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1894            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1895            return false1896        }1897        lock()1898        true1899    }19001901    /**1902     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1903     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1904     * (a restore preserves the duress-shaped blob from history), not a fresh1905     * noise record. Any failure rolls back so the at-rest pair never holds a1906     * restored slot A against a pre-restore slot B. Caller is responsible for1907     * restoring the pre-snapshot history on a false return.1908     *1909     * H-04: duress slot is now part of the atomic triad — restored last so1910     * a duress-write failure rolls the A/B pair back too.1911     */1912    private fun commitRestoreAtomic(1913        nextA: StoredVault,1914        nextB: String,1915        nextD: StoredVault,1916        priorA: StoredVault?,1917        priorB: String?,1918        priorD: StoredVault?,1919        expectedEpoch: Long,1920    ): Boolean {1921        return withEpochCommit(expectedEpoch) {1922            if (!store.writeSlotA(nextA)) return@withEpochCommit false1923            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1924            // must trigger rollback; previously a throw escaped without restoring1925            // the prior slot A and history.1926            try {1927                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1928                // H-04: duress slot is part of the restore triad. Failures here1929                // roll A+B back so the at-rest state stays consistent.1930                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1931                store.stampLocalMutation()1932            } catch (_: Exception) {1933                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1934                return@withEpochCommit false1935            }1936            true1937        } ?: false1938    }19391940    // ---- Sync apply (does NOT advance the version) ------------------------19411942    /**1943     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1944     * WITHOUT bumping the version counter, so a sync round cannot cascade into1945     * another outbound sync. Returns the number of locally-applied changes.1946     */1947    suspend fun applyMergedRemoteEntries(1948        remoteEntries: List<VaultEntry>,1949        remoteTombstones: List<Tombstone>,1950        expectedEpoch: Long? = null,1951    ): Int = mutex.withLock {1952        val epoch = expectedEpoch ?: requireUnlocked()1953        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01954        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1955        // BEFORE any comparison against a local one. Without this a peer can1956        // send a far-future modifiedAt and permanently pin its version of an1957        // entry against every honest local edit. The browser clamps in the1958        // matching place (vault.ts applyMergedRemoteEntries).1959        val mergeNow = System.currentTimeMillis()1960        val clamped = remoteEntries.map { entry ->1961            entry.copy(1962                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1963                deletedAt = entry.deletedAt?.let {1964                    SyncLimits.clampSyncTimestamp(it, mergeNow)1965                },1966            )1967        }1968        val tombMap = remoteTombstones.associate {1969            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1970        }1971        // H-03: build the new entryList under stateLock so a concurrent lock()1972        // cannot zero derivedKey/entryList between the byId read and the merge.1973        val applied = mutateCurrentSession(epoch) {1974            var n = 01975            val byId = entryList.associateBy { it.id }.toMutableMap()19761977            for (remote in clamped) {1978                val tomb = tombMap[remote.id]1979                if (tomb != null && tomb >= remote.modifiedAt) continue1980                val local = byId[remote.id]1981                if (local == null) {1982                    entryList.add(remote); byId[remote.id] = remote; n++1983                } else if (remote.modifiedAt > local.modifiedAt) {1984                    val i = entryList.indexOfFirst { it.id == local.id }1985                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1986                }1987            }19881989            for ((id, deletedAt) in tombMap) {1990                val local = byId[id] ?: continue1991                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1992                if (deletedAt < local.modifiedAt) continue1993                val i = entryList.indexOfFirst { it.id == id }1994                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1995            }1996            n1997        } ?: return@withLock 019981999        if (applied > 0) {2000            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2001            mutateIfCurrentEpoch(epoch) { publish() }2002        }2003        applied2004    }20052006    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2007        // Require a fully live session before exposing a settings mutation.  The2008        // epoch is captured with the read so a lock/new-session boundary cannot2009        // let stale work publish into the replacement session.2010        val epoch = requireUnlocked()2011        val prior = mutateCurrentSession(epoch) {2012            val old = _settings.value2013            _settings.value = settings2014            old2015        } ?: throw IllegalStateException("vault_locked")20162017        val committed = try {2018            beforeDurableCommit()2019            // The durable write is inside the same epoch fence as the in-memory2020            // publication.  If lock() won first, this returns null and no stale2021            // settings reach storage or the public StateFlow.2022            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2023        } catch (_: Exception) {2024            false2025        }2026        if (!committed) {2027            // A live-session storage failure rolls back the public value.  A2028            // stale epoch is already reset by lock()/the new session and must2029            // not be allowed to overwrite that safe state.2030            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2031            throw IllegalStateException("settings_write_failed")2032        }2033    }20342035    // ---- Recovery words (vault metadata) ----------------------------------20362037    /** True once the user has confirmed their recovery words for this vault. */2038    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20392040    /**2041     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2042     * Persisted without bumping the version — recovery metadata is local-only and2043     * is not an entry mutation paired devices should sync against.2044     */2045    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2046        val epoch = requireUnlocked()2047        // H-03: hash + escrow-related state fields set under stateLock so a2048        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2049        // M-04: the flags have to be live before the escrows are built — both seal2050        // currentPayload() — so capture the prior pair and put it back if the write2051        // never lands. Otherwise the session reports words set against a slot A2052        // that still holds no escrow, and the recovery screen never offers a retry.2053        val prior = mutateCurrentSession(epoch) {2054            val priorPair = recoveryWordHash to recoveryWordsSet2055            recoveryWordHash = recoveryWordsHash(words)2056            recoveryWordsSet = true2057            priorPair2058        } ?: return@withLock false2059        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2060        // (cross-platform recovery of the sync root key) and Android's whole-payload2061        // escrow (offline forgot-password restore of entries). Both derive their key2062        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2063        val persisted = runCatching {2064            persistSlotAWithEscrows(2065                buildRecoveryEscrow(words),2066                buildRootKeyEscrow(words),2067                expectedEpoch = epoch,2068            )2069        }.getOrDefault(false)2070        if (!persisted) {2071            mutateIfCurrentEpoch(epoch) {2072                recoveryWordHash = prior.first2073                recoveryWordsSet = prior.second2074            }2075        }2076        persisted2077    }20782079    /** Verify entered words against the stored recovery hash. */2080    fun verifyRecoveryWords(words: List<String>): Boolean {2081        return mutateState {2082            val stored = recoveryWordHash ?: return@mutateState false2083            stored == recoveryWordsHash(words)2084        }2085    }20862087    // ---- Recovery escrow (forgot-password) --------------------------------20882089    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2090    fun hasRecoveryEscrow(): Boolean =2091        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20922093    /**2094     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2095     * the master password. On success the slot-A plaintext sealed in the escrow is2096     * adopted as the live session (entries, rootKey, settings), but no Argon2id2097     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2098     * new password and finish unlocking. Returns false when there is no escrow, the2099     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2100     *2101     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2102     * detached vault key to return here — slot A is wrapped under the rotating2103     * Argon2id key — so this opens the escrowed payload directly.2104     */2105    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2106        val expectedEpoch = currentEpoch()2107        val slotA = store.loadSlotA() ?: return false2108        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21092110        // Prefer Android's whole-payload escrow: a full offline restore of entries,2111        // root key, and settings. Present for Android-created vaults.2112        val fullEscrow = slotA.recoveryEscrow2113        if (fullEscrow != null) {2114            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2115            if (material != null) {2116                var keyBytes: ByteArray? = null2117                var plaintext: ByteArray? = null2118                var candidateRoot: ByteArray? = null2119                var stateCommitStarted = false2120                try {2121                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2122                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2123                    val payload = VaultCodec.decode(plaintext)2124                    // Stage every fallible operation before touching live session2125                    // fields.  In particular, a settings/provider/decode failure2126                    // cannot leave entries, flags, or a root key resident while the2127                    // public session remains locked.2128                    val candidateSettings = store.loadSettings()2129                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2130                        val decodedRoot = unb64(payload.rootKeyB64)2131                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2132                            // Do not let a malformed authenticated payload abandon2133                            // this decoded root-key candidate before the outer2134                            // finally block owns it.2135                            VaultCrypto.zero(decodedRoot)2136                            throw IllegalArgumentException("invalid recovery root key")2137                        }2138                        decodedRoot2139                    } else {2140                        generateRecoverableRootKey()2141                    }2142                    stateCommitStarted = true2143                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2144                        clearSessionStateLocked()2145                        entryList = payload.entries.toMutableList()2146                        duressConfigured = payload.duressConfigured2147                        recoveryWordHash = payload.recoveryWordHash2148                        recoveryWordsSet = payload.recoveryWordsSet2149                        rootKey = candidateRoot2150                        candidateRoot = null2151                        _settings.value = candidateSettings2152                        publish()2153                        // This is the authenticated recovery half-unlock boundary:2154                        // only set it after every candidate field is installed.2155                        sessionDataAvailable = true2156                        _isUnlocked.value = false2157                        true2158                    }2159                    if (adopted == true) return@withLock true2160                } catch (_: Exception) {2161                    // Wrong words, malformed payloads, provider failures, and2162                    // settings failures all fall through to the browser-compatible2163                    // root-key escrow.  If the state commit had started, invalidate2164                    // that epoch so no partial candidate survives.2165                    if (stateCommitStarted) {2166                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2167                    }2168                } finally {2169                    plaintext?.let(VaultCrypto::zero)2170                    VaultCrypto.zero(material)2171                    keyBytes?.let(VaultCrypto::zero)2172                    candidateRoot?.let(VaultCrypto::zero)2173                }2174            }2175        }21762177        // Browser-format vault (only the root-key escrow): recover the 32-byte root2178        // key (sync identity) and reset the password; entries return via sync. This2179        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2180        val rootEscrow = slotA.rootKeyEscrow2181        if (rootEscrow != null) {2182            var candidateRoot: ByteArray? = null2183            var stateCommitStarted = false2184            try {2185                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2186                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2187                val candidateSettings = store.loadSettings()2188                stateCommitStarted = true2189                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2190                    clearSessionStateLocked()2191                    entryList = mutableListOf()2192                    duressConfigured = false2193                    recoveryWordHash = recoveryWordsHash(words)2194                    recoveryWordsSet = true2195                    rootKey = candidateRoot2196                    candidateRoot = null2197                    _settings.value = candidateSettings2198                    publish()2199                    sessionDataAvailable = true2200                    _isUnlocked.value = false2201                    true2202                }2203                if (adopted == true) return@withLock true2204            } catch (_: Exception) {2205                if (stateCommitStarted) {2206                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2207                }2208            } finally {2209                candidateRoot?.let(VaultCrypto::zero)2210            }2211        }2212        return false2213    }22142215    /**2216     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2217     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2218     * words, mark the session fully unlocked, and publish. Returns false if called2219     * without a prior successful recovery unlock (no in-memory root key).2220     *2221     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2222     * into one step, since there is no detached vault key here.2223     */2224    suspend fun resetPasswordWithRecoveryWords(2225        words: List<String>,2226        newPassword: String,2227    ): Boolean = mutex.withLock {2228        val expectedEpoch = currentEpoch()2229        // H-03: read rootKey + entryList under stateLock before computing the new2230        // plaintext, then re-publish + swap the derived key under stateLock so a2231        // concurrent lock() cannot straddle the two fences.2232        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2233        val priorA = store.loadSlotA() ?: return false2234        var newKey: ByteArray? = null2235        var plaintext: ByteArray? = null2236        return try {2237            val newSalt = VaultCrypto.generateSalt()2238            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2239            plaintext = VaultCodec.encode(currentPayload())2240            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2241            // Rebuild both escrows against the recovered session so their salts rotate2242            // with the password reset; fall back to the prior escrows if rebuild fails2243            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2244            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2245            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2246            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2247            val next = StoredVault(2248                version = priorA.version,2249                salt = b64(newSalt),2250                blob = blob,2251                recoveryEscrow = freshRecovery,2252                rootKeyEscrow = freshRootKey,2253            )2254            // H-01: see changePassword — both rotations share the same2255            // commit-or-keep-old-key contract so a partial write can't strand2256            // the session on a key that no longer matches disk.2257            val priorB = store.loadSlotB()2258            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2259            // H-03 (P1-2 codex): zero the old live key and install the new one2260            // inside the same stateLock critical section so a concurrent lock()2261            // can't observe the in-between.2262            if (mutateIfCurrentEpoch(expectedEpoch) {2263                derivedKey?.let(VaultCrypto::zero)2264                derivedKey = newKey; newKey = null2265                publish()2266                _isUnlocked.value = true2267            } == null) return false2268            true2269        } catch (_: Exception) {2270            false2271        } finally {2272            newKey?.let(VaultCrypto::zero)2273            plaintext?.let(VaultCrypto::zero)2274        }2275    }22762277    /**2278     * Seal the current slot-A plaintext under a key derived from the 12 BIP392279     * recovery [words] (Android offline-restore escrow). IKM is the browser's2280     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2281     * Returns null for a checksum-invalid phrase.2282     */2283    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2284        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2285        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2286        var keyBytes: ByteArray? = null2287        var plaintext: ByteArray? = null2288        return try {2289            val salt = VaultCrypto.generateSalt()2290            val recoveryKey = deriveRecoveryKey(material, salt)2291            keyBytes = recoveryKey.encoded2292            plaintext = VaultCodec.encode(currentPayload())2293            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2294            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2295        } finally {2296            VaultCrypto.zero(material)2297            keyBytes?.let(VaultCrypto::zero)2298            plaintext?.let(VaultCrypto::zero)2299        }2300    }23012302    /**2303     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2304     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2305     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2306     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2307     * the same root key on the browser. Returns null with no in-memory root key or2308     * a checksum-invalid phrase.2309     */2310    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2311        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2312        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2313        val rk = mutateState { rootKey?.copyOf() } ?: run {2314            VaultCrypto.zero(material)2315            return null2316        }2317        var keyBytes: ByteArray? = null2318        var plaintext: ByteArray? = null2319        return try {2320            val salt = VaultCrypto.generateSalt()2321            val recoveryKey = deriveRecoveryKey(material, salt)2322            keyBytes = recoveryKey.encoded2323            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2324            // combined = nonce(12) || ciphertext || tag(16)2325            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2326            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2327            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2328            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2329            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2330            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2331        } finally {2332            VaultCrypto.zero(rk)2333            VaultCrypto.zero(material)2334            keyBytes?.let(VaultCrypto::zero)2335            plaintext?.let(VaultCrypto::zero)2336        }2337    }23382339    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2340    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2341        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2342        var keyBytes: ByteArray? = null2343        var pt: ByteArray? = null2344        return try {2345            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2346            keyBytes = recoveryKey.encoded2347            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2348            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2349            hexToRootKey(String(pt, Charsets.US_ASCII))2350        } catch (_: Exception) {2351            null2352        } finally {2353            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2354            // IKM-equivalent of the vault identity. material+keyBytes were2355            // already zeroed; pt was leaking.2356            VaultCrypto.zero(material)2357            keyBytes?.let(VaultCrypto::zero)2358            pt?.let(VaultCrypto::zero)2359        }2360    }23612362    /**2363     * Re-seal slot A from the current in-memory state under the live key, carrying2364     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2365     * in decoy mode (recovery is never offered for the decoy) and persists without2366     * a version bump, like the recovery-words metadata write it replaces.2367     */2368    private fun persistSlotAWithEscrows(2369        recoveryEscrow: RecoveryEscrow?,2370        rootKeyEscrow: RootKeyEscrow?,2371        expectedEpoch: Long? = null,2372    ): Boolean {2373        val snapshot = snapshotForPersist(expectedEpoch)2374            ?: throw IllegalStateException("vault_locked_or_stale")2375        if (snapshot.decoy) {2376            VaultCrypto.zero(snapshot.key)2377            return false2378        }2379        try {2380            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2381            val plaintext = VaultCodec.encode(snapshot.payload)2382            val blob = try {2383                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2384            } finally {2385                // The escrow path used to drop this reference before zeroing it;2386                // keep it live through the fill on both success and failure.2387                VaultCrypto.zero(plaintext)2388            }2389            val next = prior.copy(2390                blob = blob,2391                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2392                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2393            )2394            beforeDurableCommit()2395            return withEpochCommit(snapshot.epoch) {2396                if (!store.writeSlotA(next)) return@withEpochCommit false2397                try {2398                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2399                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2400                    // the duress slot too. A real decoy is left alone; a fake is2401                    // re-sized to slot A's new ciphertext length.2402                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2403                } catch (e: Exception) {2404                    store.writeSlotA(prior)2405                    throw e2406                }2407                true2408            } ?: false2409        } finally {2410            VaultCrypto.zero(snapshot.key)2411        }2412    }24132414    // ---- Internals --------------------------------------------------------24152416    private fun requireUnlocked(): Long {2417        // H-03: read derivedKey atomically with [lock]'s reset; without it,2418        // [lock] from a background lifecycle event can run between this read2419        // and the caller's first state touch.2420        stateLock.lock()2421        try {2422            if (derivedKey == null) throw IllegalStateException("vault_locked")2423            return sessionEpoch2424        } finally {2425            stateLock.unlock()2426        }2427    }24282429    /** A locked or half-adopted session must not expose secret-bearing fields. */2430    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24312432    private fun countDuplicateEntriesLocked(): Int {2433        val seen = mutableMapOf<String, VaultEntry>()2434        var duplicates = 02435        for (entry in entryList.filter { it.deletedAt == null }) {2436            val key = entry.duplicateKey()2437            val existing = seen[key]2438            if (existing == null) {2439                seen[key] = entry2440            } else {2441                duplicates++2442                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2443                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2444                if (entryStamp > existingStamp) seen[key] = entry2445            }2446        }2447        return duplicates2448    }24492450    private fun VaultEntry.duplicateKey(): String {2451        if (type == "note") return "note|${title.lowercase()}"2452        val passkeyData = passkey2453        if (passkeyData != null) {2454            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2455        }2456        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2457    }24582459    /**2460     * Capture the current slot pair (and metadata) into the history list.2461     * Returns true when the history write landed, false when it didn't — callers2462     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2463     * must check the result and roll back if it does not.2464     */2465    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2466        val epoch = expectedEpoch ?: currentEpoch()2467        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2468        val slotA = store.loadSlotA() ?: return false2469        val vaultA = VaultCodec.encodeSlot(slotA)2470        val vaultB = store.loadSlotB() ?: slotA.blob2471        // H-04: capture the duress slot so a restore preserves the at-rest2472        // triad, not just the A/B pair. The duress slot is always present2473        // (real or fake), so loadDecoySlot() returning null is a logic error2474        // — fall through to a fake duress in that case rather than silently2475        // losing the record from the snapshot.2476        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2477            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162478            val fakeBlob = ByteArray(target).also(rng::nextBytes)2479            val fakeSalt = VaultCrypto.generateSalt()2480            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2481        }2482        val history = store.loadVaultHistory()2483        val latest = history.snapshots.firstOrNull()2484        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2485        val entryCount = mutateIfCurrentEpoch(epoch) {2486            if (derivedKey != null && !decoyMode) {2487                bucketEntryCount(entryList.count { it.deletedAt == null })2488            } else {2489                null2490            }2491        } ?: return false2492        return try {2493            val snapshot = VaultSnapshot(2494                id = UUID.randomUUID().toString(),2495                savedAt = System.currentTimeMillis(),2496                reason = reason,2497                vault_a = vaultA,2498                vault_b = vaultB,2499                vault_d = vaultD,2500                entryCount = entryCount,2501            )2502            // P1-4 (codex): return the history-write boolean to callers so a2503            // false return no longer leaves the snapshot out of history while2504            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2505            withEpochCommit(epoch) {2506                store.writeVaultHistory(2507                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2508                )2509            } ?: false2510        } catch (_: Exception) {2511            false2512        }2513    }25142515    private fun bucketEntryCount(n: Int): Int = when {2516        n <= 0 -> 02517        n <= 10 -> 102518        n <= 25 -> 252519        n <= 50 -> 502520        n <= 100 -> 1002521        n <= 250 -> 2502522        n <= 500 -> 5002523        else -> ((n + 499) / 500) * 5002524    }25252526    /**2527     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2528     * so it is stored explicitly and survives the sync wire format rather than being2529     * re-derived at render time. Non-card entries pass through unchanged.2530     *2531     * M-03: never overwrite a stored last4 with null. A card kept only as last42532     * (e.g. after a PeachScript import that had no PAN) must keep that value;2533     * re-deriving from an absent cardNumber would silently zero the field.2534     */2535    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2536        if (!entry.isCard()) return entry2537        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2538        val derived = if (digits.length >= 4) digits.takeLast(4) else null2539        return entry.copy(last4 = derived ?: entry.last4)2540    }25412542    /**2543     * On unlock, hard-delete trashed entries older than the retention window.2544     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2545     * (the app's "Never" option), so nothing is purged. Persists without a2546     * version bump and without touching the publish state (caller publishes).2547     */2548    private fun autoPurgeExpiredTrash() {2549        val days = _settings.value.trashRetentionDays ?: return2550        if (days <= 0) return2551        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2552        val before = entryList.size2553        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2554        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2555    }25562557    /** The slot-A plaintext envelope for the current in-memory session. */2558    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25592560    private fun currentPayloadLocked(): VaultPayload =2561        VaultPayload(2562            entries = entryList.toList(),2563            duressConfigured = duressConfigured,2564            rootKeyB64 = rootKey?.let(::b64) ?: "",2565            recoveryWordHash = recoveryWordHash,2566            recoveryWordsSet = recoveryWordsSet,2567        )25682569    /**2570     * Adopt the payload's root key, lazily minting one for legacy payloads.2571     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2572     * a root key is not a mutation paired devices should sync against.2573     */2574    private fun ensureRootKey(payload: VaultPayload) {2575        if (payload.rootKeyB64.isNotBlank()) {2576            rootKey = unb64(payload.rootKeyB64)2577            return2578        }2579        rootKey = generateRecoverableRootKey()2580        runCatching { persistSlotA(bumpVersion = false) }2581    }25822583    private fun publish() {2584        _entries.value = entryList.filter { it.deletedAt == null }2585    }25862587    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2588        var plaintext = VaultCodec.encode(currentPayload())2589        return try {2590            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2591            StoredVault(version = 1, salt = b64(salt), blob = blob)2592        } finally {2593            // M-05: full vault plaintext is allocated here once per createVault.2594            // Zeroing in finally keeps the transient copy off the heap after the2595            // caller either succeeds or rolls back via lock().2596            VaultCrypto.zero(plaintext)2597        }2598    }25992600    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2601        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2602        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2603        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2604        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2605        val noise = ByteArray(approxBytes).also(rng::nextBytes)2606        return store.writeSlotB(b64(noise))2607    }26082609    /** H-04: the ciphertext length slot A is using right now, so the duress2610     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2611     *  length equals ciphertext length, so this also matches slot A's plaintext. */2612    private fun matchingCiphertextLength(slotA: StoredVault): Int =2613        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26142615    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2616     *  bytes. Replaces a real decoy when the user clears duress, and is the2617     *  initial duress slot when no decoy is configured — so the record's2618     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2619     *  matches slot A's blob length. */2620    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2621        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162622        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2623        val fakeSalt = VaultCrypto.generateSalt()2624        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2625    }26262627    /** H-04: keep the duress record presence- and size-indistinguishable from2628     *  slot A on every persist. A real decoy is left alone (it was padded at2629     *  setDuressPassphrase time, and we don't have the duress key here to2630     *  re-pad on entry growth — that's an accepted residual tell, see the2631     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2632     *  A's size. */2633    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2634        val isConfigured = mutateState { duressConfigured }2635        return if (isConfigured) true else writeFakeDuressSlot(slotA)2636    }26372638    /**2639     * H-04: convert a browser-shaped duress slot to Android StoredVault2640     * without decrypting. The browser seals slot "b" under the duress2641     * passphrase; decrypting with the main password silently drops it.2642     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2643     * the salt from hex to base64.2644     */2645    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2646        val salt = unhex(b.salt)2647        val nonce = unhex(b.nonce)2648        val ct = unhex(b.ciphertext)2649        val tag = unhex(b.tag)2650        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2651            System.arraycopy(nonce, 0, it, 0, nonce.size)2652            System.arraycopy(ct, 0, it, nonce.size, ct.size)2653            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2654        }2655        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2656    }26572658    /** Re-seal slot A from the current in-memory state under the live key. */2659    private fun persistSlotA(2660        bumpVersion: Boolean,2661        snapshotReason: String? = "local_save",2662        expectedEpoch: Long? = null,2663    ): Boolean {2664        val snapshot = snapshotForPersist(expectedEpoch)2665            ?: throw IllegalStateException("vault_locked_or_stale")2666        return try {2667            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2668            if (snapshot.decoy) {2669                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2670                val plaintext = VaultCodec.encode(snapshot.payload)2671                val blob = try {2672                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2673                } finally {2674                    // Keep the last reference alive until the zero, including the2675                    // successful encrypt path (M-05).2676                    VaultCrypto.zero(plaintext)2677                }2678                beforeDurableCommit()2679                return withEpochCommit(snapshot.epoch) {2680                    store.writeDecoySlot(prior.copy(blob = blob))2681                } ?: false2682            }2683            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2684            // H-01: captured BEFORE the noise write so a stamp failure can restore2685            // the prior slot B alongside the prior slot A. Slot B is always2686            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2687            // leaves (new A, new B, old version) on disk — partial commit.2688            val priorB = store.loadSlotB()2689            beforeDurableCommit()2690            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2691            val plaintext = VaultCodec.encode(snapshot.payload)2692            val blob = try {2693                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2694            } finally {2695                // Do not drop [plaintext] before fill(0): the serialized vault2696                // contains every entry password and is the primary M-05 target.2697                VaultCrypto.zero(plaintext)2698            }2699            val next = prior.copy(blob = blob)2700            // The epoch check and the entire A/B/D/version commit share one brief2701            // stateLock critical section. lock() either wins before this block or2702            // waits until the already-committed write is complete; it can never2703            // clear the session between the check and the durable write.2704            withEpochCommit(snapshot.epoch) {2705                if (!store.writeSlotA(next)) return@withEpochCommit false2706                try {2707                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2708                    // H-04: duress slot stays present (real one untouched, fake one2709                    // re-padded to slot A's current size on every persist).2710                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2711                    if (bumpVersion) store.stampLocalMutation()2712                } catch (e: Exception) {2713                    store.writeSlotA(prior)2714                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2715                    throw e2716                }2717                true2718            } ?: false2719        } finally {2720            // Snapshot keys are copies, so lock() may zero the live key while this2721            // operation is still deriving/encrypting; always scrub our copy too.2722            VaultCrypto.zero(snapshot.key)2723        }2724    }27252726    /**2727     * H-01: durably commit a slot-A rotation by writing slot A, its2728     * size-matched noise slot B, and the local-mutation stamp in order. Any2729     * failure rolls the previously-committed steps back so the at-rest pair2730     * matches what was there before the call. Returns true only when every2731     * step succeeded — callers must NOT swap their session key on false.2732     */2733    private fun commitVaultRotation(2734        priorA: StoredVault,2735        priorB: String?,2736        nextA: StoredVault,2737        expectedEpoch: Long,2738    ): Boolean {2739        beforeDurableCommit()2740        return withEpochCommit(expectedEpoch) {2741            if (!store.writeSlotA(nextA)) return@withEpochCommit false2742            // P1-1 (codex): the noise write's boolean AND any thrown exception from2743            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2744            // escaped the helper and left slot A committed.2745            try {2746                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2747                // H-04: a password rotation changes slot A's plaintext size (new2748                // salt → re-encrypted blob), so the duress slot must be re-padded2749                // alongside. writeCompanionDuressSlot leaves a real decoy alone2750                // and re-emits a fake of slot A's size otherwise.2751                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2752                store.stampLocalMutation()2753            } catch (_: Exception) {2754                rollbackRotation(priorA, priorB)2755                return@withEpochCommit false2756            }2757            true2758        } ?: false2759    }27602761    /**2762     * Best-effort undo of the writes [commitVaultRotation] may have committed.2763     * Each step runs independently — a failed rollback must not mask the2764     * original commit failure. Log is a throwing stub off-device.2765     */2766    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2767        val failures = mutableListOf<String>()2768        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2769            .onFailure { failures += "writeSlotA: $it" }2770        if (priorB != null) {2771            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2772                .onFailure { failures += "writeSlotB: $it" }2773        }2774        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2775    }27762777    /** Persist a local mutation, rolling the in-memory list back on failure. */2778    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2779        try {2780            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2781                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2782                throw IllegalStateException("storage_write_failed")2783            }2784        } catch (e: Exception) {2785            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2786            throw e2787        }2788        // A lock can win in the small interval after the durable commit and2789        // before publication. The epoch check prevents pre-lock entries from2790        // being republished into a newly locked (or newly unlocked) session.2791        mutateIfCurrentEpoch(expectedEpoch) { publish() }2792    }2793}