VaultRepository.kt

134.6 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()191192193    /**194     * UI-only gate for sync-retained background mode. The vault stays unlocked195     * in memory (sync can proceed) but the UI shows the lock screen until the196     * user authenticates on resume. Cleared by a full [lock] or an explicit197     * [clearUiGate] from the lock screen.198     */199    private val _uiGateLocked = MutableStateFlow(false)200    val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()201202    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())203    /** Active (non-deleted) entryList. */204    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()205206    private val _settings = MutableStateFlow(VaultSettings())207    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()208209    /** Clear every in-memory session field while [stateLock] is held. */210    private fun clearSessionStateLocked() {211        derivedKey?.let(VaultCrypto::zero)212        rootKey?.let(VaultCrypto::zero)213        derivedKey = null214        rootKey = null215        entryList = mutableListOf()216        duressConfigured = false217        recoveryWordHash = null218        recoveryWordsSet = false219        sessionDataAvailable = false220        decoyMode = false221        _settings.value = VaultSettings()222        _entries.value = emptyList()223        _isUnlocked.value = false224    }225226    fun hasVault(): Boolean = store.hasVault()227228    // ---- Codex (printed paper backup) -------------------------------------229230    /**231     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the232     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's233     * A/B naming where slot B is the duress decoy); the noise slot is local-only234     * and never crosses devices. Restorable on any device that has the master235     * password. Null when no vault exists.236     */237    fun codexVaultBlobBase64(): String? {238        val slotA = store.loadSlotA() ?: return null239        // H-04: duress slot is always present after createVault (real or fake),240        // so the wrapper can rely on it being loadable.241        val slotD = store.loadDecoySlot() ?: return null242        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""243        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))244    }245246    /**247     * Replace the local vault from a Codex blob, then unlock with [password].248     * Writes slot A (and a size-matched noise slot B) before unlocking so the249     * restored vault is at rest exactly as a freshly-created one would be.250     */251    /** A slot is stored either as a JSON string (browser) or inline object (Android). */252    private fun JsonElement?.asSlotJson(): String? = when {253        this == null || this is JsonNull -> null254        this is JsonPrimitive && isString -> content255        else -> toString()256    }257258    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {259        val expectedEpoch = currentEpoch()260        var candidateKey: ByteArray? = null261        var plaintext: ByteArray? = null262        return try {263            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)264            // Parsed as JSON, not matched with a regex. The wrapper is265            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which266            // generates most real Codexes — writes each slot as a JSON267            // *string*, with "b" present. A regex looking for an object268            // literal under "a" at the very end of the wrapper matches269            // neither of those, so every browser-generated Codex failed here270            // with "invalid_codex_blob" after decoding perfectly.271            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject272                ?: return UnlockResult.Error("invalid_codex_blob")273            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")274            // A Codex printed from the browser carries a browser-shaped slot,275            // which is the common case — most Codexes are printed there. It is276            // translated to Android's shape (see BrowserSlot) rather than277            // rejected. A translation failure is a wrong password, not a bad278            // blob: the codes decoded fine, the password just didn't open them.279            val slotA = VaultCodec.decodeSlot(slotJson)280                ?: BrowserSlot.parse(slotJson)?.let {281                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword282                }283                ?: return UnlockResult.Error("invalid_codex_blob")284            // C-01: authenticate the candidate ENTIRELY in memory before touching285            // storage. VaultCodec.decodeSlot is structural — an Android-shaped286            // Codex parses under ANY password — so writing slot A first and287            // unlocking afterwards destroyed the device's existing vault every288            // time the user mistyped the password.289            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))290            plaintext = decryptSlotABlob(slotA, candidateKey)291            // LV-1: a Codex printed from a vault sealed before NFC normalization292            // opens only under the raw password bytes. unlockLocked used to supply293            // this fallback; authenticating up front here has to keep it, or those294            // Codexes become unrestorable.295            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {296                VaultCrypto.zero(candidateKey)297                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))298                plaintext = decryptSlotABlob(slotA, candidateKey)299            }300            if (plaintext == null) return UnlockResult.WrongPassword301            val payload = try {302                VaultCodec.decode(plaintext)303            } catch (_: Exception) {304                return UnlockResult.Error("invalid_codex_blob")305            }306307            val priorA = store.loadSlotA()308            val priorB = store.loadSlotB()309            val priorD = store.loadDecoySlot()310            // Carry the real duress slot across when the Codex has one — the311            // browser writes "b" as its duress-shaped slot, and dropping it312            // here would silently destroy a configured decoy.313            // H-04: write browser B into the duress slot (not the noise slot),314            // translating browser-shaped slots to Android's StoredVault shape.315            val codexDuressJson = wrapper["b"].asSlotJson()316            val codexDuress: StoredVault? = codexDuressJson?.let {317                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->318                    // H-04: pass-through — the browser duress slot is sealed319                    // under the duress passphrase, not the main password. Trying320                    // to decrypt with the slot-A password fails the GCM tag and321                    // silently drops a real decoy. Re-encode the browser hex322                    // fields as Android base64 salt/blob so tryUnlockDuress can323                    // still open it with the duress passphrase.324                    browserDuressPassthrough(b)325                }326            }327328            beforeDurableCommit()329            val committed = runCatching {330                withEpochCommit(expectedEpoch) {331                    try {332                        // Keep every slot write, including the inverse, under the333                        // same epoch fence. Releasing stateLock after a partial334                        // write and reacquiring it for rollback lets lock() advance335                        // the epoch in between and strand the incoming slot A.336                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")337                        // Noise slot is always size-matched to slot A — local-only,338                        // never written from a Codex.339                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")340                        // H-04: duress slot always present; real one from the Codex or341                        // a fake the size of slot A.342                        val duressOk = if (codexDuress != null) {343                            store.writeDecoySlot(codexDuress)344                        } else {345                            writeFakeDuressSlot(slotA)346                        }347                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")348                        store.stampLocalMutation()349                        true350                    } catch (_: Exception) {351                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))352                        // Keep the fence held through the final rollback353                        // observation. A concurrent lock() may queue here, but354                        // cannot invalidate the restored A/B/D triad.355                        afterCodexRollback()356                        false357                    }358                } ?: false359            }.getOrDefault(false)360            if (!committed) {361                return UnlockResult.Error("storage_write_failed")362            }363364            val ownedKey = candidateKey!!365            candidateKey = null // the session owns the key from here on366            adoptSlotA(ownedKey, payload, expectedEpoch)367        } catch (e: Exception) {368            // A raw kotlinx parse error ("Unexpected JSON token at offset N")369            // reaching the user says nothing about WHICH json failed — the370            // wrapper, or the payload decrypted out of it. Codex screens are371            // FLAG_SECURE so this can't be screenshotted either. Debug builds372            // log the structure around the failure; alphanumerics are masked so373            // no ciphertext or vault content lands in a log.374            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))375            UnlockResult.Error(e.message ?: "restore_failed")376        } finally {377            plaintext?.let(VaultCrypto::zero)378            candidateKey?.let(VaultCrypto::zero)379        }380    }381382    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */383    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =384        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()385386    /**387     * Undo a partially-committed Codex restore while [stateLock] is already held.388     * Every step is attempted independently so one failure cannot strand the389     * others. Callers that need epoch validation must enter through390     * [withEpochCommit] first; this helper deliberately does not reacquire the391     * lock, avoiding a reentrant gap between a failed write and its inverse.392     * With no prior vault the accurate inverse is having none at all, rather393     * than a half-written slot A that [hasVault] would report as a real vault.394     */395    private fun rollbackRestoreLocked(396        priorA: StoredVault?,397        priorB: String?,398        priorD: StoredVault? = null,399    ): List<String> {400        val failures = mutableListOf<String>()401        if (priorA == null) {402            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }403        } else {404            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }405                .onFailure { failures += "writeSlotA: $it" }406            if (priorB != null) {407                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }408                    .onFailure { failures += "writeSlotB: $it" }409            }410            // H-04: restore the prior duress slot — a half-committed restore that411            // left the duress record on the Codex side and slot A on the prior412            // side would put the at-rest pair in a state that never existed.413            if (priorD != null) {414                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }415                    .onFailure { failures += "writeDecoySlot: $it" }416            } else {417                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }418            }419        }420        return failures421    }422423    private fun reportRollbackFailures(failures: List<String>) {424        // Log is a throwing stub off-device, and a failed log must never mask425        // the rollback result the caller is about to report.426        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }427    }428429    /** Debug-only: structure of a failed Codex restore, with content masked. */430    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {431        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)432        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()433        val window = offset?.let {434            val from = (it - 40).coerceAtLeast(0)435            val to = (it + 40).coerceAtMost(wrapperJson.length)436            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")437        }438        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()439        val slot = wrapper?.get("a")440        buildString {441            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)442            append(" | blobBase64=").append(vaultBlobBase64.length)443            append(" wrapperChars=").append(wrapperJson.length)444            append(" wrapperParses=").append(wrapper != null)445            append(" keys=").append(wrapper?.keys)446            append(" slotAKind=").append(447                when {448                    slot == null -> "absent"449                    slot is JsonPrimitive && slot.isString -> "string"450                    slot is JsonObject -> "object:" + slot.keys451                    else -> slot::class.simpleName452                },453            )454            if (window != null) append(" masked@").append(offset).append("=").append(window)455        }456    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }457458    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {459        val expectedEpoch = currentEpoch()460        var key: ByteArray? = null461        return try {462            val salt = VaultCrypto.generateSalt()463            key = VaultCrypto.deriveKey(password, salt)464            if (mutateIfCurrentEpoch(expectedEpoch) {465                entryList = mutableListOf()466                duressConfigured = false467                recoveryWordHash = null468                recoveryWordsSet = false469                decoyMode = false470                sessionDataAvailable = false471                rootKey = generateRecoverableRootKey()472            } == null) return CreateVaultResult.Error("stale_create")473            // M-04: the fields above have to be live before sealSlotA, since it474            // seals currentPayload(). That stages a root key the session would475            // otherwise keep after a failed create — [lock] discards the whole476            // staged session on every non-success exit below.477            val slotA = sealSlotA(key!!, salt)478            // P1-3 (codex): every later step must roll the slot pair back if it479            // fails — leaving a half-built vault is worse than refusing to make it.480            beforeDurableCommit()481            val created = withEpochCommit(expectedEpoch) {482                if (!store.writeSlotA(slotA)) return@withEpochCommit false483                try {484                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")485                    // H-04: duress slot must be present from the start so its486                    // existence on disk is independent of duressConfigured.487                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")488                    store.stampLocalMutation()489                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")490                    true491                } catch (_: Exception) {492                    // Half-built vault on disk is worse than no vault — refuse to493                    // create if any step past slot A fails.494                    store.clearVault()495                    false496                }497            } ?: false498            if (!created) {499                lock()500                return CreateVaultResult.Error("storage_write_failed")501            }502            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")503            if (mutateIfCurrentEpoch(expectedEpoch) {504                derivedKey = sessionKey505                key = null506                publish()507                _isUnlocked.value = true508                sessionDataAvailable = true509                sessionEpoch = nextSessionEpoch(sessionEpoch)510            } == null) return CreateVaultResult.Error("stale_create")511            CreateVaultResult.Success512        } catch (e: Exception) {513            lock()514            CreateVaultResult.Error(e.message ?: "create_failed")515        } finally {516            key?.let(VaultCrypto::zero)517        }518    }519520    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {521        val expectedEpoch = currentEpoch()522        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")523        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")524        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {525            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")526        }527        var adoptedRootKey: ByteArray? = null528        var nextDerivedKey: ByteArray? = null529        var sessionRootKey: ByteArray? = null530        return try {531            adoptedRootKey = hexToRootKey(rootKeyHex)532            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {533                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")534            }535            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)536            val salt = VaultCrypto.generateSalt()537            nextDerivedKey = VaultCrypto.deriveKey(password, salt)538            sessionRootKey = adoptedRootKey.copyOf()539            val payload = VaultPayload(540                entries = emptyList(),541                duressConfigured = false,542                rootKeyB64 = b64(sessionRootKey),543                recoveryWordHash = null,544                recoveryWordsSet = false,545            )546            val plaintext = VaultCodec.encode(payload)547            val blob = try {548                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))549            } finally {550                VaultCrypto.zero(plaintext)551            }552            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)553            // P1-3 (codex): same partial-commit guard as createVault — any failure554            // past slot A wipes the half-built vault and refuses adoption.555            beforeDurableCommit()556            val created = withEpochCommit(expectedEpoch) {557                if (!store.writeSlotA(slotA)) return@withEpochCommit false558                try {559                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")560                    // H-04: duress slot must be present from the start so its561                    // existence on disk is independent of duressConfigured.562                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")563                    store.stampLocalMutation()564                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")565                    true566                } catch (_: Exception) {567                    store.clearVault()568                    false569                }570            } ?: false571            if (!created) return AdoptVaultResult.Error("storage_write_failed")572573            val adopted = mutateIfCurrentEpoch(expectedEpoch) {574                val previousDerivedKey = derivedKey575                val previousRootKey = rootKey576                derivedKey = nextDerivedKey577                rootKey = sessionRootKey578                nextDerivedKey = null579                sessionRootKey = null580                entryList = mutableListOf()581                duressConfigured = false582                recoveryWordHash = null583                recoveryWordsSet = false584                decoyMode = false585                sessionDataAvailable = true586                publish()587                _isUnlocked.value = true588                sessionEpoch = nextSessionEpoch(sessionEpoch)589                previousDerivedKey to previousRootKey590            }591            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")592            val (previousDerivedKey, previousRootKey) = adopted593            previousDerivedKey?.let(VaultCrypto::zero)594            previousRootKey?.let(VaultCrypto::zero)595            AdoptVaultResult.Success(vaultId)596        } catch (e: Exception) {597            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")598        } finally {599            adoptedRootKey?.let(VaultCrypto::zero)600            nextDerivedKey?.let(VaultCrypto::zero)601            sessionRootKey?.let(VaultCrypto::zero)602        }603    }604605    suspend fun unlock(password: String): UnlockResult = mutex.withLock {606        unlockLocked(password, currentEpoch())607    }608609    /** Unlock body without acquiring the mutex — callers must already hold it. */610    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {611        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")612        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))613        val payload = try {614            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)615            try {616                VaultCodec.decode(plaintext)617            } finally {618                VaultCrypto.zero(plaintext)619            }620        } catch (_: Exception) {621            VaultCrypto.zero(key)622            // LV-1: a vault sealed before NFC normalization with a non-NFC623            // password derives a different key now. If the supplied password is624            // not already in NFC form, retry with the raw (un-normalized) bytes.625            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {626                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)627                if (legacy != null) return legacy628            }629            return UnlockResult.WrongPassword630        }631        return adoptSlotA(key, payload, expectedEpoch)632    }633634    /**635     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw636     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed637     * under the NFC-normalized key — so every future unlock takes the normal638     * path. Returns null when the raw key also fails (genuinely wrong password)639     * OR when any step of the heal commits fails (the original slot A is restored640     * so disk and session stay aligned).641     */642    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {643        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))644        var newKey: ByteArray? = null645        return try {646            val payload = try {647                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)648                try {649                    VaultCodec.decode(plaintext)650                } finally {651                    VaultCrypto.zero(plaintext)652                }653            } catch (_: Exception) {654                return null655            }656            // Heal: re-seal slot A under the NFC key (mirrors changePassword's657            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.658            val newSalt = VaultCrypto.generateSalt()659            newKey = VaultCrypto.deriveKey(password, newSalt)660            val plaintext = VaultCodec.encode(payload)661            val blob = try {662                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))663            } finally {664                VaultCrypto.zero(plaintext)665            }666            val next = StoredVault(667                version = slotA.version,668                salt = b64(newSalt),669                blob = blob,670                recoveryEscrow = slotA.recoveryEscrow,671                rootKeyEscrow = slotA.rootKeyEscrow,672            )673            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,674            // or stampLocalMutation must restore the prior slot A AND slot B so disk675            // and the eventual session key never disagree on the wrapping.676            val priorB = store.loadSlotB()677            beforeDurableCommit()678            val healed = withEpochCommit(expectedEpoch) {679                if (!store.writeSlotA(next)) {680                    false681                } else try {682                    if (!writeMatchingNoiseSlotB(next)) {683                        runCatching { store.writeSlotA(slotA) }684                        if (priorB != null) runCatching { store.writeSlotB(priorB) }685                        false686                    } else if (!writeCompanionDuressSlot(next)) {687                        runCatching { store.writeSlotA(slotA) }688                        if (priorB != null) runCatching { store.writeSlotB(priorB) }689                        false690                    } else {691                        store.stampLocalMutation()692                        true693                    }694                } catch (_: Exception) {695                    runCatching { store.writeSlotA(slotA) }696                    if (priorB != null) runCatching { store.writeSlotB(priorB) }697                    false698                }699            } ?: false700            if (healed) {701                VaultCrypto.zero(rawKey!!)702                rawKey = null703                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot704                newKey = null705                result706            } else {707                VaultCrypto.zero(newKey!!)708                newKey = null709                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key710                rawKey = null711                result712            }713        } finally {714            // Cover codec, encryption, storage, and stale-epoch failures before715            // either candidate key can be transferred into the live session.716            rawKey?.let(VaultCrypto::zero)717            newKey?.let(VaultCrypto::zero)718        }719    }720721    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */722    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {723        return try {724            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they725            // can throw after the fields above are live. There is no prior session726            // worth restoring — adoption has already replaced it — so a failed727            // adoption discards everything rather than leaving entries and keys728            // resident behind _isUnlocked=false.729            val adopted = mutateIfCurrentEpoch(expectedEpoch) {730                derivedKey = key731                decoyMode = false732                entryList = payload.entries.toMutableList()733                duressConfigured = payload.duressConfigured734                recoveryWordHash = payload.recoveryWordHash735                recoveryWordsSet = payload.recoveryWordsSet736                sessionDataAvailable = true737                _settings.value = store.loadSettings()738                ensureRootKey(payload)739                autoPurgeExpiredTrash()740                publish()741                _isUnlocked.value = true742                // A successful adoption starts a new generation. A sibling743                // SlotAUnlock captured in the prior epoch will fail closed at744                // the next commit instead of replacing this session.745                sessionEpoch = nextSessionEpoch(sessionEpoch)746            }747            if (adopted == null) {748                VaultCrypto.zero(key)749                UnlockResult.Error("stale_unlock")750            } else {751                UnlockResult.Success752            }753        } catch (e: Exception) {754            // Do not call lock() here: a concurrent newer session may already755            // own this repository. Only invalidate the failed epoch's fields.756            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }757            VaultCrypto.zero(key)758            UnlockResult.Error(e.message ?: "unlock_failed")759        }760    }761762    // ---- Constant-time unlock (MF-5) --------------------------------------763764    /**765     * A slot-A unlock that has been derived but not yet adopted. Holds the live766     * Argon2id key and decoded payload so the caller can derive several candidates767     * concurrently and commit only the winner. The key is owned by this token until768     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.769     */770    class SlotAUnlock internal constructor(771        key: ByteArray,772        internal val payload: VaultPayload,773        internal val epoch: Long,774    ) {775        private var ownedKey: ByteArray? = key776        private var consumed = false777778        /** Transfer the key exactly once to the adopter. */779        internal fun takeKey(): ByteArray? = synchronized(this) {780            if (consumed) return@synchronized null781            consumed = true782            ownedKey.also { ownedKey = null }783        }784785        /** Discard an uncommitted token without touching a transferred key. */786        internal fun discard() = synchronized(this) {787            if (consumed) return@synchronized788            consumed = true789            ownedKey?.let(VaultCrypto::zero)790            ownedKey = null791        }792    }793794    /**795     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.796     * Pure computation (no mutex), so it can run concurrently with other candidate797     * derivations before the caller decides which to commit (MF-5). Returns null on798     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).799     */800    fun tryUnlockSlotA(password: String): SlotAUnlock? {801        val epoch = currentEpoch()802        val slotA = store.loadSlotA() ?: return null803        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))804        return try {805            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)806            val payload = try {807                VaultCodec.decode(plaintext)808            } finally {809                VaultCrypto.zero(plaintext)810            }811            SlotAUnlock(key, payload, epoch)812        } catch (_: Exception) {813            VaultCrypto.zero(key)814            null815        }816    }817818    /** Adopt a previously-derived [SlotAUnlock] as the live session. */819    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {820        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")821        adoptSlotA(key, unlock.payload, unlock.epoch)822    }823824    /** Zero a derived [SlotAUnlock] that will not be adopted. */825    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()826827    // ---- Duress slot B (MF-6) ---------------------------------------------828829    /**830     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key831     * and decoded decoy payload so it can be derived concurrently with slot A and832     * only committed if the duress passphrase was the one entered (MF-5/MF-6).833     */834    class DuressUnlock internal constructor(835        key: ByteArray,836        internal val payload: VaultPayload,837        internal val epoch: Long,838    ) {839        private var ownedKey: ByteArray? = key840        private var consumed = false841842        internal fun takeKey(): ByteArray? = synchronized(this) {843            if (consumed) return@synchronized null844            consumed = true845            ownedKey.also { ownedKey = null }846        }847848        internal fun discard() = synchronized(this) {849            if (consumed) return@synchronized850            consumed = true851            ownedKey?.let(VaultCrypto::zero)852            ownedKey = null853        }854    }855856    /**857     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the858     * session. Success means the entered password is the duress passphrase that keys859     * slot B — indistinguishable from the real slot A at the lock screen. Pure860     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].861     * Returns null when no duress slot exists or the password is not the duress one.862     */863    fun tryUnlockDuress(password: String): DuressUnlock? {864        val epoch = currentEpoch()865        val slot = store.loadDecoySlot() ?: return null866        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))867        return try {868            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)869            val payload = try {870                VaultCodec.decode(plaintext)871            } finally {872                VaultCrypto.zero(plaintext)873            }874            DuressUnlock(key, payload, epoch)875        } catch (_: Exception) {876            VaultCrypto.zero(key)877            null878        }879    }880881    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */882    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {883        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")884        if (currentEpoch() != unlock.epoch) {885            VaultCrypto.zero(key)886            return@withLock UnlockResult.Error("stale_unlock")887        }888        return try {889            val adopted = mutateIfCurrentEpoch(unlock.epoch) {890                derivedKey = key891                decoyMode = true892                entryList = unlock.payload.entries.toMutableList()893                duressConfigured = false894                recoveryWordHash = unlock.payload.recoveryWordHash895                recoveryWordsSet = unlock.payload.recoveryWordsSet896                sessionDataAvailable = true897                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)898                          else VaultCrypto.generateRootKey()899                _settings.value = store.loadSettings()900                publish()901                _isUnlocked.value = true902                sessionEpoch = nextSessionEpoch(sessionEpoch)903            }904            if (adopted == null) {905                VaultCrypto.zero(key)906                UnlockResult.Error("stale_unlock")907            } else {908                UnlockResult.Success909            }910        } catch (e: Exception) {911            // A payload/root/settings failure can occur after the candidate key912            // has been assigned to derivedKey. Tear down only this epoch so a913            // newer session is not disturbed, and scrub the token reference on914            // every error path.915            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }916            VaultCrypto.zero(key)917            UnlockResult.Error(e.message ?: "decoy_unlock_failed")918        }919    }920921    /** Zero a derived [DuressUnlock] that will not be adopted. */922    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()923924    /**925     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an926     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The927     * passphrase is never stored — only its ability to decrypt slot B proves it.928     * Requires the real vault to be unlocked and not itself a decoy session.929     */930    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {931        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().932        val expectedEpoch = mutateState {933            if (derivedKey != null && !decoyMode) sessionEpoch else null934        } ?: return false935        // Reject a duress passphrase that also opens the real vault: it would make the936        // lock screen ambiguous (both slots would decrypt for the same input).937        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }938        var key: ByteArray? = null939        var basePlaintext: ByteArray? = null940        return try {941            val salt = VaultCrypto.generateSalt()942            key = VaultCrypto.deriveKey(duressPassphrase, salt)943            // An empty decoy vault with its own fresh sync root key — nothing real leaks.944            val decoyRoot = VaultCrypto.generateRootKey()945            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }946            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)947            basePlaintext = VaultCodec.encode(decoyPayload)948            // H-04: pad the duress plaintext to slot A's ciphertext length so the949            // duress blob matches slot A's blob on disk. VaultCodec.decode strips950            // trailing NULs, so the extra padding survives encrypt but never951            // reaches the JSON parser.952            // ponytail: only re-pads at configuration time — if slot A grows past953            // its initial bucket (entries cross 4096/16384/etc.), the real decoy954            // stays at the smaller bucket. Re-padding on every persist requires955            // keeping the duress passphrase in memory, which we don't.956            val slotA = store.loadSlotA()957            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0958            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!959            val blob = try {960                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))961            } finally {962                VaultCrypto.zero(plaintext)963                // Padding creates a second array; scrub the original encoded964                // payload too rather than dropping its last reference untouched.965                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)966                basePlaintext = null967                key?.let(VaultCrypto::zero)968                key = null969            }970            beforeDurableCommit()971            val ok = withEpochCommit(expectedEpoch) {972                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))973            } ?: false974            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {975                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }976            } else if (ok) {977                return false978            }979            ok980        } catch (_: Exception) {981            false982        } finally {983            // Cover failures before the encrypt block is entered (for example a984            // codec/copy exception) as well as any early return after key derivation.985            key?.let(VaultCrypto::zero)986            basePlaintext?.let(VaultCrypto::zero)987        }988    }989990991    /**992     * Export the decoy slot as browser-shaped JSON for adoption snapshots.993     *994     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all995     * lowercase hex — identical to the browser's `duress_slot` wire format — so996     * any receiver (browser or Android) can install it via the existing997     * passthrough. Returns null when duress is not configured, no decoy exists,998     * or any conversion failure occurs. Never decrypts.999     */1000    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1001        if (!duressConfigured) return null1002        val slot = store.loadDecoySlot() ?: return null1003        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1004    }10051006    /**1007     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1008     *1009     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1010     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1011     * byte arrays are zeroed in finally.1012     */1013    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1014        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1015        // serialized — kotlinx-serialization's encodeDefaults = false would1016        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1017        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1018        // silently dropping the duress slot on Android→browser adoptions.1019        var salt: ByteArray? = null1020        var blob: ByteArray? = null1021        var nonce: ByteArray? = null1022        var ctAndTag: ByteArray? = null1023        var ct: ByteArray? = null1024        var tag: ByteArray? = null1025        try {1026            salt = unb64(slot.salt)1027            blob = unb64(slot.blob)1028            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1029            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1030            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1031            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1032            val obj = JsonObject(mapOf(1033                "version" to JsonPrimitive(1),1034                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1035                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1036                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1037                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1038            ))1039            return obj.toString()1040        } finally {1041            salt?.let(VaultCrypto::zero)1042            blob?.let(VaultCrypto::zero)1043            nonce?.let(VaultCrypto::zero)1044            ctAndTag?.let(VaultCrypto::zero)1045            ct?.let(VaultCrypto::zero)1046            tag?.let(VaultCrypto::zero)1047        }1048    }10491050    /**1051     * Install a browser-shaped duress slot received through adoption.1052     *1053     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1054     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1055     * writes the result to the decoy slot. Epoch-fenced like the Codex1056     * restore path so a concurrent lock() cannot strand the write.1057     *1058     * Returns false on invalid input, no live session, or write failure.1059     */1060    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1061        val b = BrowserSlot.parse(json) ?: return false1062        var stored: StoredVault? = null1063        var salt: ByteArray? = null1064        var nonce: ByteArray? = null1065        var ct: ByteArray? = null1066        var tag: ByteArray? = null1067        var blob: ByteArray? = null1068        try {1069            salt = unhex(b.salt)1070            nonce = unhex(b.nonce)1071            ct = unhex(b.ciphertext)1072            tag = unhex(b.tag)1073            blob = ByteArray(nonce.size + ct.size + tag.size).also {1074                System.arraycopy(nonce, 0, it, 0, nonce.size)1075                System.arraycopy(ct, 0, it, nonce.size, ct.size)1076                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1077            }1078            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1079            val expectedEpoch = sessionEpoch1080            beforeDurableCommit()1081            withEpochCommit(expectedEpoch) {1082                store.writeDecoySlot(stored)1083            } ?: false1084        } catch (_: Exception) {1085            false1086        } finally {1087            salt?.let(VaultCrypto::zero)1088            nonce?.let(VaultCrypto::zero)1089            ct?.let(VaultCrypto::zero)1090            tag?.let(VaultCrypto::zero)1091            blob?.let(VaultCrypto::zero)1092        }1093    }10941095    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1096    suspend fun clearDuress(): Boolean = mutex.withLock {1097        // H-04: presence-indistinguishable — replace the real decoy with a fake1098        // blob of slot A's size rather than removing the duress record. The1099        // record stays on disk at the same length, so an at-rest observer1100        // cannot tell "duress cleared" from "no duress ever configured".1101        val expectedEpoch = currentEpoch()1102        val replaced = withEpochCommit(expectedEpoch) {1103            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1104                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1105        } ?: false1106        if (!replaced) return@withLock false1107        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1108        // duressConfigured write so a concurrent lock() cannot land between them.1109        if (!isSessionEpochCurrent(expectedEpoch)) return false1110        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1111        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1112            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1113        }1114        true1115    }11161117    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1118    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1119        var current: Throwable? = error1120        while (current != null) {1121            if (current is javax.crypto.AEADBadTagException) return true1122            // Some Android providers expose a GCM tag failure as the parent1123            // BadPaddingException rather than the AEAD-specific subtype.  A1124            // short ciphertext is an IllegalBlockSizeException and therefore1125            // remains an internal/malformed-payload Error below.1126            if (current is javax.crypto.BadPaddingException) return true1127            current = current.cause1128        }1129        return false1130    }11311132    /** Unlock with an already-derived AES-256 key (biometric path). */1133    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1134        val expectedEpoch = currentEpoch()1135        val slotA = try {1136            store.loadSlotA()1137        } catch (_: Exception) {1138            return@withLock UnlockResult.Error("unlock_key_storage_failed")1139        } ?: return@withLock UnlockResult.Error("no_vault")1140        // Keep the caller-owned key separate from the staged session key. The1141        // biometric caller always zeroes its input in a finally block; adoption1142        // must therefore own a copy and use the same all-or-nothing path as a1143        // password unlock rather than publishing a partially decoded session.1144        var stagedKey: ByteArray? = key.copyOf()1145        return try {1146            val encrypted = try {1147                unb64(slotA.blob)1148            } catch (_: Exception) {1149                return@withLock UnlockResult.Error("invalid_vault_blob")1150            }1151            // A GCM record must include nonce + authentication tag.  Providers1152            // differ on whether a shorter record is reported as BadPadding or1153            // AEADBadTag; reject it before decrypt so malformed storage cannot be1154            // misreported as a user-entered wrong key.1155            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1156                return@withLock UnlockResult.Error("invalid_vault_blob")1157            }1158            val plaintext = try {1159                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1160            } catch (e: Exception) {1161                if (isGcmAuthenticationFailure(e)) {1162                    return@withLock UnlockResult.WrongPassword1163                }1164                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1165            }1166            val payload = try {1167                VaultCodec.decode(plaintext)1168            } catch (_: Exception) {1169                return@withLock UnlockResult.Error("invalid_vault_payload")1170            } finally {1171                VaultCrypto.zero(plaintext)1172            }1173            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1174            val result = adoptSlotA(candidate, payload, expectedEpoch)1175            if (result is UnlockResult.Success) stagedKey = null1176            result1177        } finally {1178            stagedKey?.let(VaultCrypto::zero)1179        }1180    }11811182    fun lock() {1183        // H-03: must observe derivedKey/entryList atomically with any in-flight1184        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1185        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1186        // guards just the brief state reset. Advancing the epoch first invalidates1187        // every operation that captured this session before it reaches a later1188        // durable commit or publication.1189        stateLock.lock()1190        try {1191            sessionEpoch = nextSessionEpoch(sessionEpoch)1192            clearSessionStateLocked()1193            _uiGateLocked.value = false1194        } finally {1195            stateLock.unlock()1196        }1197    }11981199    /**1200     * Enter sync-retained background mode: the UI shows the lock screen but the1201     * vault stays fully unlocked in memory so [PeachSyncService] can complete1202     * sync sessions. Called from [PeachApplication.onStop] when background sync1203     * is enabled. A subsequent full [lock] (auto-lock timer, manual lock)1204     * supersedes this and zeroes everything.1205     */1206    fun lockUiGate() {1207        if (_isUnlocked.value) _uiGateLocked.value = true1208    }12091210    /** Dismiss the UI gate after the user authenticates on resume. */1211    fun clearUiGate() {1212        _uiGateLocked.value = false1213    }12141215    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12161217    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1218    // is a stub off-device).1219    @VisibleForTesting1220    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12211222    /**1223     * Call immediately before launching a system Activity (a SAF file1224     * picker/save dialog, a share sheet) that briefly takes the whole process1225     * out of the foreground as an expected part of an in-app flow — not a1226     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1227     * two apart on its own (it fires for both), and without this, exporting or1228     * importing a file silently locked and wiped the vault mid-flow the moment1229     * the picker took over, before its result ever reached the app.1230     *1231     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1232     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1233     */1234    fun expectTransientBackground() {1235        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1236    }12371238    /**1239     * Consumed once by the next background-lock check; see [expectTransientBackground].1240     * Returns false (and clears the flag) if the suppression has expired or was1241     * never set — a genuine background event must always lock.1242     */1243    fun consumeTransientBackgroundSuppression(): Boolean {1244        val until = suppressNextBackgroundLockUntil1245        val active = until != 0L && nowMs() < until1246        suppressNextBackgroundLockUntil = 0L1247        return active1248    }12491250    // ---- Sync identity ----------------------------------------------------12511252    /**1253     * The vault's stable sync master key (a copy). Caller must zero it. Backs1254     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1255     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1256     */1257    /**1258     * HOLE 5 — the duress vault never syncs.1259     *1260     * [commitDuressUnlock] gives the decoy session a root key so its own codex1261     * and recovery paths work, which means (unlike the browser, where the decoy1262     * path simply has no root key) the sync surface has to refuse explicitly.1263     * Without this the decoy vault would present a second, different vault_id1264     * to a paired device, and a coerced user opening it would make sync visibly1265     * fail on their other devices — a duress indicator, which is the one thing1266     * a duress vault must never be.1267     */1268    // M-04: the "null when locked" half of the contract was documented but never1269    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1270    // [unlockWithRecoveryWords], which installs a root key with no password key1271    // yet) and in a create that failed after staging. Sync must fail closed1272    // there, so the authoritative unlocked flag gates it, not just decoyMode.1273    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12741275    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1276        if (decoyMode || !_isUnlocked.value) null1277        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1278    }12791280    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1281        mutateState { sessionEpoch == epoch }12821283    internal fun unlockedSessionEpoch(): Long? = mutateState {1284        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1285    }12861287    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12881289    /**1290     * The current session's root key, decoy included. For local-only uses that1291     * must behave identically in a decoy session — Codex backup and recovery1292     * words — because a feature that errors under duress is itself a duress1293     * tell. In a decoy session this is the DECOY's root key (see1294     * [commitDuressUnlock]), never the real vault's, so neither surface can1295     * leak the real vault. Anything that leaves the device must use1296     * [syncMasterKey] instead.1297     */1298    fun sessionRootKey(): ByteArray? = mutateState {1299        if (!sessionDataAvailable) null else rootKey?.copyOf()1300    }13011302    /** Stable per-vault id two paired devices must share to sync. Null when1303     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1304    fun syncVaultId(): String? = mutateState {1305        if (decoyMode || !_isUnlocked.value) null1306        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1307    }13081309    /**1310     * Browser parity (MV-8): rotate the sync identity by replacing the stable1311     * root key. Entries remain under the master-password key, while sync1312     * credentials, vault_id, and recovery words all change.1313     */1314    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1315        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1316            ?: return false1317        installRootKey(generateRecoverableRootKey(), expectedEpoch)1318    }13191320    /**1321     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1322     * keeping every local entry.1323     *1324     * Sync identity (vault id, signing keys) derives from the root key, and1325     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1326     * created vaults can never reconcile. Taking the other side's root key is1327     * what makes them one vault; from the next sync on, both entry sets merge.1328     *1329     * Entries survive because they are sealed under the password-derived key,1330     * not the root key — the same property [rotateSyncIdentity] relies on.1331     *1332     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1333     * this device's previous recovery words stop working for good, and its new1334     * words are the source vault's words, because words are a deterministic1335     * encoding of the root key.1336     */1337    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1338        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1339            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1340        }1341        val expectedEpoch = mutateState {1342            when {1343                derivedKey == null -> null1344                decoyMode -> Long.MIN_VALUE1345                else -> sessionEpoch1346            }1347        }1348        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1349        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1350        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13511352        val incoming = hexToRootKey(rootKeyHex)1353        if (incoming.size != VaultCrypto.KEY_SIZE) {1354            VaultCrypto.zero(incoming)1355            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1356        }1357        // Refuse before committing rather than leave this device holding a vault1358        // whose recovery words cannot be typed back. generateRecoverableRootKey1359        // screens its own keys for this; a key arriving from a peer has not been.1360        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1361        if (!roundTrips) {1362            VaultCrypto.zero(incoming)1363            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1364        }13651366        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1367        return if (installRootKey(incoming, expectedEpoch)) {1368            AdoptVaultResult.Success(vaultId)1369        } else {1370            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1371        }1372    }13731374    /**1375     * The recovery words [rootKeyHex] will produce once joined, or null if it is1376     * not a usable root key. Lets the join flow show the user the words that are1377     * about to replace theirs *before* anything is committed.1378     */1379    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1380        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1381        val bytes = hexToRootKey(rootKeyHex)1382        return try {1383            val words = rootKeyToWords(bytes)1384            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1385        } catch (_: Exception) {1386            null1387        } finally {1388            VaultCrypto.zero(bytes)1389        }1390    }13911392    /**1393     * Swap the vault's root key, keeping every entry, and re-escrow under the1394     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1395     * [mutex].1396     */1397    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1398        // H-03: snapshot the live fields under stateLock so the success and the1399        // rollback paths both see consistent prior/new state. Writing the new1400        // rootKey/recovery fields is inside its own mutateState at the end.1401        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1402        // — holding live references would let a zero-on-failure operation also1403        // wipe the session's still-live key arrays.1404        data class Snapshot(1405            val key: ByteArray,1406            val priorRootKey: ByteArray,1407            val priorRecoveryHash: String?,1408            val priorRecoverySet: Boolean,1409        )1410        val snap = mutateIfCurrentEpoch(expectedEpoch) {1411            val k = derivedKey ?: return@mutateIfCurrentEpoch null1412            if (decoyMode) return@mutateIfCurrentEpoch null1413            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1414            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1415        } ?: run {1416            VaultCrypto.zero(nextRootKey)1417            return false1418        }1419        val priorSlot = store.loadSlotA() ?: run {1420            VaultCrypto.zero(snap.key)1421            VaultCrypto.zero(snap.priorRootKey)1422            VaultCrypto.zero(nextRootKey)1423            return false1424        }1425        val nextWords = rootKeyToWords(nextRootKey)14261427        return try {1428            beforeDurableCommit()1429            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1430            if (mutateIfCurrentEpoch(expectedEpoch) {1431                rootKey = nextRootKey1432                recoveryWordHash = recoveryWordsHash(nextWords)1433                recoveryWordsSet = false1434            } == null) throw IllegalStateException("stale_root_rotation")14351436            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1437                ?: throw IllegalStateException("recovery_escrow_failed")1438            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1439                ?: throw IllegalStateException("root_escrow_failed")1440            val plaintext = VaultCodec.encode(currentPayload())1441            val blob = try {1442                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1443            } finally {1444                VaultCrypto.zero(plaintext)1445            }1446            val nextSlot = priorSlot.copy(1447                blob = blob,1448                recoveryEscrow = nextRecoveryEscrow,1449                rootKeyEscrow = nextRootKeyEscrow,1450            )1451            val committed = withEpochCommit(expectedEpoch) {1452                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1453                try {1454                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1455                    // H-04: a root-key rotation re-seals slot A, so the duress slot1456                    // (real or fake) must be re-padded to the new ciphertext length.1457                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1458                    store.stampLocalMutation()1459                } catch (_: Exception) {1460                    store.writeSlotA(priorSlot)1461                    return@withEpochCommit false1462                }1463                true1464            } ?: false1465            if (!committed) throw IllegalStateException("storage_write_failed")1466            mutateIfCurrentEpoch(expectedEpoch) { publish() }1467            true1468        } catch (_: Exception) {1469            val restored = mutateIfCurrentEpoch(expectedEpoch) {1470                rootKey?.let(VaultCrypto::zero)1471                rootKey = snap.priorRootKey.copyOf()1472                recoveryWordHash = snap.priorRecoveryHash1473                recoveryWordsSet = snap.priorRecoverySet1474            }1475            if (restored != null) {1476                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1477            }1478            false1479        } finally {1480            VaultCrypto.zero(snap.key)1481            VaultCrypto.zero(snap.priorRootKey)1482            // On success nextRootKey is owned by rootKey. On every other path1483            // it is not, including a stale epoch after lock(); scrub it while1484            // the local reference is still available.1485            val stillOwnedBySession = mutateState {1486                sessionEpoch == expectedEpoch && rootKey === nextRootKey1487            }1488            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1489        }1490    }14911492    /**1493     * A copy of the live Argon2id-derived vault key, for biometric escrow only1494     * (wrapped under a hardware KeyStore key on biometric enable). Null when1495     * locked. Caller must [VaultCrypto.zero] it after use.1496     */1497    fun currentVaultKey(): ByteArray? = mutateState {1498        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1499    }15001501    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1502        val expectedEpoch = currentEpoch()1503        val slotA = store.loadSlotA() ?: return false1504        var oldKey: ByteArray? = null1505        var newKey: ByteArray? = null1506        var plaintext: ByteArray? = null1507        return try {1508            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1509            // Keep ownership of the decrypted vault plaintext until decoding has1510            // finished.  Passing decrypt() inline made the byte array unreachable1511            // before the finally block, so a successful or failed password change1512            // could leave the full plaintext resident in the heap.1513            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1514            val payload = try {1515                VaultCodec.decode(decrypted)1516            } finally {1517                VaultCrypto.zero(decrypted)1518            }1519            val newSalt = VaultCrypto.generateSalt()1520            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1521            // Re-seal the existing payload under the new key/salt.1522            plaintext = VaultCodec.encode(payload)1523            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1524            // The escrow seals the slot-A PLAINTEXT, which a password change does not1525            // alter — only the wrapping changes — so carry it forward; rebuilding the1526            // StoredVault from scratch would silently drop it.1527            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1528            // content that a password change does not alter — only the slot-A1529            // wrapping rotates. The root-key escrow staying stable across password1530            // changes matches the browser (`vault.ts:363-364`).1531            val next = StoredVault(1532                version = slotA.version,1533                salt = b64(newSalt),1534                blob = blob,1535                recoveryEscrow = slotA.recoveryEscrow,1536                rootKeyEscrow = slotA.rootKeyEscrow,1537            )1538            // H-01: durably commit slot A + size-matched noise + version stamp1539            // before swapping the session key. A partial commit (new A on disk,1540            // old key in memory) would have the next save encrypt under the old1541            // key while matching disk on the new one.1542            val priorB = store.loadSlotB()1543            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1544            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1545            // inside the same stateLock critical section, so a concurrent lock()1546            // can't observe the in-between (zeroed but not yet reassigned).1547            if (mutateIfCurrentEpoch(expectedEpoch) {1548                oldKey?.let(VaultCrypto::zero); oldKey = null1549                derivedKey?.let(VaultCrypto::zero)1550                derivedKey = newKey; newKey = null1551            } == null) return false1552            true1553        } catch (_: Exception) {1554            false1555        } finally {1556            oldKey?.let(VaultCrypto::zero)1557            newKey?.let(VaultCrypto::zero)1558            plaintext?.let(VaultCrypto::zero)1559        }1560    }15611562    /**1563     * Irreversible wipe. Locks the session and clears every vault key. The Pro1564     * license is in a separate store and is preserved by construction.1565     */1566    suspend fun destroyVault() = mutex.withLock {1567        lock()1568        store.clearVault()1569        store.clearDecoySlot()1570    }15711572    // ---- Entry CRUD -------------------------------------------------------15731574    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1575        val epoch = requireUnlocked()1576        val stamp = VaultCrypto.monotonicEntryTimestamp()1577        val now = System.currentTimeMillis()1578        val entry = withDerivedLast4(draft.copy(1579            id = draft.id.ifBlank { UUID.randomUUID().toString() },1580            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1581            modifiedAt = stamp,1582            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1583        ))1584        // H-03: capture rollback AND write entryList under stateLock so a1585        // concurrent lock() cannot zero the session between the read and the write.1586        val rollback = mutateCurrentSession(epoch) {1587            val r = entryList.toMutableList()1588            entryList.add(entry)1589            r1590        } ?: throw IllegalStateException("vault_locked")1591        persistOrRollback(rollback, epoch)1592        entry1593    }15941595    /**1596     * Insert many entries with a single encrypt-and-write at the end.1597     *1598     * Importing a real vault through [addEntry] was quadratic: every entry took1599     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1600     * restore did 806 full-vault writes and hung the app long enough to ANR.1601     * Rolls back to the pre-import list if the one write fails, matching1602     * [addEntry]'s all-or-nothing behaviour.1603     */1604    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1605        val epoch = requireUnlocked()1606        if (drafts.isEmpty()) return 01607        val now = System.currentTimeMillis()1608        val ready = drafts.map { draft ->1609            val stamp = VaultCrypto.monotonicEntryTimestamp()1610            withDerivedLast4(1611                draft.copy(1612                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1613                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1614                    modifiedAt = stamp,1615                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1616                ),1617            )1618        }1619        // H-03: capture rollback AND append every entry under stateLock.1620        val rollback = mutateCurrentSession(epoch) {1621            val r = entryList.toMutableList()1622            entryList.addAll(ready)1623            r1624        } ?: throw IllegalStateException("vault_locked")1625        persistOrRollback(rollback, epoch)1626        return drafts.size1627    }16281629    /**1630     * M-01: replace the whole active set with [newEntries] in one write.1631     *1632     * Import's REPLACE mode used to soft-delete every current entry one durable1633     * write at a time (swallowing each failure) and then batch-add, so a failed1634     * add left the old entries stranded in Trash with nothing restored. Staging1635     * the entire next list in memory and committing it through1636     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1637     * pre-import list — active entries included — is restored.1638     *1639     * Existing tombstones are preserved so paired devices still see the1640     * deletions they already know about.1641     */1642    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1643        val epoch = requireUnlocked()1644        val now = System.currentTimeMillis()1645        val ready = newEntries.map { draft ->1646            val stamp = VaultCrypto.monotonicEntryTimestamp()1647            withDerivedLast4(1648                draft.copy(1649                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1650                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1651                    modifiedAt = stamp,1652                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1653                ),1654            )1655        }1656        // M-03: an incoming entry that shares an id with an existing live entry1657        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1658        // the old one and then appending the new one would leave the list with1659        // two entries of the same id — the tombstoned one would be returned by1660        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1661        // superseded entries from the tombstone set instead.1662        val incomingIds = ready.map { it.id }.toHashSet()1663        val rollback = mutateCurrentSession(epoch) {1664            val r = entryList.toMutableList()1665            val replaced = entryList.map { entry ->1666                if (entry.deletedAt != null) return@map entry1667                if (entry.id in incomingIds) return@map null1668                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1669                entry.copy(deletedAt = stamp, modifiedAt = stamp)1670            }.filterNotNull()1671            entryList = (replaced + ready).toMutableList()1672            r1673        } ?: throw IllegalStateException("vault_locked")1674        persistOrRollback(rollback, epoch)1675        return ready.size1676    }16771678    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1679        val epoch = requireUnlocked()1680        // H-03: read entryList, compute the patched entry, and write it under a1681        // single stateLock so a concurrent lock() cannot interleave between the1682        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1683        // list, or clobber the entry onto the wrong id after a relock).1684        val prepared = mutateCurrentSession(epoch) {1685            val idx = entryList.indexOfFirst { it.id == patch.id }1686            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1687            val current = entryList[idx]1688            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1689            // Only refresh passwordChangedAt when the password value actually changes.1690            val passwordChanged = patch.password != null && patch.password != current.password1691            // Capture the outgoing password (newest first) so the user can reveal/copy/1692            // restore it later. History is computed authoritatively from `current`, not1693            // the patch — updateEntry is a full-entry replace, so trusting patch's value1694            // would let a stale form clobber the real history. Record the age the old1695            // password carried, skip an empty prior, and skip an immediate exact dupe1696            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1697            val priorPassword = current.password1698            val shouldCapture = passwordChanged &&1699                !priorPassword.isNullOrEmpty() &&1700                current.passwordHistory?.firstOrNull()?.password != priorPassword1701            val nextHistory = if (shouldCapture) {1702                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1703                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1704            } else {1705                current.passwordHistory1706            }1707            val updated = withDerivedLast4(patch.copy(1708                createdAt = current.createdAt,1709                modifiedAt = stamp,1710                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1711                passwordHistory = nextHistory,1712            ))1713            val rollback = entryList.toMutableList()1714            entryList[idx] = updated1715            updated to rollback1716        } ?: throw IllegalStateException("vault_locked")1717        val (updated, rollback) = prepared1718        persistOrRollback(rollback, epoch)1719        updated1720    }17211722    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1723    suspend fun deleteEntry(id: String) = mutex.withLock {1724        val epoch = requireUnlocked()1725        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1726        // the list out from under the indexOfFirst.1727        val rollback = mutateCurrentSession(epoch) {1728            val idx = entryList.indexOfFirst { it.id == id }1729            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1730            val current = entryList[idx]1731            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1732            val prior = entryList.toMutableList()1733            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1734            prior1735        } ?: throw IllegalStateException("vault_locked")1736        persistOrRollback(rollback, epoch)1737    }17381739    suspend fun restoreEntry(id: String) = mutex.withLock {1740        val epoch = requireUnlocked()1741        val rollback = mutateCurrentSession(epoch) {1742            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1743            if (idx == -1) throw IllegalStateException("deleted entry not found")1744            val current = entryList[idx]1745            val prior = entryList.toMutableList()1746            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1747            prior1748        } ?: throw IllegalStateException("vault_locked")1749        persistOrRollback(rollback, epoch)1750    }17511752    /** Permanent delete — no tombstone, no recovery. */1753    suspend fun purgeEntry(id: String) = mutex.withLock {1754        val epoch = requireUnlocked()1755        val rollback = mutateCurrentSession(epoch) {1756            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1757            if (idx == -1) throw IllegalStateException("deleted entry not found")1758            val prior = entryList.toMutableList()1759            entryList.removeAt(idx)1760            prior1761        } ?: throw IllegalStateException("vault_locked")1762        persistOrRollback(rollback, epoch)1763    }17641765    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1766    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1767        val epoch = requireUnlocked()1768        // H-03: read+remove under stateLock so lock() can't reorder the remove.1769        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1770        val r = mutateCurrentSession(epoch) {1771            val rollback = entryList.toMutableList()1772            val before = entryList.size1773            entryList.removeAll { it.deletedAt != null }1774            R(rollback, before - entryList.size)1775        } ?: throw IllegalStateException("vault_locked")1776        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1777        r.removed1778    }17791780    /** Browser parity: number of active entries that dedupe would move to trash. */1781    suspend fun countDuplicateEntries(): Int = mutex.withLock {1782        val epoch = requireUnlocked()1783        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1784        // lock() can't race the read with state writes.1785        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1786            ?: throw IllegalStateException("vault_locked")1787    }17881789    /**1790     * Browser parity: soft-delete duplicate entries, keeping the most recently1791     * modified member of each group. Refuse in decoy mode so the duress vault is1792     * not reshaped into a forensically distinct state.1793     */1794    suspend fun deduplicateEntries(): Int = mutex.withLock {1795        val epoch = requireUnlocked()1796        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1797        val ready = mutateCurrentSession(epoch) {1798            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1799            val active = entryList.filter { it.deletedAt == null }1800            val keepIds = mutableSetOf<String>()1801            active.groupBy { it.duplicateKey() }.values.forEach { group ->1802                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1803            }1804            val rollback = entryList.toMutableList()1805            var removed = 01806            entryList = entryList.map { entry ->1807                if (entry.deletedAt == null && entry.id !in keepIds) {1808                    removed++1809                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1810                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1811                } else {1812                    entry1813                }1814            }.toMutableList()1815            R(rollback, removed)1816        } ?: throw IllegalStateException("vault_locked")1817        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1818        ready.removed1819    }18201821    fun getEntries(): List<VaultEntry> = mutateState {1822        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1823    }1824    fun getEntry(id: String): VaultEntry? = mutateState {1825        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1826    }1827    fun getDeletedEntries(): List<VaultEntry> = mutateState {1828        if (!sessionDataAvailable) emptyList()1829        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1830    }18311832    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1833    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1834        return mutateState {1835            if (!sessionDataAvailable) return@mutateState emptyList()1836            val active = entryList.filter { it.deletedAt == null }1837            if (tagFilter == null) return@mutateState active1838            val needle = tagFilter.trim().lowercase()1839            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1840        }1841    }18421843    /** Sorted, de-duplicated tags across all active entries. */1844    fun getDistinctTags(): List<String> = mutateState {1845        if (!sessionDataAvailable) return@mutateState emptyList()1846        entryList.asSequence()1847            .filter { it.deletedAt == null }1848            .flatMap { it.tags?.asSequence() ?: emptySequence() }1849            .map { it.trim().lowercase() }1850            .filter { it.isNotEmpty() }1851            .distinct()1852            .sorted()1853            .toList()1854    }18551856    /**1857     * Distinct non-blank values for an identity field across active entries,1858     * most-frequently-used first. Supports "username" and "email"; unknown field1859     * names return an empty list. Values keep original casing (deduped1860     * case-insensitively) so a suggestion matches what the user typed.1861     */1862    fun getDistinctFieldValues(field: String): List<String> {1863        val select: (VaultEntry) -> String? = when (field) {1864            "username" -> { e -> e.username }1865            "email" -> { e -> e.email }1866            else -> return emptyList()1867        }1868        return mutateState {1869            if (!sessionDataAvailable) return@mutateState emptyList()1870            entryList.asSequence()1871                .filter { it.deletedAt == null }1872                .mapNotNull { select(it)?.trim() }1873                .filter { it.isNotEmpty() }1874                .groupingBy { it.lowercase() }1875                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1876                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1877                }1878                .values1879                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1880                .map { it.first }1881        }1882    }18831884    fun getTombstones(): List<Tombstone> = mutateState {1885        if (!sessionDataAvailable) emptyList()1886        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1887    }1888    fun getVaultVersion(): Long = store.version()1889    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18901891    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1892        val expectedEpoch = currentEpoch()1893        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1894        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1895        // H-04: snapshots now carry the duress slot too — restore preserves1896        // the at-rest triad (A/B/D), not just the A/B pair.1897        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1898        val priorA = store.loadSlotA()1899        val priorB = store.loadSlotB()1900        val priorD = store.loadDecoySlot()1901        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1902        // slot write — the old code took this snapshot, then stranded it on1903        // every failure, polluting history with a "restore" entry that never1904        // restored anything. The history below is rolled back together with1905        // the slots if the commit fails.1906        val priorHistory = store.loadVaultHistory()1907        // P1-4 (codex): the recovery snapshot must land before we commit the1908        // slot pair, otherwise a failed restore cannot be rolled back to the1909        // pre-restore history. If the history write itself fails, refuse to1910        // proceed — the alternative is a successful restore with no undo path.1911        beforeDurableCommit()1912        if (!takeVaultSnapshot("restore", expectedEpoch)) {1913            return false1914        }1915        // H-04: legacy snapshots without vault_d, or vault_d that fails to1916        // decode, fall back to a fake duress of slot A's size — presence-1917        // indistinguishable from any other freshly-restored vault.1918        val nextD: StoredVault = restoredD ?: run {1919            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161920            val fakeBlob = ByteArray(target).also(rng::nextBytes)1921            val fakeSalt = VaultCrypto.generateSalt()1922            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1923        }1924        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1925            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1926            return false1927        }1928        lock()1929        true1930    }19311932    /**1933     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1934     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1935     * (a restore preserves the duress-shaped blob from history), not a fresh1936     * noise record. Any failure rolls back so the at-rest pair never holds a1937     * restored slot A against a pre-restore slot B. Caller is responsible for1938     * restoring the pre-snapshot history on a false return.1939     *1940     * H-04: duress slot is now part of the atomic triad — restored last so1941     * a duress-write failure rolls the A/B pair back too.1942     */1943    private fun commitRestoreAtomic(1944        nextA: StoredVault,1945        nextB: String,1946        nextD: StoredVault,1947        priorA: StoredVault?,1948        priorB: String?,1949        priorD: StoredVault?,1950        expectedEpoch: Long,1951    ): Boolean {1952        return withEpochCommit(expectedEpoch) {1953            if (!store.writeSlotA(nextA)) return@withEpochCommit false1954            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1955            // must trigger rollback; previously a throw escaped without restoring1956            // the prior slot A and history.1957            try {1958                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1959                // H-04: duress slot is part of the restore triad. Failures here1960                // roll A+B back so the at-rest state stays consistent.1961                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1962                store.stampLocalMutation()1963            } catch (_: Exception) {1964                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1965                return@withEpochCommit false1966            }1967            true1968        } ?: false1969    }19701971    // ---- Sync apply (does NOT advance the version) ------------------------19721973    /**1974     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1975     * WITHOUT bumping the version counter, so a sync round cannot cascade into1976     * another outbound sync. Returns the number of locally-applied changes.1977     */1978    suspend fun applyMergedRemoteEntries(1979        remoteEntries: List<VaultEntry>,1980        remoteTombstones: List<Tombstone>,1981        expectedEpoch: Long? = null,1982    ): Int = mutex.withLock {1983        val epoch = expectedEpoch ?: requireUnlocked()1984        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01985        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1986        // BEFORE any comparison against a local one. Without this a peer can1987        // send a far-future modifiedAt and permanently pin its version of an1988        // entry against every honest local edit. The browser clamps in the1989        // matching place (vault.ts applyMergedRemoteEntries).1990        val mergeNow = System.currentTimeMillis()1991        val clamped = remoteEntries.map { entry ->1992            entry.copy(1993                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1994                deletedAt = entry.deletedAt?.let {1995                    SyncLimits.clampSyncTimestamp(it, mergeNow)1996                },1997            )1998        }1999        val tombMap = remoteTombstones.associate {2000            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2001        }2002        // H-03: build the new entryList under stateLock so a concurrent lock()2003        // cannot zero derivedKey/entryList between the byId read and the merge.2004        val applied = mutateCurrentSession(epoch) {2005            var n = 02006            val byId = entryList.associateBy { it.id }.toMutableMap()20072008            for (remote in clamped) {2009                val tomb = tombMap[remote.id]2010                if (tomb != null && tomb >= remote.modifiedAt) continue2011                val local = byId[remote.id]2012                if (local == null) {2013                    entryList.add(remote); byId[remote.id] = remote; n++2014                } else if (remote.modifiedAt > local.modifiedAt) {2015                    val i = entryList.indexOfFirst { it.id == local.id }2016                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2017                }2018            }20192020            for ((id, deletedAt) in tombMap) {2021                val local = byId[id] ?: continue2022                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2023                if (deletedAt < local.modifiedAt) continue2024                val i = entryList.indexOfFirst { it.id == id }2025                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2026            }2027            n2028        } ?: return@withLock 020292030        if (applied > 0) {2031            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2032            mutateIfCurrentEpoch(epoch) { publish() }2033        }2034        applied2035    }20362037    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2038        // Require a fully live session before exposing a settings mutation.  The2039        // epoch is captured with the read so a lock/new-session boundary cannot2040        // let stale work publish into the replacement session.2041        val epoch = requireUnlocked()2042        val prior = mutateCurrentSession(epoch) {2043            val old = _settings.value2044            _settings.value = settings2045            old2046        } ?: throw IllegalStateException("vault_locked")20472048        val committed = try {2049            beforeDurableCommit()2050            // The durable write is inside the same epoch fence as the in-memory2051            // publication.  If lock() won first, this returns null and no stale2052            // settings reach storage or the public StateFlow.2053            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2054        } catch (_: Exception) {2055            false2056        }2057        if (!committed) {2058            // A live-session storage failure rolls back the public value.  A2059            // stale epoch is already reset by lock()/the new session and must2060            // not be allowed to overwrite that safe state.2061            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2062            throw IllegalStateException("settings_write_failed")2063        }2064    }20652066    // ---- Recovery words (vault metadata) ----------------------------------20672068    /** True once the user has confirmed their recovery words for this vault. */2069    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20702071    /**2072     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2073     * Persisted without bumping the version — recovery metadata is local-only and2074     * is not an entry mutation paired devices should sync against.2075     */2076    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2077        val epoch = requireUnlocked()2078        // H-03: hash + escrow-related state fields set under stateLock so a2079        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2080        // M-04: the flags have to be live before the escrows are built — both seal2081        // currentPayload() — so capture the prior pair and put it back if the write2082        // never lands. Otherwise the session reports words set against a slot A2083        // that still holds no escrow, and the recovery screen never offers a retry.2084        val prior = mutateCurrentSession(epoch) {2085            val priorPair = recoveryWordHash to recoveryWordsSet2086            recoveryWordHash = recoveryWordsHash(words)2087            recoveryWordsSet = true2088            priorPair2089        } ?: return@withLock false2090        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2091        // (cross-platform recovery of the sync root key) and Android's whole-payload2092        // escrow (offline forgot-password restore of entries). Both derive their key2093        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2094        val persisted = runCatching {2095            persistSlotAWithEscrows(2096                buildRecoveryEscrow(words),2097                buildRootKeyEscrow(words),2098                expectedEpoch = epoch,2099            )2100        }.getOrDefault(false)2101        if (!persisted) {2102            mutateIfCurrentEpoch(epoch) {2103                recoveryWordHash = prior.first2104                recoveryWordsSet = prior.second2105            }2106        }2107        persisted2108    }21092110    /** Verify entered words against the stored recovery hash. */2111    fun verifyRecoveryWords(words: List<String>): Boolean {2112        return mutateState {2113            val stored = recoveryWordHash ?: return@mutateState false2114            stored == recoveryWordsHash(words)2115        }2116    }21172118    // ---- Recovery escrow (forgot-password) --------------------------------21192120    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2121    fun hasRecoveryEscrow(): Boolean =2122        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21232124    /**2125     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2126     * the master password. On success the slot-A plaintext sealed in the escrow is2127     * adopted as the live session (entries, rootKey, settings), but no Argon2id2128     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2129     * new password and finish unlocking. Returns false when there is no escrow, the2130     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2131     *2132     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2133     * detached vault key to return here — slot A is wrapped under the rotating2134     * Argon2id key — so this opens the escrowed payload directly.2135     */2136    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2137        val expectedEpoch = currentEpoch()2138        val slotA = store.loadSlotA() ?: return false2139        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21402141        // Prefer Android's whole-payload escrow: a full offline restore of entries,2142        // root key, and settings. Present for Android-created vaults.2143        val fullEscrow = slotA.recoveryEscrow2144        if (fullEscrow != null) {2145            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2146            if (material != null) {2147                var keyBytes: ByteArray? = null2148                var plaintext: ByteArray? = null2149                var candidateRoot: ByteArray? = null2150                var stateCommitStarted = false2151                try {2152                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2153                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2154                    val payload = VaultCodec.decode(plaintext)2155                    // Stage every fallible operation before touching live session2156                    // fields.  In particular, a settings/provider/decode failure2157                    // cannot leave entries, flags, or a root key resident while the2158                    // public session remains locked.2159                    val candidateSettings = store.loadSettings()2160                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2161                        val decodedRoot = unb64(payload.rootKeyB64)2162                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2163                            // Do not let a malformed authenticated payload abandon2164                            // this decoded root-key candidate before the outer2165                            // finally block owns it.2166                            VaultCrypto.zero(decodedRoot)2167                            throw IllegalArgumentException("invalid recovery root key")2168                        }2169                        decodedRoot2170                    } else {2171                        generateRecoverableRootKey()2172                    }2173                    stateCommitStarted = true2174                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2175                        clearSessionStateLocked()2176                        entryList = payload.entries.toMutableList()2177                        duressConfigured = payload.duressConfigured2178                        recoveryWordHash = payload.recoveryWordHash2179                        recoveryWordsSet = payload.recoveryWordsSet2180                        rootKey = candidateRoot2181                        candidateRoot = null2182                        _settings.value = candidateSettings2183                        publish()2184                        // This is the authenticated recovery half-unlock boundary:2185                        // only set it after every candidate field is installed.2186                        sessionDataAvailable = true2187                        _isUnlocked.value = false2188                        true2189                    }2190                    if (adopted == true) return@withLock true2191                } catch (_: Exception) {2192                    // Wrong words, malformed payloads, provider failures, and2193                    // settings failures all fall through to the browser-compatible2194                    // root-key escrow.  If the state commit had started, invalidate2195                    // that epoch so no partial candidate survives.2196                    if (stateCommitStarted) {2197                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2198                    }2199                } finally {2200                    plaintext?.let(VaultCrypto::zero)2201                    VaultCrypto.zero(material)2202                    keyBytes?.let(VaultCrypto::zero)2203                    candidateRoot?.let(VaultCrypto::zero)2204                }2205            }2206        }22072208        // Browser-format vault (only the root-key escrow): recover the 32-byte root2209        // key (sync identity) and reset the password; entries return via sync. This2210        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2211        val rootEscrow = slotA.rootKeyEscrow2212        if (rootEscrow != null) {2213            var candidateRoot: ByteArray? = null2214            var stateCommitStarted = false2215            try {2216                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2217                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2218                val candidateSettings = store.loadSettings()2219                stateCommitStarted = true2220                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2221                    clearSessionStateLocked()2222                    entryList = mutableListOf()2223                    duressConfigured = false2224                    recoveryWordHash = recoveryWordsHash(words)2225                    recoveryWordsSet = true2226                    rootKey = candidateRoot2227                    candidateRoot = null2228                    _settings.value = candidateSettings2229                    publish()2230                    sessionDataAvailable = true2231                    _isUnlocked.value = false2232                    true2233                }2234                if (adopted == true) return@withLock true2235            } catch (_: Exception) {2236                if (stateCommitStarted) {2237                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2238                }2239            } finally {2240                candidateRoot?.let(VaultCrypto::zero)2241            }2242        }2243        return false2244    }22452246    /**2247     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2248     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2249     * words, mark the session fully unlocked, and publish. Returns false if called2250     * without a prior successful recovery unlock (no in-memory root key).2251     *2252     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2253     * into one step, since there is no detached vault key here.2254     */2255    suspend fun resetPasswordWithRecoveryWords(2256        words: List<String>,2257        newPassword: String,2258    ): Boolean = mutex.withLock {2259        val expectedEpoch = currentEpoch()2260        // H-03: read rootKey + entryList under stateLock before computing the new2261        // plaintext, then re-publish + swap the derived key under stateLock so a2262        // concurrent lock() cannot straddle the two fences.2263        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2264        val priorA = store.loadSlotA() ?: return false2265        var newKey: ByteArray? = null2266        var plaintext: ByteArray? = null2267        return try {2268            val newSalt = VaultCrypto.generateSalt()2269            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2270            plaintext = VaultCodec.encode(currentPayload())2271            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2272            // Rebuild both escrows against the recovered session so their salts rotate2273            // with the password reset; fall back to the prior escrows if rebuild fails2274            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2275            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2276            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2277            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2278            val next = StoredVault(2279                version = priorA.version,2280                salt = b64(newSalt),2281                blob = blob,2282                recoveryEscrow = freshRecovery,2283                rootKeyEscrow = freshRootKey,2284            )2285            // H-01: see changePassword — both rotations share the same2286            // commit-or-keep-old-key contract so a partial write can't strand2287            // the session on a key that no longer matches disk.2288            val priorB = store.loadSlotB()2289            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2290            // H-03 (P1-2 codex): zero the old live key and install the new one2291            // inside the same stateLock critical section so a concurrent lock()2292            // can't observe the in-between.2293            if (mutateIfCurrentEpoch(expectedEpoch) {2294                derivedKey?.let(VaultCrypto::zero)2295                derivedKey = newKey; newKey = null2296                publish()2297                _isUnlocked.value = true2298            } == null) return false2299            true2300        } catch (_: Exception) {2301            false2302        } finally {2303            newKey?.let(VaultCrypto::zero)2304            plaintext?.let(VaultCrypto::zero)2305        }2306    }23072308    /**2309     * Seal the current slot-A plaintext under a key derived from the 12 BIP392310     * recovery [words] (Android offline-restore escrow). IKM is the browser's2311     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2312     * Returns null for a checksum-invalid phrase.2313     */2314    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2315        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2316        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2317        var keyBytes: ByteArray? = null2318        var plaintext: ByteArray? = null2319        return try {2320            val salt = VaultCrypto.generateSalt()2321            val recoveryKey = deriveRecoveryKey(material, salt)2322            keyBytes = recoveryKey.encoded2323            plaintext = VaultCodec.encode(currentPayload())2324            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2325            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2326        } finally {2327            VaultCrypto.zero(material)2328            keyBytes?.let(VaultCrypto::zero)2329            plaintext?.let(VaultCrypto::zero)2330        }2331    }23322333    /**2334     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2335     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2336     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2337     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2338     * the same root key on the browser. Returns null with no in-memory root key or2339     * a checksum-invalid phrase.2340     */2341    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2342        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2343        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2344        val rk = mutateState { rootKey?.copyOf() } ?: run {2345            VaultCrypto.zero(material)2346            return null2347        }2348        var keyBytes: ByteArray? = null2349        var plaintext: ByteArray? = null2350        return try {2351            val salt = VaultCrypto.generateSalt()2352            val recoveryKey = deriveRecoveryKey(material, salt)2353            keyBytes = recoveryKey.encoded2354            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2355            // combined = nonce(12) || ciphertext || tag(16)2356            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2357            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2358            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2359            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2360            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2361            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2362        } finally {2363            VaultCrypto.zero(rk)2364            VaultCrypto.zero(material)2365            keyBytes?.let(VaultCrypto::zero)2366            plaintext?.let(VaultCrypto::zero)2367        }2368    }23692370    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2371    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2372        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2373        var keyBytes: ByteArray? = null2374        var pt: ByteArray? = null2375        return try {2376            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2377            keyBytes = recoveryKey.encoded2378            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2379            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2380            hexToRootKey(String(pt, Charsets.US_ASCII))2381        } catch (_: Exception) {2382            null2383        } finally {2384            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2385            // IKM-equivalent of the vault identity. material+keyBytes were2386            // already zeroed; pt was leaking.2387            VaultCrypto.zero(material)2388            keyBytes?.let(VaultCrypto::zero)2389            pt?.let(VaultCrypto::zero)2390        }2391    }23922393    /**2394     * Re-seal slot A from the current in-memory state under the live key, carrying2395     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2396     * in decoy mode (recovery is never offered for the decoy) and persists without2397     * a version bump, like the recovery-words metadata write it replaces.2398     */2399    private fun persistSlotAWithEscrows(2400        recoveryEscrow: RecoveryEscrow?,2401        rootKeyEscrow: RootKeyEscrow?,2402        expectedEpoch: Long? = null,2403    ): Boolean {2404        val snapshot = snapshotForPersist(expectedEpoch)2405            ?: throw IllegalStateException("vault_locked_or_stale")2406        if (snapshot.decoy) {2407            VaultCrypto.zero(snapshot.key)2408            return false2409        }2410        try {2411            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2412            val plaintext = VaultCodec.encode(snapshot.payload)2413            val blob = try {2414                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2415            } finally {2416                // The escrow path used to drop this reference before zeroing it;2417                // keep it live through the fill on both success and failure.2418                VaultCrypto.zero(plaintext)2419            }2420            val next = prior.copy(2421                blob = blob,2422                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2423                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2424            )2425            beforeDurableCommit()2426            return withEpochCommit(snapshot.epoch) {2427                if (!store.writeSlotA(next)) return@withEpochCommit false2428                try {2429                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2430                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2431                    // the duress slot too. A real decoy is left alone; a fake is2432                    // re-sized to slot A's new ciphertext length.2433                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2434                } catch (e: Exception) {2435                    store.writeSlotA(prior)2436                    throw e2437                }2438                true2439            } ?: false2440        } finally {2441            VaultCrypto.zero(snapshot.key)2442        }2443    }24442445    // ---- Internals --------------------------------------------------------24462447    private fun requireUnlocked(): Long {2448        // H-03: read derivedKey atomically with [lock]'s reset; without it,2449        // [lock] from a background lifecycle event can run between this read2450        // and the caller's first state touch.2451        stateLock.lock()2452        try {2453            if (derivedKey == null) throw IllegalStateException("vault_locked")2454            return sessionEpoch2455        } finally {2456            stateLock.unlock()2457        }2458    }24592460    /** A locked or half-adopted session must not expose secret-bearing fields. */2461    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24622463    private fun countDuplicateEntriesLocked(): Int {2464        val seen = mutableMapOf<String, VaultEntry>()2465        var duplicates = 02466        for (entry in entryList.filter { it.deletedAt == null }) {2467            val key = entry.duplicateKey()2468            val existing = seen[key]2469            if (existing == null) {2470                seen[key] = entry2471            } else {2472                duplicates++2473                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2474                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2475                if (entryStamp > existingStamp) seen[key] = entry2476            }2477        }2478        return duplicates2479    }24802481    private fun VaultEntry.duplicateKey(): String {2482        if (type == "note") return "note|${title.lowercase()}"2483        val passkeyData = passkey2484        if (passkeyData != null) {2485            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2486        }2487        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2488    }24892490    /**2491     * Capture the current slot pair (and metadata) into the history list.2492     * Returns true when the history write landed, false when it didn't — callers2493     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2494     * must check the result and roll back if it does not.2495     */2496    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2497        val epoch = expectedEpoch ?: currentEpoch()2498        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2499        val slotA = store.loadSlotA() ?: return false2500        val vaultA = VaultCodec.encodeSlot(slotA)2501        val vaultB = store.loadSlotB() ?: slotA.blob2502        // H-04: capture the duress slot so a restore preserves the at-rest2503        // triad, not just the A/B pair. The duress slot is always present2504        // (real or fake), so loadDecoySlot() returning null is a logic error2505        // — fall through to a fake duress in that case rather than silently2506        // losing the record from the snapshot.2507        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2508            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162509            val fakeBlob = ByteArray(target).also(rng::nextBytes)2510            val fakeSalt = VaultCrypto.generateSalt()2511            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2512        }2513        val history = store.loadVaultHistory()2514        val latest = history.snapshots.firstOrNull()2515        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2516        val entryCount = mutateIfCurrentEpoch(epoch) {2517            if (derivedKey != null && !decoyMode) {2518                bucketEntryCount(entryList.count { it.deletedAt == null })2519            } else {2520                null2521            }2522        } ?: return false2523        return try {2524            val snapshot = VaultSnapshot(2525                id = UUID.randomUUID().toString(),2526                savedAt = System.currentTimeMillis(),2527                reason = reason,2528                vault_a = vaultA,2529                vault_b = vaultB,2530                vault_d = vaultD,2531                entryCount = entryCount,2532            )2533            // P1-4 (codex): return the history-write boolean to callers so a2534            // false return no longer leaves the snapshot out of history while2535            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2536            withEpochCommit(epoch) {2537                store.writeVaultHistory(2538                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2539                )2540            } ?: false2541        } catch (_: Exception) {2542            false2543        }2544    }25452546    private fun bucketEntryCount(n: Int): Int = when {2547        n <= 0 -> 02548        n <= 10 -> 102549        n <= 25 -> 252550        n <= 50 -> 502551        n <= 100 -> 1002552        n <= 250 -> 2502553        n <= 500 -> 5002554        else -> ((n + 499) / 500) * 5002555    }25562557    /**2558     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2559     * so it is stored explicitly and survives the sync wire format rather than being2560     * re-derived at render time. Non-card entries pass through unchanged.2561     *2562     * M-03: never overwrite a stored last4 with null. A card kept only as last42563     * (e.g. after a PeachScript import that had no PAN) must keep that value;2564     * re-deriving from an absent cardNumber would silently zero the field.2565     */2566    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2567        if (!entry.isCard()) return entry2568        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2569        val derived = if (digits.length >= 4) digits.takeLast(4) else null2570        return entry.copy(last4 = derived ?: entry.last4)2571    }25722573    /**2574     * On unlock, hard-delete trashed entries older than the retention window.2575     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2576     * (the app's "Never" option), so nothing is purged. Persists without a2577     * version bump and without touching the publish state (caller publishes).2578     */2579    private fun autoPurgeExpiredTrash() {2580        val days = _settings.value.trashRetentionDays ?: return2581        if (days <= 0) return2582        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2583        val before = entryList.size2584        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2585        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2586    }25872588    /** The slot-A plaintext envelope for the current in-memory session. */2589    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25902591    private fun currentPayloadLocked(): VaultPayload =2592        VaultPayload(2593            entries = entryList.toList(),2594            duressConfigured = duressConfigured,2595            rootKeyB64 = rootKey?.let(::b64) ?: "",2596            recoveryWordHash = recoveryWordHash,2597            recoveryWordsSet = recoveryWordsSet,2598        )25992600    /**2601     * Adopt the payload's root key, lazily minting one for legacy payloads.2602     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2603     * a root key is not a mutation paired devices should sync against.2604     */2605    private fun ensureRootKey(payload: VaultPayload) {2606        if (payload.rootKeyB64.isNotBlank()) {2607            rootKey = unb64(payload.rootKeyB64)2608            return2609        }2610        rootKey = generateRecoverableRootKey()2611        runCatching { persistSlotA(bumpVersion = false) }2612    }26132614    private fun publish() {2615        _entries.value = entryList.filter { it.deletedAt == null }2616    }26172618    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2619        var plaintext = VaultCodec.encode(currentPayload())2620        return try {2621            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2622            StoredVault(version = 1, salt = b64(salt), blob = blob)2623        } finally {2624            // M-05: full vault plaintext is allocated here once per createVault.2625            // Zeroing in finally keeps the transient copy off the heap after the2626            // caller either succeeds or rolls back via lock().2627            VaultCrypto.zero(plaintext)2628        }2629    }26302631    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2632        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2633        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2634        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2635        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2636        val noise = ByteArray(approxBytes).also(rng::nextBytes)2637        return store.writeSlotB(b64(noise))2638    }26392640    /** H-04: the ciphertext length slot A is using right now, so the duress2641     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2642     *  length equals ciphertext length, so this also matches slot A's plaintext. */2643    private fun matchingCiphertextLength(slotA: StoredVault): Int =2644        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26452646    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2647     *  bytes. Replaces a real decoy when the user clears duress, and is the2648     *  initial duress slot when no decoy is configured — so the record's2649     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2650     *  matches slot A's blob length. */2651    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2652        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162653        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2654        val fakeSalt = VaultCrypto.generateSalt()2655        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2656    }26572658    /** H-04: keep the duress record presence- and size-indistinguishable from2659     *  slot A on every persist. A real decoy is left alone (it was padded at2660     *  setDuressPassphrase time, and we don't have the duress key here to2661     *  re-pad on entry growth — that's an accepted residual tell, see the2662     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2663     *  A's size. */2664    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2665        val isConfigured = mutateState { duressConfigured }2666        return if (isConfigured) true else writeFakeDuressSlot(slotA)2667    }26682669    /**2670     * H-04: convert a browser-shaped duress slot to Android StoredVault2671     * without decrypting. The browser seals slot "b" under the duress2672     * passphrase; decrypting with the main password silently drops it.2673     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2674     * the salt from hex to base64.2675     */2676    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2677        val salt = unhex(b.salt)2678        val nonce = unhex(b.nonce)2679        val ct = unhex(b.ciphertext)2680        val tag = unhex(b.tag)2681        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2682            System.arraycopy(nonce, 0, it, 0, nonce.size)2683            System.arraycopy(ct, 0, it, nonce.size, ct.size)2684            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2685        }2686        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2687    }26882689    /** Re-seal slot A from the current in-memory state under the live key. */2690    private fun persistSlotA(2691        bumpVersion: Boolean,2692        snapshotReason: String? = "local_save",2693        expectedEpoch: Long? = null,2694    ): Boolean {2695        val snapshot = snapshotForPersist(expectedEpoch)2696            ?: throw IllegalStateException("vault_locked_or_stale")2697        return try {2698            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2699            if (snapshot.decoy) {2700                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2701                val plaintext = VaultCodec.encode(snapshot.payload)2702                val blob = try {2703                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2704                } finally {2705                    // Keep the last reference alive until the zero, including the2706                    // successful encrypt path (M-05).2707                    VaultCrypto.zero(plaintext)2708                }2709                beforeDurableCommit()2710                return withEpochCommit(snapshot.epoch) {2711                    store.writeDecoySlot(prior.copy(blob = blob))2712                } ?: false2713            }2714            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2715            // H-01: captured BEFORE the noise write so a stamp failure can restore2716            // the prior slot B alongside the prior slot A. Slot B is always2717            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2718            // leaves (new A, new B, old version) on disk — partial commit.2719            val priorB = store.loadSlotB()2720            beforeDurableCommit()2721            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2722            val plaintext = VaultCodec.encode(snapshot.payload)2723            val blob = try {2724                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2725            } finally {2726                // Do not drop [plaintext] before fill(0): the serialized vault2727                // contains every entry password and is the primary M-05 target.2728                VaultCrypto.zero(plaintext)2729            }2730            val next = prior.copy(blob = blob)2731            // The epoch check and the entire A/B/D/version commit share one brief2732            // stateLock critical section. lock() either wins before this block or2733            // waits until the already-committed write is complete; it can never2734            // clear the session between the check and the durable write.2735            withEpochCommit(snapshot.epoch) {2736                if (!store.writeSlotA(next)) return@withEpochCommit false2737                try {2738                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2739                    // H-04: duress slot stays present (real one untouched, fake one2740                    // re-padded to slot A's current size on every persist).2741                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2742                    if (bumpVersion) store.stampLocalMutation()2743                } catch (e: Exception) {2744                    store.writeSlotA(prior)2745                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2746                    throw e2747                }2748                true2749            } ?: false2750        } finally {2751            // Snapshot keys are copies, so lock() may zero the live key while this2752            // operation is still deriving/encrypting; always scrub our copy too.2753            VaultCrypto.zero(snapshot.key)2754        }2755    }27562757    /**2758     * H-01: durably commit a slot-A rotation by writing slot A, its2759     * size-matched noise slot B, and the local-mutation stamp in order. Any2760     * failure rolls the previously-committed steps back so the at-rest pair2761     * matches what was there before the call. Returns true only when every2762     * step succeeded — callers must NOT swap their session key on false.2763     */2764    private fun commitVaultRotation(2765        priorA: StoredVault,2766        priorB: String?,2767        nextA: StoredVault,2768        expectedEpoch: Long,2769    ): Boolean {2770        beforeDurableCommit()2771        return withEpochCommit(expectedEpoch) {2772            if (!store.writeSlotA(nextA)) return@withEpochCommit false2773            // P1-1 (codex): the noise write's boolean AND any thrown exception from2774            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2775            // escaped the helper and left slot A committed.2776            try {2777                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2778                // H-04: a password rotation changes slot A's plaintext size (new2779                // salt → re-encrypted blob), so the duress slot must be re-padded2780                // alongside. writeCompanionDuressSlot leaves a real decoy alone2781                // and re-emits a fake of slot A's size otherwise.2782                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2783                store.stampLocalMutation()2784            } catch (_: Exception) {2785                rollbackRotation(priorA, priorB)2786                return@withEpochCommit false2787            }2788            true2789        } ?: false2790    }27912792    /**2793     * Best-effort undo of the writes [commitVaultRotation] may have committed.2794     * Each step runs independently — a failed rollback must not mask the2795     * original commit failure. Log is a throwing stub off-device.2796     */2797    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2798        val failures = mutableListOf<String>()2799        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2800            .onFailure { failures += "writeSlotA: $it" }2801        if (priorB != null) {2802            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2803                .onFailure { failures += "writeSlotB: $it" }2804        }2805        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2806    }28072808    /** Persist a local mutation, rolling the in-memory list back on failure. */2809    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2810        try {2811            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2812                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2813                throw IllegalStateException("storage_write_failed")2814            }2815        } catch (e: Exception) {2816            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2817            throw e2818        }2819        // A lock can win in the small interval after the durable commit and2820        // before publication. The epoch check prevents pre-lock entries from2821        // being republished into a newly locked (or newly unlocked) session.2822        mutateIfCurrentEpoch(expectedEpoch) { publish() }2823    }2824}