VaultRepository.kt

133.5 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 }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)3132/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */33private const val PASSWORD_HISTORY_MAX = 5034private const val VAULT_HISTORY_MAX = 53536// How long the transient-background-lock suppression stays valid. If the37// launched system Activity never reports the expected lifecycle stop (picker38// crashed, user backed out into another app, OEM killed the picker process),39// the flag expires and the next genuine background event locks the vault40// rather than consuming a stale suppression.41private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4243sealed interface UnlockResult {44    data object Success : UnlockResult45    data object WrongPassword : UnlockResult46    data class Error(val reason: String) : UnlockResult47}4849sealed interface CreateVaultResult {50    data object Success : CreateVaultResult51    data class Error(val reason: String) : CreateVaultResult52}5354sealed interface AdoptVaultResult {55    data class Success(val vaultId: String) : AdoptVaultResult56    data class Error(val reason: String) : AdoptVaultResult57}5859/**60 * In-memory vault session backed by [VaultStore]. Faithful port of the browser61 * `vault.ts` semantics that matter for correctness:62 *63 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched64 *    noise blob so a fresh install shows two similarly-shaped records.65 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a66 *    deletion always wins last-write-wins against an in-flight edit.67 *  - Local writes bump the version counter; sync-applied writes do NOT (so a68 *    sync round never cascades into another outbound sync).69 *  - add/update/delete roll back in-memory state if the disk write fails.70 *  - destroyVault never touches the Pro license (separate store).71 */72@Singleton73class VaultRepository @Inject constructor(74    private val store: VaultStorage,75) {76    private val mutex = Mutex()77    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend78    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync79    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so80    // lock() can serialize against an in-flight suspend op without blocking on81    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and82    // take [stateLock] only around the state-write portions. Acquisition order83    // is always mutex → stateLock.84    private val stateLock = ReentrantLock()85    private val rng = SecureRandom()8687    /**88     * Monotonic session generation.  [lock] advances this before clearing any89     * secret state; work that captured the previous generation is therefore90     * stale even if it is still running its (non-suspending) storage step.91     */92    private var sessionEpoch: Long = 0L9394    private fun nextSessionEpoch(epoch: Long): Long =95        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9697    /** Test seam for deterministic "lock while waiting to commit" races. */98    @VisibleForTesting99    internal var beforeDurableCommit: () -> Unit = {}100101    /** Test seam after a failed Codex rollback, while the epoch fence is held. */102    @VisibleForTesting103    internal var afterCodexRollback: () -> Unit = {}104105    /**106     * Run [block] with [stateLock] held. Used to fence the small state-write107     * portions of suspend operations against [lock] and [requireUnlocked]108     * racing from another thread.109     */110    private inline fun <T> mutateState(block: () -> T): T {111        stateLock.lock()112        return try { block() } finally { stateLock.unlock() }113    }114115    /** Run a durable commit only while the session generation is still live. */116    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {117        stateLock.lock()118        return try {119            if (sessionEpoch != epoch) null else block()120        } finally {121            stateLock.unlock()122        }123    }124125    /** Test seam for a caller that must stage work before the epoch fence. */126    @VisibleForTesting127    internal var beforeSessionEpochCommit: () -> Unit = {}128129    /**130     * Test seam for the narrower H-03 race: a suspend mutation has authenticated131     * the session and staged its candidate state, but has not yet entered the132     * epoch-fenced in-memory write. Production callers leave this as a no-op.133     */134    @VisibleForTesting135    internal var beforeSessionStateMutation: () -> Unit = {}136137    /**138     * Run an external session-bound commit while the repository's epoch fence is139     * held. The callback is deliberately non-suspending: [lock] cannot advance140     * the epoch until the whole external batch has reached its linearization141     * point.142     */143    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {144        beforeSessionEpochCommit()145        return withEpochCommit(epoch, block)146    }147148    private fun currentEpoch(): Long = mutateState { sessionEpoch }149150    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =151        mutateState { if (sessionEpoch == epoch) block() else null }152153    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */154    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {155        beforeSessionStateMutation()156        return mutateIfCurrentEpoch(epoch, block)157    }158159    private data class PersistSnapshot(160        val epoch: Long,161        val key: ByteArray,162        val payload: VaultPayload,163        val decoy: Boolean,164    )165166    /** Copy all session material while fenced; callers own and must zero [key]. */167    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {168        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null169        val liveKey = derivedKey ?: return@mutateState null170        PersistSnapshot(171            epoch = sessionEpoch,172            key = liveKey.copyOf(),173            payload = currentPayloadLocked(),174            decoy = decoyMode,175        )176    }177178    private var derivedKey: ByteArray? = null179    private var rootKey: ByteArray? = null180    private var entryList: MutableList<VaultEntry> = mutableListOf()181    private var duressConfigured: Boolean = false182    private var recoveryWordHash: String? = null183    private var recoveryWordsSet: Boolean = false184    /** True when an authenticated vault payload is available for local reads. */185    private var sessionDataAvailable: Boolean = false186    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */187    private var decoyMode: Boolean = false188189    private val _isUnlocked = MutableStateFlow(false)190    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()191192    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())193    /** Active (non-deleted) entryList. */194    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()195196    private val _settings = MutableStateFlow(VaultSettings())197    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()198199    /** Clear every in-memory session field while [stateLock] is held. */200    private fun clearSessionStateLocked() {201        derivedKey?.let(VaultCrypto::zero)202        rootKey?.let(VaultCrypto::zero)203        derivedKey = null204        rootKey = null205        entryList = mutableListOf()206        duressConfigured = false207        recoveryWordHash = null208        recoveryWordsSet = false209        sessionDataAvailable = false210        decoyMode = false211        _settings.value = VaultSettings()212        _entries.value = emptyList()213        _isUnlocked.value = false214    }215216    fun hasVault(): Boolean = store.hasVault()217218    // ---- Codex (printed paper backup) -------------------------------------219220    /**221     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the222     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's223     * A/B naming where slot B is the duress decoy); the noise slot is local-only224     * and never crosses devices. Restorable on any device that has the master225     * password. Null when no vault exists.226     */227    fun codexVaultBlobBase64(): String? {228        val slotA = store.loadSlotA() ?: return null229        // H-04: duress slot is always present after createVault (real or fake),230        // so the wrapper can rely on it being loadable.231        val slotD = store.loadDecoySlot() ?: return null232        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""233        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))234    }235236    /**237     * Replace the local vault from a Codex blob, then unlock with [password].238     * Writes slot A (and a size-matched noise slot B) before unlocking so the239     * restored vault is at rest exactly as a freshly-created one would be.240     */241    /** A slot is stored either as a JSON string (browser) or inline object (Android). */242    private fun JsonElement?.asSlotJson(): String? = when {243        this == null || this is JsonNull -> null244        this is JsonPrimitive && isString -> content245        else -> toString()246    }247248    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {249        val expectedEpoch = currentEpoch()250        var candidateKey: ByteArray? = null251        var plaintext: ByteArray? = null252        return try {253            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)254            // Parsed as JSON, not matched with a regex. The wrapper is255            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which256            // generates most real Codexes — writes each slot as a JSON257            // *string*, with "b" present. A regex looking for an object258            // literal under "a" at the very end of the wrapper matches259            // neither of those, so every browser-generated Codex failed here260            // with "invalid_codex_blob" after decoding perfectly.261            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject262                ?: return UnlockResult.Error("invalid_codex_blob")263            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")264            // A Codex printed from the browser carries a browser-shaped slot,265            // which is the common case — most Codexes are printed there. It is266            // translated to Android's shape (see BrowserSlot) rather than267            // rejected. A translation failure is a wrong password, not a bad268            // blob: the codes decoded fine, the password just didn't open them.269            val slotA = VaultCodec.decodeSlot(slotJson)270                ?: BrowserSlot.parse(slotJson)?.let {271                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword272                }273                ?: return UnlockResult.Error("invalid_codex_blob")274            // C-01: authenticate the candidate ENTIRELY in memory before touching275            // storage. VaultCodec.decodeSlot is structural — an Android-shaped276            // Codex parses under ANY password — so writing slot A first and277            // unlocking afterwards destroyed the device's existing vault every278            // time the user mistyped the password.279            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))280            plaintext = decryptSlotABlob(slotA, candidateKey)281            // LV-1: a Codex printed from a vault sealed before NFC normalization282            // opens only under the raw password bytes. unlockLocked used to supply283            // this fallback; authenticating up front here has to keep it, or those284            // Codexes become unrestorable.285            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {286                VaultCrypto.zero(candidateKey)287                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))288                plaintext = decryptSlotABlob(slotA, candidateKey)289            }290            if (plaintext == null) return UnlockResult.WrongPassword291            val payload = try {292                VaultCodec.decode(plaintext)293            } catch (_: Exception) {294                return UnlockResult.Error("invalid_codex_blob")295            }296297            val priorA = store.loadSlotA()298            val priorB = store.loadSlotB()299            val priorD = store.loadDecoySlot()300            // Carry the real duress slot across when the Codex has one — the301            // browser writes "b" as its duress-shaped slot, and dropping it302            // here would silently destroy a configured decoy.303            // H-04: write browser B into the duress slot (not the noise slot),304            // translating browser-shaped slots to Android's StoredVault shape.305            val codexDuressJson = wrapper["b"].asSlotJson()306            val codexDuress: StoredVault? = codexDuressJson?.let {307                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->308                    // H-04: pass-through — the browser duress slot is sealed309                    // under the duress passphrase, not the main password. Trying310                    // to decrypt with the slot-A password fails the GCM tag and311                    // silently drops a real decoy. Re-encode the browser hex312                    // fields as Android base64 salt/blob so tryUnlockDuress can313                    // still open it with the duress passphrase.314                    browserDuressPassthrough(b)315                }316            }317318            beforeDurableCommit()319            val committed = runCatching {320                withEpochCommit(expectedEpoch) {321                    try {322                        // Keep every slot write, including the inverse, under the323                        // same epoch fence. Releasing stateLock after a partial324                        // write and reacquiring it for rollback lets lock() advance325                        // the epoch in between and strand the incoming slot A.326                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")327                        // Noise slot is always size-matched to slot A — local-only,328                        // never written from a Codex.329                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")330                        // H-04: duress slot always present; real one from the Codex or331                        // a fake the size of slot A.332                        val duressOk = if (codexDuress != null) {333                            store.writeDecoySlot(codexDuress)334                        } else {335                            writeFakeDuressSlot(slotA)336                        }337                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")338                        store.stampLocalMutation()339                        true340                    } catch (_: Exception) {341                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))342                        // Keep the fence held through the final rollback343                        // observation. A concurrent lock() may queue here, but344                        // cannot invalidate the restored A/B/D triad.345                        afterCodexRollback()346                        false347                    }348                } ?: false349            }.getOrDefault(false)350            if (!committed) {351                return UnlockResult.Error("storage_write_failed")352            }353354            val ownedKey = candidateKey!!355            candidateKey = null // the session owns the key from here on356            adoptSlotA(ownedKey, payload, expectedEpoch)357        } catch (e: Exception) {358            // A raw kotlinx parse error ("Unexpected JSON token at offset N")359            // reaching the user says nothing about WHICH json failed — the360            // wrapper, or the payload decrypted out of it. Codex screens are361            // FLAG_SECURE so this can't be screenshotted either. Debug builds362            // log the structure around the failure; alphanumerics are masked so363            // no ciphertext or vault content lands in a log.364            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))365            UnlockResult.Error(e.message ?: "restore_failed")366        } finally {367            plaintext?.let(VaultCrypto::zero)368            candidateKey?.let(VaultCrypto::zero)369        }370    }371372    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */373    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =374        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()375376    /**377     * Undo a partially-committed Codex restore while [stateLock] is already held.378     * Every step is attempted independently so one failure cannot strand the379     * others. Callers that need epoch validation must enter through380     * [withEpochCommit] first; this helper deliberately does not reacquire the381     * lock, avoiding a reentrant gap between a failed write and its inverse.382     * With no prior vault the accurate inverse is having none at all, rather383     * than a half-written slot A that [hasVault] would report as a real vault.384     */385    private fun rollbackRestoreLocked(386        priorA: StoredVault?,387        priorB: String?,388        priorD: StoredVault? = null,389    ): List<String> {390        val failures = mutableListOf<String>()391        if (priorA == null) {392            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }393        } else {394            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }395                .onFailure { failures += "writeSlotA: $it" }396            if (priorB != null) {397                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }398                    .onFailure { failures += "writeSlotB: $it" }399            }400            // H-04: restore the prior duress slot — a half-committed restore that401            // left the duress record on the Codex side and slot A on the prior402            // side would put the at-rest pair in a state that never existed.403            if (priorD != null) {404                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }405                    .onFailure { failures += "writeDecoySlot: $it" }406            } else {407                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }408            }409        }410        return failures411    }412413    private fun reportRollbackFailures(failures: List<String>) {414        // Log is a throwing stub off-device, and a failed log must never mask415        // the rollback result the caller is about to report.416        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }417    }418419    /** Debug-only: structure of a failed Codex restore, with content masked. */420    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {421        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)422        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()423        val window = offset?.let {424            val from = (it - 40).coerceAtLeast(0)425            val to = (it + 40).coerceAtMost(wrapperJson.length)426            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")427        }428        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()429        val slot = wrapper?.get("a")430        buildString {431            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)432            append(" | blobBase64=").append(vaultBlobBase64.length)433            append(" wrapperChars=").append(wrapperJson.length)434            append(" wrapperParses=").append(wrapper != null)435            append(" keys=").append(wrapper?.keys)436            append(" slotAKind=").append(437                when {438                    slot == null -> "absent"439                    slot is JsonPrimitive && slot.isString -> "string"440                    slot is JsonObject -> "object:" + slot.keys441                    else -> slot::class.simpleName442                },443            )444            if (window != null) append(" masked@").append(offset).append("=").append(window)445        }446    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }447448    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {449        val expectedEpoch = currentEpoch()450        var key: ByteArray? = null451        return try {452            val salt = VaultCrypto.generateSalt()453            key = VaultCrypto.deriveKey(password, salt)454            if (mutateIfCurrentEpoch(expectedEpoch) {455                entryList = mutableListOf()456                duressConfigured = false457                recoveryWordHash = null458                recoveryWordsSet = false459                decoyMode = false460                sessionDataAvailable = false461                rootKey = generateRecoverableRootKey()462            } == null) return CreateVaultResult.Error("stale_create")463            // M-04: the fields above have to be live before sealSlotA, since it464            // seals currentPayload(). That stages a root key the session would465            // otherwise keep after a failed create — [lock] discards the whole466            // staged session on every non-success exit below.467            val slotA = sealSlotA(key!!, salt)468            // P1-3 (codex): every later step must roll the slot pair back if it469            // fails — leaving a half-built vault is worse than refusing to make it.470            beforeDurableCommit()471            val created = withEpochCommit(expectedEpoch) {472                if (!store.writeSlotA(slotA)) return@withEpochCommit false473                try {474                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")475                    // H-04: duress slot must be present from the start so its476                    // existence on disk is independent of duressConfigured.477                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")478                    store.stampLocalMutation()479                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")480                    true481                } catch (_: Exception) {482                    // Half-built vault on disk is worse than no vault — refuse to483                    // create if any step past slot A fails.484                    store.clearVault()485                    false486                }487            } ?: false488            if (!created) {489                lock()490                return CreateVaultResult.Error("storage_write_failed")491            }492            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")493            if (mutateIfCurrentEpoch(expectedEpoch) {494                derivedKey = sessionKey495                key = null496                publish()497                _isUnlocked.value = true498                sessionDataAvailable = true499                sessionEpoch = nextSessionEpoch(sessionEpoch)500            } == null) return CreateVaultResult.Error("stale_create")501            CreateVaultResult.Success502        } catch (e: Exception) {503            lock()504            CreateVaultResult.Error(e.message ?: "create_failed")505        } finally {506            key?.let(VaultCrypto::zero)507        }508    }509510    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {511        val expectedEpoch = currentEpoch()512        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")513        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")514        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {515            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")516        }517        var adoptedRootKey: ByteArray? = null518        var nextDerivedKey: ByteArray? = null519        var sessionRootKey: ByteArray? = null520        return try {521            adoptedRootKey = hexToRootKey(rootKeyHex)522            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {523                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")524            }525            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)526            val salt = VaultCrypto.generateSalt()527            nextDerivedKey = VaultCrypto.deriveKey(password, salt)528            sessionRootKey = adoptedRootKey.copyOf()529            val payload = VaultPayload(530                entries = emptyList(),531                duressConfigured = false,532                rootKeyB64 = b64(sessionRootKey),533                recoveryWordHash = null,534                recoveryWordsSet = false,535            )536            val plaintext = VaultCodec.encode(payload)537            val blob = try {538                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))539            } finally {540                VaultCrypto.zero(plaintext)541            }542            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)543            // P1-3 (codex): same partial-commit guard as createVault — any failure544            // past slot A wipes the half-built vault and refuses adoption.545            beforeDurableCommit()546            val created = withEpochCommit(expectedEpoch) {547                if (!store.writeSlotA(slotA)) return@withEpochCommit false548                try {549                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")550                    // H-04: duress slot must be present from the start so its551                    // existence on disk is independent of duressConfigured.552                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")553                    store.stampLocalMutation()554                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")555                    true556                } catch (_: Exception) {557                    store.clearVault()558                    false559                }560            } ?: false561            if (!created) return AdoptVaultResult.Error("storage_write_failed")562563            val adopted = mutateIfCurrentEpoch(expectedEpoch) {564                val previousDerivedKey = derivedKey565                val previousRootKey = rootKey566                derivedKey = nextDerivedKey567                rootKey = sessionRootKey568                nextDerivedKey = null569                sessionRootKey = null570                entryList = mutableListOf()571                duressConfigured = false572                recoveryWordHash = null573                recoveryWordsSet = false574                decoyMode = false575                sessionDataAvailable = true576                publish()577                _isUnlocked.value = true578                sessionEpoch = nextSessionEpoch(sessionEpoch)579                previousDerivedKey to previousRootKey580            }581            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")582            val (previousDerivedKey, previousRootKey) = adopted583            previousDerivedKey?.let(VaultCrypto::zero)584            previousRootKey?.let(VaultCrypto::zero)585            AdoptVaultResult.Success(vaultId)586        } catch (e: Exception) {587            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")588        } finally {589            adoptedRootKey?.let(VaultCrypto::zero)590            nextDerivedKey?.let(VaultCrypto::zero)591            sessionRootKey?.let(VaultCrypto::zero)592        }593    }594595    suspend fun unlock(password: String): UnlockResult = mutex.withLock {596        unlockLocked(password, currentEpoch())597    }598599    /** Unlock body without acquiring the mutex — callers must already hold it. */600    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {601        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")602        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))603        val payload = try {604            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)605            try {606                VaultCodec.decode(plaintext)607            } finally {608                VaultCrypto.zero(plaintext)609            }610        } catch (_: Exception) {611            VaultCrypto.zero(key)612            // LV-1: a vault sealed before NFC normalization with a non-NFC613            // password derives a different key now. If the supplied password is614            // not already in NFC form, retry with the raw (un-normalized) bytes.615            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {616                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)617                if (legacy != null) return legacy618            }619            return UnlockResult.WrongPassword620        }621        return adoptSlotA(key, payload, expectedEpoch)622    }623624    /**625     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw626     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed627     * under the NFC-normalized key — so every future unlock takes the normal628     * path. Returns null when the raw key also fails (genuinely wrong password)629     * OR when any step of the heal commits fails (the original slot A is restored630     * so disk and session stay aligned).631     */632    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {633        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))634        var newKey: ByteArray? = null635        return try {636            val payload = try {637                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)638                try {639                    VaultCodec.decode(plaintext)640                } finally {641                    VaultCrypto.zero(plaintext)642                }643            } catch (_: Exception) {644                return null645            }646            // Heal: re-seal slot A under the NFC key (mirrors changePassword's647            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.648            val newSalt = VaultCrypto.generateSalt()649            newKey = VaultCrypto.deriveKey(password, newSalt)650            val plaintext = VaultCodec.encode(payload)651            val blob = try {652                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))653            } finally {654                VaultCrypto.zero(plaintext)655            }656            val next = StoredVault(657                version = slotA.version,658                salt = b64(newSalt),659                blob = blob,660                recoveryEscrow = slotA.recoveryEscrow,661                rootKeyEscrow = slotA.rootKeyEscrow,662            )663            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,664            // or stampLocalMutation must restore the prior slot A AND slot B so disk665            // and the eventual session key never disagree on the wrapping.666            val priorB = store.loadSlotB()667            beforeDurableCommit()668            val healed = withEpochCommit(expectedEpoch) {669                if (!store.writeSlotA(next)) {670                    false671                } else try {672                    if (!writeMatchingNoiseSlotB(next)) {673                        runCatching { store.writeSlotA(slotA) }674                        if (priorB != null) runCatching { store.writeSlotB(priorB) }675                        false676                    } else if (!writeCompanionDuressSlot(next)) {677                        runCatching { store.writeSlotA(slotA) }678                        if (priorB != null) runCatching { store.writeSlotB(priorB) }679                        false680                    } else {681                        store.stampLocalMutation()682                        true683                    }684                } catch (_: Exception) {685                    runCatching { store.writeSlotA(slotA) }686                    if (priorB != null) runCatching { store.writeSlotB(priorB) }687                    false688                }689            } ?: false690            if (healed) {691                VaultCrypto.zero(rawKey!!)692                rawKey = null693                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot694                newKey = null695                result696            } else {697                VaultCrypto.zero(newKey!!)698                newKey = null699                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key700                rawKey = null701                result702            }703        } finally {704            // Cover codec, encryption, storage, and stale-epoch failures before705            // either candidate key can be transferred into the live session.706            rawKey?.let(VaultCrypto::zero)707            newKey?.let(VaultCrypto::zero)708        }709    }710711    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */712    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {713        return try {714            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they715            // can throw after the fields above are live. There is no prior session716            // worth restoring — adoption has already replaced it — so a failed717            // adoption discards everything rather than leaving entries and keys718            // resident behind _isUnlocked=false.719            val adopted = mutateIfCurrentEpoch(expectedEpoch) {720                derivedKey = key721                decoyMode = false722                entryList = payload.entries.toMutableList()723                duressConfigured = payload.duressConfigured724                recoveryWordHash = payload.recoveryWordHash725                recoveryWordsSet = payload.recoveryWordsSet726                sessionDataAvailable = true727                _settings.value = store.loadSettings()728                ensureRootKey(payload)729                autoPurgeExpiredTrash()730                publish()731                _isUnlocked.value = true732                // A successful adoption starts a new generation. A sibling733                // SlotAUnlock captured in the prior epoch will fail closed at734                // the next commit instead of replacing this session.735                sessionEpoch = nextSessionEpoch(sessionEpoch)736            }737            if (adopted == null) {738                VaultCrypto.zero(key)739                UnlockResult.Error("stale_unlock")740            } else {741                UnlockResult.Success742            }743        } catch (e: Exception) {744            // Do not call lock() here: a concurrent newer session may already745            // own this repository. Only invalidate the failed epoch's fields.746            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }747            VaultCrypto.zero(key)748            UnlockResult.Error(e.message ?: "unlock_failed")749        }750    }751752    // ---- Constant-time unlock (MF-5) --------------------------------------753754    /**755     * A slot-A unlock that has been derived but not yet adopted. Holds the live756     * Argon2id key and decoded payload so the caller can derive several candidates757     * concurrently and commit only the winner. The key is owned by this token until758     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.759     */760    class SlotAUnlock internal constructor(761        key: ByteArray,762        internal val payload: VaultPayload,763        internal val epoch: Long,764    ) {765        private var ownedKey: ByteArray? = key766        private var consumed = false767768        /** Transfer the key exactly once to the adopter. */769        internal fun takeKey(): ByteArray? = synchronized(this) {770            if (consumed) return@synchronized null771            consumed = true772            ownedKey.also { ownedKey = null }773        }774775        /** Discard an uncommitted token without touching a transferred key. */776        internal fun discard() = synchronized(this) {777            if (consumed) return@synchronized778            consumed = true779            ownedKey?.let(VaultCrypto::zero)780            ownedKey = null781        }782    }783784    /**785     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.786     * Pure computation (no mutex), so it can run concurrently with other candidate787     * derivations before the caller decides which to commit (MF-5). Returns null on788     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).789     */790    fun tryUnlockSlotA(password: String): SlotAUnlock? {791        val epoch = currentEpoch()792        val slotA = store.loadSlotA() ?: return null793        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))794        return try {795            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)796            val payload = try {797                VaultCodec.decode(plaintext)798            } finally {799                VaultCrypto.zero(plaintext)800            }801            SlotAUnlock(key, payload, epoch)802        } catch (_: Exception) {803            VaultCrypto.zero(key)804            null805        }806    }807808    /** Adopt a previously-derived [SlotAUnlock] as the live session. */809    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {810        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")811        adoptSlotA(key, unlock.payload, unlock.epoch)812    }813814    /** Zero a derived [SlotAUnlock] that will not be adopted. */815    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()816817    // ---- Duress slot B (MF-6) ---------------------------------------------818819    /**820     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key821     * and decoded decoy payload so it can be derived concurrently with slot A and822     * only committed if the duress passphrase was the one entered (MF-5/MF-6).823     */824    class DuressUnlock internal constructor(825        key: ByteArray,826        internal val payload: VaultPayload,827        internal val epoch: Long,828    ) {829        private var ownedKey: ByteArray? = key830        private var consumed = false831832        internal fun takeKey(): ByteArray? = synchronized(this) {833            if (consumed) return@synchronized null834            consumed = true835            ownedKey.also { ownedKey = null }836        }837838        internal fun discard() = synchronized(this) {839            if (consumed) return@synchronized840            consumed = true841            ownedKey?.let(VaultCrypto::zero)842            ownedKey = null843        }844    }845846    /**847     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the848     * session. Success means the entered password is the duress passphrase that keys849     * slot B — indistinguishable from the real slot A at the lock screen. Pure850     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].851     * Returns null when no duress slot exists or the password is not the duress one.852     */853    fun tryUnlockDuress(password: String): DuressUnlock? {854        val epoch = currentEpoch()855        val slot = store.loadDecoySlot() ?: return null856        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))857        return try {858            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)859            val payload = try {860                VaultCodec.decode(plaintext)861            } finally {862                VaultCrypto.zero(plaintext)863            }864            DuressUnlock(key, payload, epoch)865        } catch (_: Exception) {866            VaultCrypto.zero(key)867            null868        }869    }870871    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */872    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {873        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")874        if (currentEpoch() != unlock.epoch) {875            VaultCrypto.zero(key)876            return@withLock UnlockResult.Error("stale_unlock")877        }878        return try {879            val adopted = mutateIfCurrentEpoch(unlock.epoch) {880                derivedKey = key881                decoyMode = true882                entryList = unlock.payload.entries.toMutableList()883                duressConfigured = false884                recoveryWordHash = unlock.payload.recoveryWordHash885                recoveryWordsSet = unlock.payload.recoveryWordsSet886                sessionDataAvailable = true887                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)888                          else VaultCrypto.generateRootKey()889                _settings.value = store.loadSettings()890                publish()891                _isUnlocked.value = true892                sessionEpoch = nextSessionEpoch(sessionEpoch)893            }894            if (adopted == null) {895                VaultCrypto.zero(key)896                UnlockResult.Error("stale_unlock")897            } else {898                UnlockResult.Success899            }900        } catch (e: Exception) {901            // A payload/root/settings failure can occur after the candidate key902            // has been assigned to derivedKey. Tear down only this epoch so a903            // newer session is not disturbed, and scrub the token reference on904            // every error path.905            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }906            VaultCrypto.zero(key)907            UnlockResult.Error(e.message ?: "decoy_unlock_failed")908        }909    }910911    /** Zero a derived [DuressUnlock] that will not be adopted. */912    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()913914    /**915     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an916     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The917     * passphrase is never stored — only its ability to decrypt slot B proves it.918     * Requires the real vault to be unlocked and not itself a decoy session.919     */920    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {921        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().922        val expectedEpoch = mutateState {923            if (derivedKey != null && !decoyMode) sessionEpoch else null924        } ?: return false925        // Reject a duress passphrase that also opens the real vault: it would make the926        // lock screen ambiguous (both slots would decrypt for the same input).927        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }928        var key: ByteArray? = null929        var basePlaintext: ByteArray? = null930        return try {931            val salt = VaultCrypto.generateSalt()932            key = VaultCrypto.deriveKey(duressPassphrase, salt)933            // An empty decoy vault with its own fresh sync root key — nothing real leaks.934            val decoyRoot = VaultCrypto.generateRootKey()935            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }936            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)937            basePlaintext = VaultCodec.encode(decoyPayload)938            // H-04: pad the duress plaintext to slot A's ciphertext length so the939            // duress blob matches slot A's blob on disk. VaultCodec.decode strips940            // trailing NULs, so the extra padding survives encrypt but never941            // reaches the JSON parser.942            // ponytail: only re-pads at configuration time — if slot A grows past943            // its initial bucket (entries cross 4096/16384/etc.), the real decoy944            // stays at the smaller bucket. Re-padding on every persist requires945            // keeping the duress passphrase in memory, which we don't.946            val slotA = store.loadSlotA()947            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0948            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!949            val blob = try {950                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))951            } finally {952                VaultCrypto.zero(plaintext)953                // Padding creates a second array; scrub the original encoded954                // payload too rather than dropping its last reference untouched.955                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)956                basePlaintext = null957                key?.let(VaultCrypto::zero)958                key = null959            }960            beforeDurableCommit()961            val ok = withEpochCommit(expectedEpoch) {962                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))963            } ?: false964            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {965                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }966            } else if (ok) {967                return false968            }969            ok970        } catch (_: Exception) {971            false972        } finally {973            // Cover failures before the encrypt block is entered (for example a974            // codec/copy exception) as well as any early return after key derivation.975            key?.let(VaultCrypto::zero)976            basePlaintext?.let(VaultCrypto::zero)977        }978    }979980981    /**982     * Export the decoy slot as browser-shaped JSON for adoption snapshots.983     *984     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all985     * lowercase hex — identical to the browser's `duress_slot` wire format — so986     * any receiver (browser or Android) can install it via the existing987     * passthrough. Returns null when duress is not configured, no decoy exists,988     * or any conversion failure occurs. Never decrypts.989     */990    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {991        if (!duressConfigured) return null992        val slot = store.loadDecoySlot() ?: return null993        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()994    }995996    /**997     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.998     *999     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1000     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1001     * byte arrays are zeroed in finally.1002     */1003    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1004        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1005        // serialized — kotlinx-serialization's encodeDefaults = false would1006        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1007        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1008        // silently dropping the duress slot on Android→browser adoptions.1009        var salt: ByteArray? = null1010        var blob: ByteArray? = null1011        var nonce: ByteArray? = null1012        var ctAndTag: ByteArray? = null1013        var ct: ByteArray? = null1014        var tag: ByteArray? = null1015        try {1016            salt = unb64(slot.salt)1017            blob = unb64(slot.blob)1018            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1019            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1020            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1021            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1022            val obj = JsonObject(mapOf(1023                "version" to JsonPrimitive(1),1024                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1025                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1026                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1027                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1028            ))1029            return obj.toString()1030        } finally {1031            salt?.let(VaultCrypto::zero)1032            blob?.let(VaultCrypto::zero)1033            nonce?.let(VaultCrypto::zero)1034            ctAndTag?.let(VaultCrypto::zero)1035            ct?.let(VaultCrypto::zero)1036            tag?.let(VaultCrypto::zero)1037        }1038    }10391040    /**1041     * Install a browser-shaped duress slot received through adoption.1042     *1043     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1044     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1045     * writes the result to the decoy slot. Epoch-fenced like the Codex1046     * restore path so a concurrent lock() cannot strand the write.1047     *1048     * Returns false on invalid input, no live session, or write failure.1049     */1050    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1051        val b = BrowserSlot.parse(json) ?: return false1052        var stored: StoredVault? = null1053        var salt: ByteArray? = null1054        var nonce: ByteArray? = null1055        var ct: ByteArray? = null1056        var tag: ByteArray? = null1057        var blob: ByteArray? = null1058        try {1059            salt = unhex(b.salt)1060            nonce = unhex(b.nonce)1061            ct = unhex(b.ciphertext)1062            tag = unhex(b.tag)1063            blob = ByteArray(nonce.size + ct.size + tag.size).also {1064                System.arraycopy(nonce, 0, it, 0, nonce.size)1065                System.arraycopy(ct, 0, it, nonce.size, ct.size)1066                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1067            }1068            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1069            val expectedEpoch = sessionEpoch1070            beforeDurableCommit()1071            withEpochCommit(expectedEpoch) {1072                store.writeDecoySlot(stored)1073            } ?: false1074        } catch (_: Exception) {1075            false1076        } finally {1077            salt?.let(VaultCrypto::zero)1078            nonce?.let(VaultCrypto::zero)1079            ct?.let(VaultCrypto::zero)1080            tag?.let(VaultCrypto::zero)1081            blob?.let(VaultCrypto::zero)1082        }1083    }10841085    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1086    suspend fun clearDuress(): Boolean = mutex.withLock {1087        // H-04: presence-indistinguishable — replace the real decoy with a fake1088        // blob of slot A's size rather than removing the duress record. The1089        // record stays on disk at the same length, so an at-rest observer1090        // cannot tell "duress cleared" from "no duress ever configured".1091        val expectedEpoch = currentEpoch()1092        val replaced = withEpochCommit(expectedEpoch) {1093            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1094                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1095        } ?: false1096        if (!replaced) return@withLock false1097        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1098        // duressConfigured write so a concurrent lock() cannot land between them.1099        if (!isSessionEpochCurrent(expectedEpoch)) return false1100        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1101        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1102            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1103        }1104        true1105    }11061107    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1108    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1109        var current: Throwable? = error1110        while (current != null) {1111            if (current is javax.crypto.AEADBadTagException) return true1112            // Some Android providers expose a GCM tag failure as the parent1113            // BadPaddingException rather than the AEAD-specific subtype.  A1114            // short ciphertext is an IllegalBlockSizeException and therefore1115            // remains an internal/malformed-payload Error below.1116            if (current is javax.crypto.BadPaddingException) return true1117            current = current.cause1118        }1119        return false1120    }11211122    /** Unlock with an already-derived AES-256 key (biometric path). */1123    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1124        val expectedEpoch = currentEpoch()1125        val slotA = try {1126            store.loadSlotA()1127        } catch (_: Exception) {1128            return@withLock UnlockResult.Error("unlock_key_storage_failed")1129        } ?: return@withLock UnlockResult.Error("no_vault")1130        // Keep the caller-owned key separate from the staged session key. The1131        // biometric caller always zeroes its input in a finally block; adoption1132        // must therefore own a copy and use the same all-or-nothing path as a1133        // password unlock rather than publishing a partially decoded session.1134        var stagedKey: ByteArray? = key.copyOf()1135        return try {1136            val encrypted = try {1137                unb64(slotA.blob)1138            } catch (_: Exception) {1139                return@withLock UnlockResult.Error("invalid_vault_blob")1140            }1141            // A GCM record must include nonce + authentication tag.  Providers1142            // differ on whether a shorter record is reported as BadPadding or1143            // AEADBadTag; reject it before decrypt so malformed storage cannot be1144            // misreported as a user-entered wrong key.1145            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1146                return@withLock UnlockResult.Error("invalid_vault_blob")1147            }1148            val plaintext = try {1149                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1150            } catch (e: Exception) {1151                if (isGcmAuthenticationFailure(e)) {1152                    return@withLock UnlockResult.WrongPassword1153                }1154                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1155            }1156            val payload = try {1157                VaultCodec.decode(plaintext)1158            } catch (_: Exception) {1159                return@withLock UnlockResult.Error("invalid_vault_payload")1160            } finally {1161                VaultCrypto.zero(plaintext)1162            }1163            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1164            val result = adoptSlotA(candidate, payload, expectedEpoch)1165            if (result is UnlockResult.Success) stagedKey = null1166            result1167        } finally {1168            stagedKey?.let(VaultCrypto::zero)1169        }1170    }11711172    fun lock() {1173        // H-03: must observe derivedKey/entryList atomically with any in-flight1174        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1175        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1176        // guards just the brief state reset. Advancing the epoch first invalidates1177        // every operation that captured this session before it reaches a later1178        // durable commit or publication.1179        stateLock.lock()1180        try {1181            sessionEpoch = nextSessionEpoch(sessionEpoch)1182            clearSessionStateLocked()1183        } finally {1184            stateLock.unlock()1185        }1186    }11871188    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11891190    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1191    // is a stub off-device).1192    @VisibleForTesting1193    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11941195    /**1196     * Call immediately before launching a system Activity (a SAF file1197     * picker/save dialog, a share sheet) that briefly takes the whole process1198     * out of the foreground as an expected part of an in-app flow — not a1199     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1200     * two apart on its own (it fires for both), and without this, exporting or1201     * importing a file silently locked and wiped the vault mid-flow the moment1202     * the picker took over, before its result ever reached the app.1203     *1204     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1205     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1206     */1207    fun expectTransientBackground() {1208        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1209    }12101211    /**1212     * Consumed once by the next background-lock check; see [expectTransientBackground].1213     * Returns false (and clears the flag) if the suppression has expired or was1214     * never set — a genuine background event must always lock.1215     */1216    fun consumeTransientBackgroundSuppression(): Boolean {1217        val until = suppressNextBackgroundLockUntil1218        val active = until != 0L && nowMs() < until1219        suppressNextBackgroundLockUntil = 0L1220        return active1221    }12221223    // ---- Sync identity ----------------------------------------------------12241225    /**1226     * The vault's stable sync master key (a copy). Caller must zero it. Backs1227     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1228     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1229     */1230    /**1231     * HOLE 5 — the duress vault never syncs.1232     *1233     * [commitDuressUnlock] gives the decoy session a root key so its own codex1234     * and recovery paths work, which means (unlike the browser, where the decoy1235     * path simply has no root key) the sync surface has to refuse explicitly.1236     * Without this the decoy vault would present a second, different vault_id1237     * to a paired device, and a coerced user opening it would make sync visibly1238     * fail on their other devices — a duress indicator, which is the one thing1239     * a duress vault must never be.1240     */1241    // M-04: the "null when locked" half of the contract was documented but never1242    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1243    // [unlockWithRecoveryWords], which installs a root key with no password key1244    // yet) and in a create that failed after staging. Sync must fail closed1245    // there, so the authoritative unlocked flag gates it, not just decoyMode.1246    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12471248    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1249        if (decoyMode || !_isUnlocked.value) null1250        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1251    }12521253    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1254        mutateState { sessionEpoch == epoch }12551256    internal fun unlockedSessionEpoch(): Long? = mutateState {1257        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1258    }12591260    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12611262    /**1263     * The current session's root key, decoy included. For local-only uses that1264     * must behave identically in a decoy session — Codex backup and recovery1265     * words — because a feature that errors under duress is itself a duress1266     * tell. In a decoy session this is the DECOY's root key (see1267     * [commitDuressUnlock]), never the real vault's, so neither surface can1268     * leak the real vault. Anything that leaves the device must use1269     * [syncMasterKey] instead.1270     */1271    fun sessionRootKey(): ByteArray? = mutateState {1272        if (!sessionDataAvailable) null else rootKey?.copyOf()1273    }12741275    /** Stable per-vault id two paired devices must share to sync. Null when1276     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1277    fun syncVaultId(): String? = mutateState {1278        if (decoyMode || !_isUnlocked.value) null1279        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1280    }12811282    /**1283     * Browser parity (MV-8): rotate the sync identity by replacing the stable1284     * root key. Entries remain under the master-password key, while sync1285     * credentials, vault_id, and recovery words all change.1286     */1287    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1288        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1289            ?: return false1290        installRootKey(generateRecoverableRootKey(), expectedEpoch)1291    }12921293    /**1294     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1295     * keeping every local entry.1296     *1297     * Sync identity (vault id, signing keys) derives from the root key, and1298     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1299     * created vaults can never reconcile. Taking the other side's root key is1300     * what makes them one vault; from the next sync on, both entry sets merge.1301     *1302     * Entries survive because they are sealed under the password-derived key,1303     * not the root key — the same property [rotateSyncIdentity] relies on.1304     *1305     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1306     * this device's previous recovery words stop working for good, and its new1307     * words are the source vault's words, because words are a deterministic1308     * encoding of the root key.1309     */1310    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1311        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1312            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1313        }1314        val expectedEpoch = mutateState {1315            when {1316                derivedKey == null -> null1317                decoyMode -> Long.MIN_VALUE1318                else -> sessionEpoch1319            }1320        }1321        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1322        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1323        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13241325        val incoming = hexToRootKey(rootKeyHex)1326        if (incoming.size != VaultCrypto.KEY_SIZE) {1327            VaultCrypto.zero(incoming)1328            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1329        }1330        // Refuse before committing rather than leave this device holding a vault1331        // whose recovery words cannot be typed back. generateRecoverableRootKey1332        // screens its own keys for this; a key arriving from a peer has not been.1333        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1334        if (!roundTrips) {1335            VaultCrypto.zero(incoming)1336            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1337        }13381339        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1340        return if (installRootKey(incoming, expectedEpoch)) {1341            AdoptVaultResult.Success(vaultId)1342        } else {1343            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1344        }1345    }13461347    /**1348     * The recovery words [rootKeyHex] will produce once joined, or null if it is1349     * not a usable root key. Lets the join flow show the user the words that are1350     * about to replace theirs *before* anything is committed.1351     */1352    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1353        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1354        val bytes = hexToRootKey(rootKeyHex)1355        return try {1356            val words = rootKeyToWords(bytes)1357            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1358        } catch (_: Exception) {1359            null1360        } finally {1361            VaultCrypto.zero(bytes)1362        }1363    }13641365    /**1366     * Swap the vault's root key, keeping every entry, and re-escrow under the1367     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1368     * [mutex].1369     */1370    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1371        // H-03: snapshot the live fields under stateLock so the success and the1372        // rollback paths both see consistent prior/new state. Writing the new1373        // rootKey/recovery fields is inside its own mutateState at the end.1374        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1375        // — holding live references would let a zero-on-failure operation also1376        // wipe the session's still-live key arrays.1377        data class Snapshot(1378            val key: ByteArray,1379            val priorRootKey: ByteArray,1380            val priorRecoveryHash: String?,1381            val priorRecoverySet: Boolean,1382        )1383        val snap = mutateIfCurrentEpoch(expectedEpoch) {1384            val k = derivedKey ?: return@mutateIfCurrentEpoch null1385            if (decoyMode) return@mutateIfCurrentEpoch null1386            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1387            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1388        } ?: run {1389            VaultCrypto.zero(nextRootKey)1390            return false1391        }1392        val priorSlot = store.loadSlotA() ?: run {1393            VaultCrypto.zero(snap.key)1394            VaultCrypto.zero(snap.priorRootKey)1395            VaultCrypto.zero(nextRootKey)1396            return false1397        }1398        val nextWords = rootKeyToWords(nextRootKey)13991400        return try {1401            beforeDurableCommit()1402            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1403            if (mutateIfCurrentEpoch(expectedEpoch) {1404                rootKey = nextRootKey1405                recoveryWordHash = recoveryWordsHash(nextWords)1406                recoveryWordsSet = false1407            } == null) throw IllegalStateException("stale_root_rotation")14081409            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1410                ?: throw IllegalStateException("recovery_escrow_failed")1411            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1412                ?: throw IllegalStateException("root_escrow_failed")1413            val plaintext = VaultCodec.encode(currentPayload())1414            val blob = try {1415                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1416            } finally {1417                VaultCrypto.zero(plaintext)1418            }1419            val nextSlot = priorSlot.copy(1420                blob = blob,1421                recoveryEscrow = nextRecoveryEscrow,1422                rootKeyEscrow = nextRootKeyEscrow,1423            )1424            val committed = withEpochCommit(expectedEpoch) {1425                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1426                try {1427                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1428                    // H-04: a root-key rotation re-seals slot A, so the duress slot1429                    // (real or fake) must be re-padded to the new ciphertext length.1430                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1431                    store.stampLocalMutation()1432                } catch (_: Exception) {1433                    store.writeSlotA(priorSlot)1434                    return@withEpochCommit false1435                }1436                true1437            } ?: false1438            if (!committed) throw IllegalStateException("storage_write_failed")1439            mutateIfCurrentEpoch(expectedEpoch) { publish() }1440            true1441        } catch (_: Exception) {1442            val restored = mutateIfCurrentEpoch(expectedEpoch) {1443                rootKey?.let(VaultCrypto::zero)1444                rootKey = snap.priorRootKey.copyOf()1445                recoveryWordHash = snap.priorRecoveryHash1446                recoveryWordsSet = snap.priorRecoverySet1447            }1448            if (restored != null) {1449                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1450            }1451            false1452        } finally {1453            VaultCrypto.zero(snap.key)1454            VaultCrypto.zero(snap.priorRootKey)1455            // On success nextRootKey is owned by rootKey. On every other path1456            // it is not, including a stale epoch after lock(); scrub it while1457            // the local reference is still available.1458            val stillOwnedBySession = mutateState {1459                sessionEpoch == expectedEpoch && rootKey === nextRootKey1460            }1461            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1462        }1463    }14641465    /**1466     * A copy of the live Argon2id-derived vault key, for biometric escrow only1467     * (wrapped under a hardware KeyStore key on biometric enable). Null when1468     * locked. Caller must [VaultCrypto.zero] it after use.1469     */1470    fun currentVaultKey(): ByteArray? = mutateState {1471        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1472    }14731474    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1475        val expectedEpoch = currentEpoch()1476        val slotA = store.loadSlotA() ?: return false1477        var oldKey: ByteArray? = null1478        var newKey: ByteArray? = null1479        var plaintext: ByteArray? = null1480        return try {1481            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1482            // Keep ownership of the decrypted vault plaintext until decoding has1483            // finished.  Passing decrypt() inline made the byte array unreachable1484            // before the finally block, so a successful or failed password change1485            // could leave the full plaintext resident in the heap.1486            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1487            val payload = try {1488                VaultCodec.decode(decrypted)1489            } finally {1490                VaultCrypto.zero(decrypted)1491            }1492            val newSalt = VaultCrypto.generateSalt()1493            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1494            // Re-seal the existing payload under the new key/salt.1495            plaintext = VaultCodec.encode(payload)1496            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1497            // The escrow seals the slot-A PLAINTEXT, which a password change does not1498            // alter — only the wrapping changes — so carry it forward; rebuilding the1499            // StoredVault from scratch would silently drop it.1500            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1501            // content that a password change does not alter — only the slot-A1502            // wrapping rotates. The root-key escrow staying stable across password1503            // changes matches the browser (`vault.ts:363-364`).1504            val next = StoredVault(1505                version = slotA.version,1506                salt = b64(newSalt),1507                blob = blob,1508                recoveryEscrow = slotA.recoveryEscrow,1509                rootKeyEscrow = slotA.rootKeyEscrow,1510            )1511            // H-01: durably commit slot A + size-matched noise + version stamp1512            // before swapping the session key. A partial commit (new A on disk,1513            // old key in memory) would have the next save encrypt under the old1514            // key while matching disk on the new one.1515            val priorB = store.loadSlotB()1516            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1517            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1518            // inside the same stateLock critical section, so a concurrent lock()1519            // can't observe the in-between (zeroed but not yet reassigned).1520            if (mutateIfCurrentEpoch(expectedEpoch) {1521                oldKey?.let(VaultCrypto::zero); oldKey = null1522                derivedKey?.let(VaultCrypto::zero)1523                derivedKey = newKey; newKey = null1524            } == null) return false1525            true1526        } catch (_: Exception) {1527            false1528        } finally {1529            oldKey?.let(VaultCrypto::zero)1530            newKey?.let(VaultCrypto::zero)1531            plaintext?.let(VaultCrypto::zero)1532        }1533    }15341535    /**1536     * Irreversible wipe. Locks the session and clears every vault key. The Pro1537     * license is in a separate store and is preserved by construction.1538     */1539    suspend fun destroyVault() = mutex.withLock {1540        lock()1541        store.clearVault()1542        store.clearDecoySlot()1543    }15441545    // ---- Entry CRUD -------------------------------------------------------15461547    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1548        val epoch = requireUnlocked()1549        val stamp = VaultCrypto.monotonicEntryTimestamp()1550        val now = System.currentTimeMillis()1551        val entry = withDerivedLast4(draft.copy(1552            id = draft.id.ifBlank { UUID.randomUUID().toString() },1553            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1554            modifiedAt = stamp,1555            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1556        ))1557        // H-03: capture rollback AND write entryList under stateLock so a1558        // concurrent lock() cannot zero the session between the read and the write.1559        val rollback = mutateCurrentSession(epoch) {1560            val r = entryList.toMutableList()1561            entryList.add(entry)1562            r1563        } ?: throw IllegalStateException("vault_locked")1564        persistOrRollback(rollback, epoch)1565        entry1566    }15671568    /**1569     * Insert many entries with a single encrypt-and-write at the end.1570     *1571     * Importing a real vault through [addEntry] was quadratic: every entry took1572     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1573     * restore did 806 full-vault writes and hung the app long enough to ANR.1574     * Rolls back to the pre-import list if the one write fails, matching1575     * [addEntry]'s all-or-nothing behaviour.1576     */1577    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1578        val epoch = requireUnlocked()1579        if (drafts.isEmpty()) return 01580        val now = System.currentTimeMillis()1581        val ready = drafts.map { draft ->1582            val stamp = VaultCrypto.monotonicEntryTimestamp()1583            withDerivedLast4(1584                draft.copy(1585                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1586                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1587                    modifiedAt = stamp,1588                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1589                ),1590            )1591        }1592        // H-03: capture rollback AND append every entry under stateLock.1593        val rollback = mutateCurrentSession(epoch) {1594            val r = entryList.toMutableList()1595            entryList.addAll(ready)1596            r1597        } ?: throw IllegalStateException("vault_locked")1598        persistOrRollback(rollback, epoch)1599        return drafts.size1600    }16011602    /**1603     * M-01: replace the whole active set with [newEntries] in one write.1604     *1605     * Import's REPLACE mode used to soft-delete every current entry one durable1606     * write at a time (swallowing each failure) and then batch-add, so a failed1607     * add left the old entries stranded in Trash with nothing restored. Staging1608     * the entire next list in memory and committing it through1609     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1610     * pre-import list — active entries included — is restored.1611     *1612     * Existing tombstones are preserved so paired devices still see the1613     * deletions they already know about.1614     */1615    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1616        val epoch = requireUnlocked()1617        val now = System.currentTimeMillis()1618        val ready = newEntries.map { draft ->1619            val stamp = VaultCrypto.monotonicEntryTimestamp()1620            withDerivedLast4(1621                draft.copy(1622                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1623                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1624                    modifiedAt = stamp,1625                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1626                ),1627            )1628        }1629        // M-03: an incoming entry that shares an id with an existing live entry1630        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1631        // the old one and then appending the new one would leave the list with1632        // two entries of the same id — the tombstoned one would be returned by1633        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1634        // superseded entries from the tombstone set instead.1635        val incomingIds = ready.map { it.id }.toHashSet()1636        val rollback = mutateCurrentSession(epoch) {1637            val r = entryList.toMutableList()1638            val replaced = entryList.map { entry ->1639                if (entry.deletedAt != null) return@map entry1640                if (entry.id in incomingIds) return@map null1641                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1642                entry.copy(deletedAt = stamp, modifiedAt = stamp)1643            }.filterNotNull()1644            entryList = (replaced + ready).toMutableList()1645            r1646        } ?: throw IllegalStateException("vault_locked")1647        persistOrRollback(rollback, epoch)1648        return ready.size1649    }16501651    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1652        val epoch = requireUnlocked()1653        // H-03: read entryList, compute the patched entry, and write it under a1654        // single stateLock so a concurrent lock() cannot interleave between the1655        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1656        // list, or clobber the entry onto the wrong id after a relock).1657        val prepared = mutateCurrentSession(epoch) {1658            val idx = entryList.indexOfFirst { it.id == patch.id }1659            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1660            val current = entryList[idx]1661            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1662            // Only refresh passwordChangedAt when the password value actually changes.1663            val passwordChanged = patch.password != null && patch.password != current.password1664            // Capture the outgoing password (newest first) so the user can reveal/copy/1665            // restore it later. History is computed authoritatively from `current`, not1666            // the patch — updateEntry is a full-entry replace, so trusting patch's value1667            // would let a stale form clobber the real history. Record the age the old1668            // password carried, skip an empty prior, and skip an immediate exact dupe1669            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1670            val priorPassword = current.password1671            val shouldCapture = passwordChanged &&1672                !priorPassword.isNullOrEmpty() &&1673                current.passwordHistory?.firstOrNull()?.password != priorPassword1674            val nextHistory = if (shouldCapture) {1675                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1676                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1677            } else {1678                current.passwordHistory1679            }1680            val updated = withDerivedLast4(patch.copy(1681                createdAt = current.createdAt,1682                modifiedAt = stamp,1683                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1684                passwordHistory = nextHistory,1685            ))1686            val rollback = entryList.toMutableList()1687            entryList[idx] = updated1688            updated to rollback1689        } ?: throw IllegalStateException("vault_locked")1690        val (updated, rollback) = prepared1691        persistOrRollback(rollback, epoch)1692        updated1693    }16941695    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1696    suspend fun deleteEntry(id: String) = mutex.withLock {1697        val epoch = requireUnlocked()1698        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1699        // the list out from under the indexOfFirst.1700        val rollback = mutateCurrentSession(epoch) {1701            val idx = entryList.indexOfFirst { it.id == id }1702            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1703            val current = entryList[idx]1704            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1705            val prior = entryList.toMutableList()1706            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1707            prior1708        } ?: throw IllegalStateException("vault_locked")1709        persistOrRollback(rollback, epoch)1710    }17111712    suspend fun restoreEntry(id: String) = mutex.withLock {1713        val epoch = requireUnlocked()1714        val rollback = mutateCurrentSession(epoch) {1715            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1716            if (idx == -1) throw IllegalStateException("deleted entry not found")1717            val current = entryList[idx]1718            val prior = entryList.toMutableList()1719            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1720            prior1721        } ?: throw IllegalStateException("vault_locked")1722        persistOrRollback(rollback, epoch)1723    }17241725    /** Permanent delete — no tombstone, no recovery. */1726    suspend fun purgeEntry(id: String) = mutex.withLock {1727        val epoch = requireUnlocked()1728        val rollback = mutateCurrentSession(epoch) {1729            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1730            if (idx == -1) throw IllegalStateException("deleted entry not found")1731            val prior = entryList.toMutableList()1732            entryList.removeAt(idx)1733            prior1734        } ?: throw IllegalStateException("vault_locked")1735        persistOrRollback(rollback, epoch)1736    }17371738    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1739    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1740        val epoch = requireUnlocked()1741        // H-03: read+remove under stateLock so lock() can't reorder the remove.1742        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1743        val r = mutateCurrentSession(epoch) {1744            val rollback = entryList.toMutableList()1745            val before = entryList.size1746            entryList.removeAll { it.deletedAt != null }1747            R(rollback, before - entryList.size)1748        } ?: throw IllegalStateException("vault_locked")1749        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1750        r.removed1751    }17521753    /** Browser parity: number of active entries that dedupe would move to trash. */1754    suspend fun countDuplicateEntries(): Int = mutex.withLock {1755        val epoch = requireUnlocked()1756        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1757        // lock() can't race the read with state writes.1758        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1759            ?: throw IllegalStateException("vault_locked")1760    }17611762    /**1763     * Browser parity: soft-delete duplicate entries, keeping the most recently1764     * modified member of each group. Refuse in decoy mode so the duress vault is1765     * not reshaped into a forensically distinct state.1766     */1767    suspend fun deduplicateEntries(): Int = mutex.withLock {1768        val epoch = requireUnlocked()1769        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1770        val ready = mutateCurrentSession(epoch) {1771            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1772            val active = entryList.filter { it.deletedAt == null }1773            val keepIds = mutableSetOf<String>()1774            active.groupBy { it.duplicateKey() }.values.forEach { group ->1775                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1776            }1777            val rollback = entryList.toMutableList()1778            var removed = 01779            entryList = entryList.map { entry ->1780                if (entry.deletedAt == null && entry.id !in keepIds) {1781                    removed++1782                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1783                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1784                } else {1785                    entry1786                }1787            }.toMutableList()1788            R(rollback, removed)1789        } ?: throw IllegalStateException("vault_locked")1790        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1791        ready.removed1792    }17931794    fun getEntries(): List<VaultEntry> = mutateState {1795        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1796    }1797    fun getEntry(id: String): VaultEntry? = mutateState {1798        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1799    }1800    fun getDeletedEntries(): List<VaultEntry> = mutateState {1801        if (!sessionDataAvailable) emptyList()1802        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1803    }18041805    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1806    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1807        return mutateState {1808            if (!sessionDataAvailable) return@mutateState emptyList()1809            val active = entryList.filter { it.deletedAt == null }1810            if (tagFilter == null) return@mutateState active1811            val needle = tagFilter.trim().lowercase()1812            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1813        }1814    }18151816    /** Sorted, de-duplicated tags across all active entries. */1817    fun getDistinctTags(): List<String> = mutateState {1818        if (!sessionDataAvailable) return@mutateState emptyList()1819        entryList.asSequence()1820            .filter { it.deletedAt == null }1821            .flatMap { it.tags?.asSequence() ?: emptySequence() }1822            .map { it.trim().lowercase() }1823            .filter { it.isNotEmpty() }1824            .distinct()1825            .sorted()1826            .toList()1827    }18281829    /**1830     * Distinct non-blank values for an identity field across active entries,1831     * most-frequently-used first. Supports "username" and "email"; unknown field1832     * names return an empty list. Values keep original casing (deduped1833     * case-insensitively) so a suggestion matches what the user typed.1834     */1835    fun getDistinctFieldValues(field: String): List<String> {1836        val select: (VaultEntry) -> String? = when (field) {1837            "username" -> { e -> e.username }1838            "email" -> { e -> e.email }1839            else -> return emptyList()1840        }1841        return mutateState {1842            if (!sessionDataAvailable) return@mutateState emptyList()1843            entryList.asSequence()1844                .filter { it.deletedAt == null }1845                .mapNotNull { select(it)?.trim() }1846                .filter { it.isNotEmpty() }1847                .groupingBy { it.lowercase() }1848                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1849                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1850                }1851                .values1852                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1853                .map { it.first }1854        }1855    }18561857    fun getTombstones(): List<Tombstone> = mutateState {1858        if (!sessionDataAvailable) emptyList()1859        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1860    }1861    fun getVaultVersion(): Long = store.version()1862    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18631864    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1865        val expectedEpoch = currentEpoch()1866        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1867        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1868        // H-04: snapshots now carry the duress slot too — restore preserves1869        // the at-rest triad (A/B/D), not just the A/B pair.1870        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1871        val priorA = store.loadSlotA()1872        val priorB = store.loadSlotB()1873        val priorD = store.loadDecoySlot()1874        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1875        // slot write — the old code took this snapshot, then stranded it on1876        // every failure, polluting history with a "restore" entry that never1877        // restored anything. The history below is rolled back together with1878        // the slots if the commit fails.1879        val priorHistory = store.loadVaultHistory()1880        // P1-4 (codex): the recovery snapshot must land before we commit the1881        // slot pair, otherwise a failed restore cannot be rolled back to the1882        // pre-restore history. If the history write itself fails, refuse to1883        // proceed — the alternative is a successful restore with no undo path.1884        beforeDurableCommit()1885        if (!takeVaultSnapshot("restore", expectedEpoch)) {1886            return false1887        }1888        // H-04: legacy snapshots without vault_d, or vault_d that fails to1889        // decode, fall back to a fake duress of slot A's size — presence-1890        // indistinguishable from any other freshly-restored vault.1891        val nextD: StoredVault = restoredD ?: run {1892            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161893            val fakeBlob = ByteArray(target).also(rng::nextBytes)1894            val fakeSalt = VaultCrypto.generateSalt()1895            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1896        }1897        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1898            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1899            return false1900        }1901        lock()1902        true1903    }19041905    /**1906     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1907     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1908     * (a restore preserves the duress-shaped blob from history), not a fresh1909     * noise record. Any failure rolls back so the at-rest pair never holds a1910     * restored slot A against a pre-restore slot B. Caller is responsible for1911     * restoring the pre-snapshot history on a false return.1912     *1913     * H-04: duress slot is now part of the atomic triad — restored last so1914     * a duress-write failure rolls the A/B pair back too.1915     */1916    private fun commitRestoreAtomic(1917        nextA: StoredVault,1918        nextB: String,1919        nextD: StoredVault,1920        priorA: StoredVault?,1921        priorB: String?,1922        priorD: StoredVault?,1923        expectedEpoch: Long,1924    ): Boolean {1925        return withEpochCommit(expectedEpoch) {1926            if (!store.writeSlotA(nextA)) return@withEpochCommit false1927            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1928            // must trigger rollback; previously a throw escaped without restoring1929            // the prior slot A and history.1930            try {1931                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1932                // H-04: duress slot is part of the restore triad. Failures here1933                // roll A+B back so the at-rest state stays consistent.1934                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1935                store.stampLocalMutation()1936            } catch (_: Exception) {1937                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1938                return@withEpochCommit false1939            }1940            true1941        } ?: false1942    }19431944    // ---- Sync apply (does NOT advance the version) ------------------------19451946    /**1947     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1948     * WITHOUT bumping the version counter, so a sync round cannot cascade into1949     * another outbound sync. Returns the number of locally-applied changes.1950     */1951    suspend fun applyMergedRemoteEntries(1952        remoteEntries: List<VaultEntry>,1953        remoteTombstones: List<Tombstone>,1954        expectedEpoch: Long? = null,1955    ): Int = mutex.withLock {1956        val epoch = expectedEpoch ?: requireUnlocked()1957        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01958        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1959        // BEFORE any comparison against a local one. Without this a peer can1960        // send a far-future modifiedAt and permanently pin its version of an1961        // entry against every honest local edit. The browser clamps in the1962        // matching place (vault.ts applyMergedRemoteEntries).1963        val mergeNow = System.currentTimeMillis()1964        val clamped = remoteEntries.map { entry ->1965            entry.copy(1966                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1967                deletedAt = entry.deletedAt?.let {1968                    SyncLimits.clampSyncTimestamp(it, mergeNow)1969                },1970            )1971        }1972        val tombMap = remoteTombstones.associate {1973            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1974        }1975        // H-03: build the new entryList under stateLock so a concurrent lock()1976        // cannot zero derivedKey/entryList between the byId read and the merge.1977        val applied = mutateCurrentSession(epoch) {1978            var n = 01979            val byId = entryList.associateBy { it.id }.toMutableMap()19801981            for (remote in clamped) {1982                val tomb = tombMap[remote.id]1983                if (tomb != null && tomb >= remote.modifiedAt) continue1984                val local = byId[remote.id]1985                if (local == null) {1986                    entryList.add(remote); byId[remote.id] = remote; n++1987                } else if (remote.modifiedAt > local.modifiedAt) {1988                    val i = entryList.indexOfFirst { it.id == local.id }1989                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1990                }1991            }19921993            for ((id, deletedAt) in tombMap) {1994                val local = byId[id] ?: continue1995                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1996                if (deletedAt < local.modifiedAt) continue1997                val i = entryList.indexOfFirst { it.id == id }1998                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1999            }2000            n2001        } ?: return@withLock 020022003        if (applied > 0) {2004            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2005            mutateIfCurrentEpoch(epoch) { publish() }2006        }2007        applied2008    }20092010    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2011        // Require a fully live session before exposing a settings mutation.  The2012        // epoch is captured with the read so a lock/new-session boundary cannot2013        // let stale work publish into the replacement session.2014        val epoch = requireUnlocked()2015        val prior = mutateCurrentSession(epoch) {2016            val old = _settings.value2017            _settings.value = settings2018            old2019        } ?: throw IllegalStateException("vault_locked")20202021        val committed = try {2022            beforeDurableCommit()2023            // The durable write is inside the same epoch fence as the in-memory2024            // publication.  If lock() won first, this returns null and no stale2025            // settings reach storage or the public StateFlow.2026            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2027        } catch (_: Exception) {2028            false2029        }2030        if (!committed) {2031            // A live-session storage failure rolls back the public value.  A2032            // stale epoch is already reset by lock()/the new session and must2033            // not be allowed to overwrite that safe state.2034            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2035            throw IllegalStateException("settings_write_failed")2036        }2037    }20382039    // ---- Recovery words (vault metadata) ----------------------------------20402041    /** True once the user has confirmed their recovery words for this vault. */2042    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20432044    /**2045     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2046     * Persisted without bumping the version — recovery metadata is local-only and2047     * is not an entry mutation paired devices should sync against.2048     */2049    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2050        val epoch = requireUnlocked()2051        // H-03: hash + escrow-related state fields set under stateLock so a2052        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2053        // M-04: the flags have to be live before the escrows are built — both seal2054        // currentPayload() — so capture the prior pair and put it back if the write2055        // never lands. Otherwise the session reports words set against a slot A2056        // that still holds no escrow, and the recovery screen never offers a retry.2057        val prior = mutateCurrentSession(epoch) {2058            val priorPair = recoveryWordHash to recoveryWordsSet2059            recoveryWordHash = recoveryWordsHash(words)2060            recoveryWordsSet = true2061            priorPair2062        } ?: return@withLock false2063        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2064        // (cross-platform recovery of the sync root key) and Android's whole-payload2065        // escrow (offline forgot-password restore of entries). Both derive their key2066        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2067        val persisted = runCatching {2068            persistSlotAWithEscrows(2069                buildRecoveryEscrow(words),2070                buildRootKeyEscrow(words),2071                expectedEpoch = epoch,2072            )2073        }.getOrDefault(false)2074        if (!persisted) {2075            mutateIfCurrentEpoch(epoch) {2076                recoveryWordHash = prior.first2077                recoveryWordsSet = prior.second2078            }2079        }2080        persisted2081    }20822083    /** Verify entered words against the stored recovery hash. */2084    fun verifyRecoveryWords(words: List<String>): Boolean {2085        return mutateState {2086            val stored = recoveryWordHash ?: return@mutateState false2087            stored == recoveryWordsHash(words)2088        }2089    }20902091    // ---- Recovery escrow (forgot-password) --------------------------------20922093    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2094    fun hasRecoveryEscrow(): Boolean =2095        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20962097    /**2098     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2099     * the master password. On success the slot-A plaintext sealed in the escrow is2100     * adopted as the live session (entries, rootKey, settings), but no Argon2id2101     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2102     * new password and finish unlocking. Returns false when there is no escrow, the2103     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2104     *2105     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2106     * detached vault key to return here — slot A is wrapped under the rotating2107     * Argon2id key — so this opens the escrowed payload directly.2108     */2109    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2110        val expectedEpoch = currentEpoch()2111        val slotA = store.loadSlotA() ?: return false2112        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21132114        // Prefer Android's whole-payload escrow: a full offline restore of entries,2115        // root key, and settings. Present for Android-created vaults.2116        val fullEscrow = slotA.recoveryEscrow2117        if (fullEscrow != null) {2118            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2119            if (material != null) {2120                var keyBytes: ByteArray? = null2121                var plaintext: ByteArray? = null2122                var candidateRoot: ByteArray? = null2123                var stateCommitStarted = false2124                try {2125                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2126                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2127                    val payload = VaultCodec.decode(plaintext)2128                    // Stage every fallible operation before touching live session2129                    // fields.  In particular, a settings/provider/decode failure2130                    // cannot leave entries, flags, or a root key resident while the2131                    // public session remains locked.2132                    val candidateSettings = store.loadSettings()2133                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2134                        val decodedRoot = unb64(payload.rootKeyB64)2135                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2136                            // Do not let a malformed authenticated payload abandon2137                            // this decoded root-key candidate before the outer2138                            // finally block owns it.2139                            VaultCrypto.zero(decodedRoot)2140                            throw IllegalArgumentException("invalid recovery root key")2141                        }2142                        decodedRoot2143                    } else {2144                        generateRecoverableRootKey()2145                    }2146                    stateCommitStarted = true2147                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2148                        clearSessionStateLocked()2149                        entryList = payload.entries.toMutableList()2150                        duressConfigured = payload.duressConfigured2151                        recoveryWordHash = payload.recoveryWordHash2152                        recoveryWordsSet = payload.recoveryWordsSet2153                        rootKey = candidateRoot2154                        candidateRoot = null2155                        _settings.value = candidateSettings2156                        publish()2157                        // This is the authenticated recovery half-unlock boundary:2158                        // only set it after every candidate field is installed.2159                        sessionDataAvailable = true2160                        _isUnlocked.value = false2161                        true2162                    }2163                    if (adopted == true) return@withLock true2164                } catch (_: Exception) {2165                    // Wrong words, malformed payloads, provider failures, and2166                    // settings failures all fall through to the browser-compatible2167                    // root-key escrow.  If the state commit had started, invalidate2168                    // that epoch so no partial candidate survives.2169                    if (stateCommitStarted) {2170                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2171                    }2172                } finally {2173                    plaintext?.let(VaultCrypto::zero)2174                    VaultCrypto.zero(material)2175                    keyBytes?.let(VaultCrypto::zero)2176                    candidateRoot?.let(VaultCrypto::zero)2177                }2178            }2179        }21802181        // Browser-format vault (only the root-key escrow): recover the 32-byte root2182        // key (sync identity) and reset the password; entries return via sync. This2183        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2184        val rootEscrow = slotA.rootKeyEscrow2185        if (rootEscrow != null) {2186            var candidateRoot: ByteArray? = null2187            var stateCommitStarted = false2188            try {2189                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2190                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2191                val candidateSettings = store.loadSettings()2192                stateCommitStarted = true2193                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2194                    clearSessionStateLocked()2195                    entryList = mutableListOf()2196                    duressConfigured = false2197                    recoveryWordHash = recoveryWordsHash(words)2198                    recoveryWordsSet = true2199                    rootKey = candidateRoot2200                    candidateRoot = null2201                    _settings.value = candidateSettings2202                    publish()2203                    sessionDataAvailable = true2204                    _isUnlocked.value = false2205                    true2206                }2207                if (adopted == true) return@withLock true2208            } catch (_: Exception) {2209                if (stateCommitStarted) {2210                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2211                }2212            } finally {2213                candidateRoot?.let(VaultCrypto::zero)2214            }2215        }2216        return false2217    }22182219    /**2220     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2221     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2222     * words, mark the session fully unlocked, and publish. Returns false if called2223     * without a prior successful recovery unlock (no in-memory root key).2224     *2225     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2226     * into one step, since there is no detached vault key here.2227     */2228    suspend fun resetPasswordWithRecoveryWords(2229        words: List<String>,2230        newPassword: String,2231    ): Boolean = mutex.withLock {2232        val expectedEpoch = currentEpoch()2233        // H-03: read rootKey + entryList under stateLock before computing the new2234        // plaintext, then re-publish + swap the derived key under stateLock so a2235        // concurrent lock() cannot straddle the two fences.2236        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2237        val priorA = store.loadSlotA() ?: return false2238        var newKey: ByteArray? = null2239        var plaintext: ByteArray? = null2240        return try {2241            val newSalt = VaultCrypto.generateSalt()2242            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2243            plaintext = VaultCodec.encode(currentPayload())2244            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2245            // Rebuild both escrows against the recovered session so their salts rotate2246            // with the password reset; fall back to the prior escrows if rebuild fails2247            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2248            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2249            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2250            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2251            val next = StoredVault(2252                version = priorA.version,2253                salt = b64(newSalt),2254                blob = blob,2255                recoveryEscrow = freshRecovery,2256                rootKeyEscrow = freshRootKey,2257            )2258            // H-01: see changePassword — both rotations share the same2259            // commit-or-keep-old-key contract so a partial write can't strand2260            // the session on a key that no longer matches disk.2261            val priorB = store.loadSlotB()2262            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2263            // H-03 (P1-2 codex): zero the old live key and install the new one2264            // inside the same stateLock critical section so a concurrent lock()2265            // can't observe the in-between.2266            if (mutateIfCurrentEpoch(expectedEpoch) {2267                derivedKey?.let(VaultCrypto::zero)2268                derivedKey = newKey; newKey = null2269                publish()2270                _isUnlocked.value = true2271            } == null) return false2272            true2273        } catch (_: Exception) {2274            false2275        } finally {2276            newKey?.let(VaultCrypto::zero)2277            plaintext?.let(VaultCrypto::zero)2278        }2279    }22802281    /**2282     * Seal the current slot-A plaintext under a key derived from the 12 BIP392283     * recovery [words] (Android offline-restore escrow). IKM is the browser's2284     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2285     * Returns null for a checksum-invalid phrase.2286     */2287    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2288        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2289        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2290        var keyBytes: ByteArray? = null2291        var plaintext: ByteArray? = null2292        return try {2293            val salt = VaultCrypto.generateSalt()2294            val recoveryKey = deriveRecoveryKey(material, salt)2295            keyBytes = recoveryKey.encoded2296            plaintext = VaultCodec.encode(currentPayload())2297            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2298            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2299        } finally {2300            VaultCrypto.zero(material)2301            keyBytes?.let(VaultCrypto::zero)2302            plaintext?.let(VaultCrypto::zero)2303        }2304    }23052306    /**2307     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2308     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2309     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2310     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2311     * the same root key on the browser. Returns null with no in-memory root key or2312     * a checksum-invalid phrase.2313     */2314    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2315        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2316        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2317        val rk = mutateState { rootKey?.copyOf() } ?: run {2318            VaultCrypto.zero(material)2319            return null2320        }2321        var keyBytes: ByteArray? = null2322        var plaintext: ByteArray? = null2323        return try {2324            val salt = VaultCrypto.generateSalt()2325            val recoveryKey = deriveRecoveryKey(material, salt)2326            keyBytes = recoveryKey.encoded2327            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2328            // combined = nonce(12) || ciphertext || tag(16)2329            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2330            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2331            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2332            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2333            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2334            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2335        } finally {2336            VaultCrypto.zero(rk)2337            VaultCrypto.zero(material)2338            keyBytes?.let(VaultCrypto::zero)2339            plaintext?.let(VaultCrypto::zero)2340        }2341    }23422343    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2344    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2345        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2346        var keyBytes: ByteArray? = null2347        var pt: ByteArray? = null2348        return try {2349            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2350            keyBytes = recoveryKey.encoded2351            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2352            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2353            hexToRootKey(String(pt, Charsets.US_ASCII))2354        } catch (_: Exception) {2355            null2356        } finally {2357            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2358            // IKM-equivalent of the vault identity. material+keyBytes were2359            // already zeroed; pt was leaking.2360            VaultCrypto.zero(material)2361            keyBytes?.let(VaultCrypto::zero)2362            pt?.let(VaultCrypto::zero)2363        }2364    }23652366    /**2367     * Re-seal slot A from the current in-memory state under the live key, carrying2368     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2369     * in decoy mode (recovery is never offered for the decoy) and persists without2370     * a version bump, like the recovery-words metadata write it replaces.2371     */2372    private fun persistSlotAWithEscrows(2373        recoveryEscrow: RecoveryEscrow?,2374        rootKeyEscrow: RootKeyEscrow?,2375        expectedEpoch: Long? = null,2376    ): Boolean {2377        val snapshot = snapshotForPersist(expectedEpoch)2378            ?: throw IllegalStateException("vault_locked_or_stale")2379        if (snapshot.decoy) {2380            VaultCrypto.zero(snapshot.key)2381            return false2382        }2383        try {2384            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2385            val plaintext = VaultCodec.encode(snapshot.payload)2386            val blob = try {2387                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2388            } finally {2389                // The escrow path used to drop this reference before zeroing it;2390                // keep it live through the fill on both success and failure.2391                VaultCrypto.zero(plaintext)2392            }2393            val next = prior.copy(2394                blob = blob,2395                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2396                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2397            )2398            beforeDurableCommit()2399            return withEpochCommit(snapshot.epoch) {2400                if (!store.writeSlotA(next)) return@withEpochCommit false2401                try {2402                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2403                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2404                    // the duress slot too. A real decoy is left alone; a fake is2405                    // re-sized to slot A's new ciphertext length.2406                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2407                } catch (e: Exception) {2408                    store.writeSlotA(prior)2409                    throw e2410                }2411                true2412            } ?: false2413        } finally {2414            VaultCrypto.zero(snapshot.key)2415        }2416    }24172418    // ---- Internals --------------------------------------------------------24192420    private fun requireUnlocked(): Long {2421        // H-03: read derivedKey atomically with [lock]'s reset; without it,2422        // [lock] from a background lifecycle event can run between this read2423        // and the caller's first state touch.2424        stateLock.lock()2425        try {2426            if (derivedKey == null) throw IllegalStateException("vault_locked")2427            return sessionEpoch2428        } finally {2429            stateLock.unlock()2430        }2431    }24322433    /** A locked or half-adopted session must not expose secret-bearing fields. */2434    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24352436    private fun countDuplicateEntriesLocked(): Int {2437        val seen = mutableMapOf<String, VaultEntry>()2438        var duplicates = 02439        for (entry in entryList.filter { it.deletedAt == null }) {2440            val key = entry.duplicateKey()2441            val existing = seen[key]2442            if (existing == null) {2443                seen[key] = entry2444            } else {2445                duplicates++2446                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2447                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2448                if (entryStamp > existingStamp) seen[key] = entry2449            }2450        }2451        return duplicates2452    }24532454    private fun VaultEntry.duplicateKey(): String {2455        if (type == "note") return "note|${title.lowercase()}"2456        val passkeyData = passkey2457        if (passkeyData != null) {2458            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2459        }2460        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2461    }24622463    /**2464     * Capture the current slot pair (and metadata) into the history list.2465     * Returns true when the history write landed, false when it didn't — callers2466     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2467     * must check the result and roll back if it does not.2468     */2469    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2470        val epoch = expectedEpoch ?: currentEpoch()2471        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2472        val slotA = store.loadSlotA() ?: return false2473        val vaultA = VaultCodec.encodeSlot(slotA)2474        val vaultB = store.loadSlotB() ?: slotA.blob2475        // H-04: capture the duress slot so a restore preserves the at-rest2476        // triad, not just the A/B pair. The duress slot is always present2477        // (real or fake), so loadDecoySlot() returning null is a logic error2478        // — fall through to a fake duress in that case rather than silently2479        // losing the record from the snapshot.2480        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2481            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162482            val fakeBlob = ByteArray(target).also(rng::nextBytes)2483            val fakeSalt = VaultCrypto.generateSalt()2484            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2485        }2486        val history = store.loadVaultHistory()2487        val latest = history.snapshots.firstOrNull()2488        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2489        val entryCount = mutateIfCurrentEpoch(epoch) {2490            if (derivedKey != null && !decoyMode) {2491                bucketEntryCount(entryList.count { it.deletedAt == null })2492            } else {2493                null2494            }2495        } ?: return false2496        return try {2497            val snapshot = VaultSnapshot(2498                id = UUID.randomUUID().toString(),2499                savedAt = System.currentTimeMillis(),2500                reason = reason,2501                vault_a = vaultA,2502                vault_b = vaultB,2503                vault_d = vaultD,2504                entryCount = entryCount,2505            )2506            // P1-4 (codex): return the history-write boolean to callers so a2507            // false return no longer leaves the snapshot out of history while2508            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2509            withEpochCommit(epoch) {2510                store.writeVaultHistory(2511                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2512                )2513            } ?: false2514        } catch (_: Exception) {2515            false2516        }2517    }25182519    private fun bucketEntryCount(n: Int): Int = when {2520        n <= 0 -> 02521        n <= 10 -> 102522        n <= 25 -> 252523        n <= 50 -> 502524        n <= 100 -> 1002525        n <= 250 -> 2502526        n <= 500 -> 5002527        else -> ((n + 499) / 500) * 5002528    }25292530    /**2531     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2532     * so it is stored explicitly and survives the sync wire format rather than being2533     * re-derived at render time. Non-card entries pass through unchanged.2534     *2535     * M-03: never overwrite a stored last4 with null. A card kept only as last42536     * (e.g. after a PeachScript import that had no PAN) must keep that value;2537     * re-deriving from an absent cardNumber would silently zero the field.2538     */2539    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2540        if (!entry.isCard()) return entry2541        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2542        val derived = if (digits.length >= 4) digits.takeLast(4) else null2543        return entry.copy(last4 = derived ?: entry.last4)2544    }25452546    /**2547     * On unlock, hard-delete trashed entries older than the retention window.2548     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2549     * (the app's "Never" option), so nothing is purged. Persists without a2550     * version bump and without touching the publish state (caller publishes).2551     */2552    private fun autoPurgeExpiredTrash() {2553        val days = _settings.value.trashRetentionDays ?: return2554        if (days <= 0) return2555        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2556        val before = entryList.size2557        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2558        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2559    }25602561    /** The slot-A plaintext envelope for the current in-memory session. */2562    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25632564    private fun currentPayloadLocked(): VaultPayload =2565        VaultPayload(2566            entries = entryList.toList(),2567            duressConfigured = duressConfigured,2568            rootKeyB64 = rootKey?.let(::b64) ?: "",2569            recoveryWordHash = recoveryWordHash,2570            recoveryWordsSet = recoveryWordsSet,2571        )25722573    /**2574     * Adopt the payload's root key, lazily minting one for legacy payloads.2575     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2576     * a root key is not a mutation paired devices should sync against.2577     */2578    private fun ensureRootKey(payload: VaultPayload) {2579        if (payload.rootKeyB64.isNotBlank()) {2580            rootKey = unb64(payload.rootKeyB64)2581            return2582        }2583        rootKey = generateRecoverableRootKey()2584        runCatching { persistSlotA(bumpVersion = false) }2585    }25862587    private fun publish() {2588        _entries.value = entryList.filter { it.deletedAt == null }2589    }25902591    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2592        var plaintext = VaultCodec.encode(currentPayload())2593        return try {2594            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2595            StoredVault(version = 1, salt = b64(salt), blob = blob)2596        } finally {2597            // M-05: full vault plaintext is allocated here once per createVault.2598            // Zeroing in finally keeps the transient copy off the heap after the2599            // caller either succeeds or rolls back via lock().2600            VaultCrypto.zero(plaintext)2601        }2602    }26032604    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2605        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2606        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2607        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2608        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2609        val noise = ByteArray(approxBytes).also(rng::nextBytes)2610        return store.writeSlotB(b64(noise))2611    }26122613    /** H-04: the ciphertext length slot A is using right now, so the duress2614     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2615     *  length equals ciphertext length, so this also matches slot A's plaintext. */2616    private fun matchingCiphertextLength(slotA: StoredVault): Int =2617        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26182619    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2620     *  bytes. Replaces a real decoy when the user clears duress, and is the2621     *  initial duress slot when no decoy is configured — so the record's2622     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2623     *  matches slot A's blob length. */2624    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2625        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162626        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2627        val fakeSalt = VaultCrypto.generateSalt()2628        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2629    }26302631    /** H-04: keep the duress record presence- and size-indistinguishable from2632     *  slot A on every persist. A real decoy is left alone (it was padded at2633     *  setDuressPassphrase time, and we don't have the duress key here to2634     *  re-pad on entry growth — that's an accepted residual tell, see the2635     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2636     *  A's size. */2637    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2638        val isConfigured = mutateState { duressConfigured }2639        return if (isConfigured) true else writeFakeDuressSlot(slotA)2640    }26412642    /**2643     * H-04: convert a browser-shaped duress slot to Android StoredVault2644     * without decrypting. The browser seals slot "b" under the duress2645     * passphrase; decrypting with the main password silently drops it.2646     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2647     * the salt from hex to base64.2648     */2649    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2650        val salt = unhex(b.salt)2651        val nonce = unhex(b.nonce)2652        val ct = unhex(b.ciphertext)2653        val tag = unhex(b.tag)2654        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2655            System.arraycopy(nonce, 0, it, 0, nonce.size)2656            System.arraycopy(ct, 0, it, nonce.size, ct.size)2657            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2658        }2659        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2660    }26612662    /** Re-seal slot A from the current in-memory state under the live key. */2663    private fun persistSlotA(2664        bumpVersion: Boolean,2665        snapshotReason: String? = "local_save",2666        expectedEpoch: Long? = null,2667    ): Boolean {2668        val snapshot = snapshotForPersist(expectedEpoch)2669            ?: throw IllegalStateException("vault_locked_or_stale")2670        return try {2671            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2672            if (snapshot.decoy) {2673                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2674                val plaintext = VaultCodec.encode(snapshot.payload)2675                val blob = try {2676                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2677                } finally {2678                    // Keep the last reference alive until the zero, including the2679                    // successful encrypt path (M-05).2680                    VaultCrypto.zero(plaintext)2681                }2682                beforeDurableCommit()2683                return withEpochCommit(snapshot.epoch) {2684                    store.writeDecoySlot(prior.copy(blob = blob))2685                } ?: false2686            }2687            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2688            // H-01: captured BEFORE the noise write so a stamp failure can restore2689            // the prior slot B alongside the prior slot A. Slot B is always2690            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2691            // leaves (new A, new B, old version) on disk — partial commit.2692            val priorB = store.loadSlotB()2693            beforeDurableCommit()2694            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2695            val plaintext = VaultCodec.encode(snapshot.payload)2696            val blob = try {2697                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2698            } finally {2699                // Do not drop [plaintext] before fill(0): the serialized vault2700                // contains every entry password and is the primary M-05 target.2701                VaultCrypto.zero(plaintext)2702            }2703            val next = prior.copy(blob = blob)2704            // The epoch check and the entire A/B/D/version commit share one brief2705            // stateLock critical section. lock() either wins before this block or2706            // waits until the already-committed write is complete; it can never2707            // clear the session between the check and the durable write.2708            withEpochCommit(snapshot.epoch) {2709                if (!store.writeSlotA(next)) return@withEpochCommit false2710                try {2711                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2712                    // H-04: duress slot stays present (real one untouched, fake one2713                    // re-padded to slot A's current size on every persist).2714                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2715                    if (bumpVersion) store.stampLocalMutation()2716                } catch (e: Exception) {2717                    store.writeSlotA(prior)2718                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2719                    throw e2720                }2721                true2722            } ?: false2723        } finally {2724            // Snapshot keys are copies, so lock() may zero the live key while this2725            // operation is still deriving/encrypting; always scrub our copy too.2726            VaultCrypto.zero(snapshot.key)2727        }2728    }27292730    /**2731     * H-01: durably commit a slot-A rotation by writing slot A, its2732     * size-matched noise slot B, and the local-mutation stamp in order. Any2733     * failure rolls the previously-committed steps back so the at-rest pair2734     * matches what was there before the call. Returns true only when every2735     * step succeeded — callers must NOT swap their session key on false.2736     */2737    private fun commitVaultRotation(2738        priorA: StoredVault,2739        priorB: String?,2740        nextA: StoredVault,2741        expectedEpoch: Long,2742    ): Boolean {2743        beforeDurableCommit()2744        return withEpochCommit(expectedEpoch) {2745            if (!store.writeSlotA(nextA)) return@withEpochCommit false2746            // P1-1 (codex): the noise write's boolean AND any thrown exception from2747            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2748            // escaped the helper and left slot A committed.2749            try {2750                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2751                // H-04: a password rotation changes slot A's plaintext size (new2752                // salt → re-encrypted blob), so the duress slot must be re-padded2753                // alongside. writeCompanionDuressSlot leaves a real decoy alone2754                // and re-emits a fake of slot A's size otherwise.2755                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2756                store.stampLocalMutation()2757            } catch (_: Exception) {2758                rollbackRotation(priorA, priorB)2759                return@withEpochCommit false2760            }2761            true2762        } ?: false2763    }27642765    /**2766     * Best-effort undo of the writes [commitVaultRotation] may have committed.2767     * Each step runs independently — a failed rollback must not mask the2768     * original commit failure. Log is a throwing stub off-device.2769     */2770    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2771        val failures = mutableListOf<String>()2772        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2773            .onFailure { failures += "writeSlotA: $it" }2774        if (priorB != null) {2775            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2776                .onFailure { failures += "writeSlotB: $it" }2777        }2778        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2779    }27802781    /** Persist a local mutation, rolling the in-memory list back on failure. */2782    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2783        try {2784            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2785                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2786                throw IllegalStateException("storage_write_failed")2787            }2788        } catch (e: Exception) {2789            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2790            throw e2791        }2792        // A lock can win in the small interval after the durable commit and2793        // before publication. The epoch check prevents pre-lock entries from2794        // being republished into a newly locked (or newly unlocked) session.2795        mutateIfCurrentEpoch(expectedEpoch) { publish() }2796    }2797}