VaultRepository.kt

133.5 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton2526/** Lenient parser for the Codex vault-blob wrapper only. */27private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829/** Omits null optional fields — used for browser-shaped duress slot export. */30private val BrowserSlotJson = Json { encodeDefaults = false }3132private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)33private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)34private fun unhex(s: String): ByteArray {35    require(s.length % 2 == 0) { "odd-length hex" }36    return ByteArray(s.length / 2) { i ->37        val hi = Character.digit(s[i * 2], 16)38        val lo = Character.digit(s[i * 2 + 1], 16)39        require(hi >= 0 && lo >= 0) { "bad hex digit" }40        ((hi shl 4) or lo).toByte()41    }42}4344/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */45private const val PASSWORD_HISTORY_MAX = 5046private const val VAULT_HISTORY_MAX = 54748// How long the transient-background-lock suppression stays valid. If the49// launched system Activity never reports the expected lifecycle stop (picker50// crashed, user backed out into another app, OEM killed the picker process),51// the flag expires and the next genuine background event locks the vault52// rather than consuming a stale suppression.53private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L5455sealed interface UnlockResult {56    data object Success : UnlockResult57    data object WrongPassword : UnlockResult58    data class Error(val reason: String) : UnlockResult59}6061sealed interface CreateVaultResult {62    data object Success : CreateVaultResult63    data class Error(val reason: String) : CreateVaultResult64}6566sealed interface AdoptVaultResult {67    data class Success(val vaultId: String) : AdoptVaultResult68    data class Error(val reason: String) : AdoptVaultResult69}7071/**72 * In-memory vault session backed by [VaultStore]. Faithful port of the browser73 * `vault.ts` semantics that matter for correctness:74 *75 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched76 *    noise blob so a fresh install shows two similarly-shaped records.77 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a78 *    deletion always wins last-write-wins against an in-flight edit.79 *  - Local writes bump the version counter; sync-applied writes do NOT (so a80 *    sync round never cascades into another outbound sync).81 *  - add/update/delete roll back in-memory state if the disk write fails.82 *  - destroyVault never touches the Pro license (separate store).83 */84@Singleton85class VaultRepository @Inject constructor(86    private val store: VaultStorage,87) {88    private val mutex = Mutex()89    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend90    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync91    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so92    // lock() can serialize against an in-flight suspend op without blocking on93    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and94    // take [stateLock] only around the state-write portions. Acquisition order95    // is always mutex → stateLock.96    private val stateLock = ReentrantLock()97    private val rng = SecureRandom()9899    /**100     * Monotonic session generation.  [lock] advances this before clearing any101     * secret state; work that captured the previous generation is therefore102     * stale even if it is still running its (non-suspending) storage step.103     */104    private var sessionEpoch: Long = 0L105106    private fun nextSessionEpoch(epoch: Long): Long =107        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L108109    /** Test seam for deterministic "lock while waiting to commit" races. */110    @VisibleForTesting111    internal var beforeDurableCommit: () -> Unit = {}112113    /** Test seam after a failed Codex rollback, while the epoch fence is held. */114    @VisibleForTesting115    internal var afterCodexRollback: () -> Unit = {}116117    /**118     * Run [block] with [stateLock] held. Used to fence the small state-write119     * portions of suspend operations against [lock] and [requireUnlocked]120     * racing from another thread.121     */122    private inline fun <T> mutateState(block: () -> T): T {123        stateLock.lock()124        return try { block() } finally { stateLock.unlock() }125    }126127    /** Run a durable commit only while the session generation is still live. */128    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {129        stateLock.lock()130        return try {131            if (sessionEpoch != epoch) null else block()132        } finally {133            stateLock.unlock()134        }135    }136137    /** Test seam for a caller that must stage work before the epoch fence. */138    @VisibleForTesting139    internal var beforeSessionEpochCommit: () -> Unit = {}140141    /**142     * Test seam for the narrower H-03 race: a suspend mutation has authenticated143     * the session and staged its candidate state, but has not yet entered the144     * epoch-fenced in-memory write. Production callers leave this as a no-op.145     */146    @VisibleForTesting147    internal var beforeSessionStateMutation: () -> Unit = {}148149    /**150     * Run an external session-bound commit while the repository's epoch fence is151     * held. The callback is deliberately non-suspending: [lock] cannot advance152     * the epoch until the whole external batch has reached its linearization153     * point.154     */155    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {156        beforeSessionEpochCommit()157        return withEpochCommit(epoch, block)158    }159160    private fun currentEpoch(): Long = mutateState { sessionEpoch }161162    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =163        mutateState { if (sessionEpoch == epoch) block() else null }164165    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */166    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {167        beforeSessionStateMutation()168        return mutateIfCurrentEpoch(epoch, block)169    }170171    private data class PersistSnapshot(172        val epoch: Long,173        val key: ByteArray,174        val payload: VaultPayload,175        val decoy: Boolean,176    )177178    /** Copy all session material while fenced; callers own and must zero [key]. */179    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {180        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null181        val liveKey = derivedKey ?: return@mutateState null182        PersistSnapshot(183            epoch = sessionEpoch,184            key = liveKey.copyOf(),185            payload = currentPayloadLocked(),186            decoy = decoyMode,187        )188    }189190    private var derivedKey: ByteArray? = null191    private var rootKey: ByteArray? = null192    private var entryList: MutableList<VaultEntry> = mutableListOf()193    private var duressConfigured: Boolean = false194    private var recoveryWordHash: String? = null195    private var recoveryWordsSet: Boolean = false196    /** True when an authenticated vault payload is available for local reads. */197    private var sessionDataAvailable: Boolean = false198    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */199    private var decoyMode: Boolean = false200201    private val _isUnlocked = MutableStateFlow(false)202    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()203204    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())205    /** Active (non-deleted) entryList. */206    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()207208    private val _settings = MutableStateFlow(VaultSettings())209    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()210211    /** Clear every in-memory session field while [stateLock] is held. */212    private fun clearSessionStateLocked() {213        derivedKey?.let(VaultCrypto::zero)214        rootKey?.let(VaultCrypto::zero)215        derivedKey = null216        rootKey = null217        entryList = mutableListOf()218        duressConfigured = false219        recoveryWordHash = null220        recoveryWordsSet = false221        sessionDataAvailable = false222        decoyMode = false223        _settings.value = VaultSettings()224        _entries.value = emptyList()225        _isUnlocked.value = false226    }227228    fun hasVault(): Boolean = store.hasVault()229230    // ---- Codex (printed paper backup) -------------------------------------231232    /**233     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the234     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's235     * A/B naming where slot B is the duress decoy); the noise slot is local-only236     * and never crosses devices. Restorable on any device that has the master237     * password. Null when no vault exists.238     */239    fun codexVaultBlobBase64(): String? {240        val slotA = store.loadSlotA() ?: return null241        // H-04: duress slot is always present after createVault (real or fake),242        // so the wrapper can rely on it being loadable.243        val slotD = store.loadDecoySlot() ?: return null244        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""245        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))246    }247248    /**249     * Replace the local vault from a Codex blob, then unlock with [password].250     * Writes slot A (and a size-matched noise slot B) before unlocking so the251     * restored vault is at rest exactly as a freshly-created one would be.252     */253    /** A slot is stored either as a JSON string (browser) or inline object (Android). */254    private fun JsonElement?.asSlotJson(): String? = when {255        this == null || this is JsonNull -> null256        this is JsonPrimitive && isString -> content257        else -> toString()258    }259260    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {261        val expectedEpoch = currentEpoch()262        var candidateKey: ByteArray? = null263        var plaintext: ByteArray? = null264        return try {265            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)266            // Parsed as JSON, not matched with a regex. The wrapper is267            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which268            // generates most real Codexes — writes each slot as a JSON269            // *string*, with "b" present. A regex looking for an object270            // literal under "a" at the very end of the wrapper matches271            // neither of those, so every browser-generated Codex failed here272            // with "invalid_codex_blob" after decoding perfectly.273            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject274                ?: return UnlockResult.Error("invalid_codex_blob")275            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")276            // A Codex printed from the browser carries a browser-shaped slot,277            // which is the common case — most Codexes are printed there. It is278            // translated to Android's shape (see BrowserSlot) rather than279            // rejected. A translation failure is a wrong password, not a bad280            // blob: the codes decoded fine, the password just didn't open them.281            val slotA = VaultCodec.decodeSlot(slotJson)282                ?: BrowserSlot.parse(slotJson)?.let {283                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword284                }285                ?: return UnlockResult.Error("invalid_codex_blob")286            // C-01: authenticate the candidate ENTIRELY in memory before touching287            // storage. VaultCodec.decodeSlot is structural — an Android-shaped288            // Codex parses under ANY password — so writing slot A first and289            // unlocking afterwards destroyed the device's existing vault every290            // time the user mistyped the password.291            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))292            plaintext = decryptSlotABlob(slotA, candidateKey)293            // LV-1: a Codex printed from a vault sealed before NFC normalization294            // opens only under the raw password bytes. unlockLocked used to supply295            // this fallback; authenticating up front here has to keep it, or those296            // Codexes become unrestorable.297            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {298                VaultCrypto.zero(candidateKey)299                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))300                plaintext = decryptSlotABlob(slotA, candidateKey)301            }302            if (plaintext == null) return UnlockResult.WrongPassword303            val payload = try {304                VaultCodec.decode(plaintext)305            } catch (_: Exception) {306                return UnlockResult.Error("invalid_codex_blob")307            }308309            val priorA = store.loadSlotA()310            val priorB = store.loadSlotB()311            val priorD = store.loadDecoySlot()312            // Carry the real duress slot across when the Codex has one — the313            // browser writes "b" as its duress-shaped slot, and dropping it314            // here would silently destroy a configured decoy.315            // H-04: write browser B into the duress slot (not the noise slot),316            // translating browser-shaped slots to Android's StoredVault shape.317            val codexDuressJson = wrapper["b"].asSlotJson()318            val codexDuress: StoredVault? = codexDuressJson?.let {319                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->320                    // H-04: pass-through — the browser duress slot is sealed321                    // under the duress passphrase, not the main password. Trying322                    // to decrypt with the slot-A password fails the GCM tag and323                    // silently drops a real decoy. Re-encode the browser hex324                    // fields as Android base64 salt/blob so tryUnlockDuress can325                    // still open it with the duress passphrase.326                    browserDuressPassthrough(b)327                }328            }329330            beforeDurableCommit()331            val committed = runCatching {332                withEpochCommit(expectedEpoch) {333                    try {334                        // Keep every slot write, including the inverse, under the335                        // same epoch fence. Releasing stateLock after a partial336                        // write and reacquiring it for rollback lets lock() advance337                        // the epoch in between and strand the incoming slot A.338                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")339                        // Noise slot is always size-matched to slot A — local-only,340                        // never written from a Codex.341                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")342                        // H-04: duress slot always present; real one from the Codex or343                        // a fake the size of slot A.344                        val duressOk = if (codexDuress != null) {345                            store.writeDecoySlot(codexDuress)346                        } else {347                            writeFakeDuressSlot(slotA)348                        }349                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")350                        store.stampLocalMutation()351                        true352                    } catch (_: Exception) {353                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))354                        // Keep the fence held through the final rollback355                        // observation. A concurrent lock() may queue here, but356                        // cannot invalidate the restored A/B/D triad.357                        afterCodexRollback()358                        false359                    }360                } ?: false361            }.getOrDefault(false)362            if (!committed) {363                return UnlockResult.Error("storage_write_failed")364            }365366            val ownedKey = candidateKey!!367            candidateKey = null // the session owns the key from here on368            adoptSlotA(ownedKey, payload, expectedEpoch)369        } catch (e: Exception) {370            // A raw kotlinx parse error ("Unexpected JSON token at offset N")371            // reaching the user says nothing about WHICH json failed — the372            // wrapper, or the payload decrypted out of it. Codex screens are373            // FLAG_SECURE so this can't be screenshotted either. Debug builds374            // log the structure around the failure; alphanumerics are masked so375            // no ciphertext or vault content lands in a log.376            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))377            UnlockResult.Error(e.message ?: "restore_failed")378        } finally {379            plaintext?.let(VaultCrypto::zero)380            candidateKey?.let(VaultCrypto::zero)381        }382    }383384    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */385    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =386        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()387388    /**389     * Undo a partially-committed Codex restore while [stateLock] is already held.390     * Every step is attempted independently so one failure cannot strand the391     * others. Callers that need epoch validation must enter through392     * [withEpochCommit] first; this helper deliberately does not reacquire the393     * lock, avoiding a reentrant gap between a failed write and its inverse.394     * With no prior vault the accurate inverse is having none at all, rather395     * than a half-written slot A that [hasVault] would report as a real vault.396     */397    private fun rollbackRestoreLocked(398        priorA: StoredVault?,399        priorB: String?,400        priorD: StoredVault? = null,401    ): List<String> {402        val failures = mutableListOf<String>()403        if (priorA == null) {404            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }405        } else {406            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }407                .onFailure { failures += "writeSlotA: $it" }408            if (priorB != null) {409                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }410                    .onFailure { failures += "writeSlotB: $it" }411            }412            // H-04: restore the prior duress slot — a half-committed restore that413            // left the duress record on the Codex side and slot A on the prior414            // side would put the at-rest pair in a state that never existed.415            if (priorD != null) {416                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }417                    .onFailure { failures += "writeDecoySlot: $it" }418            } else {419                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }420            }421        }422        return failures423    }424425    private fun reportRollbackFailures(failures: List<String>) {426        // Log is a throwing stub off-device, and a failed log must never mask427        // the rollback result the caller is about to report.428        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }429    }430431    /** Debug-only: structure of a failed Codex restore, with content masked. */432    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {433        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)434        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()435        val window = offset?.let {436            val from = (it - 40).coerceAtLeast(0)437            val to = (it + 40).coerceAtMost(wrapperJson.length)438            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")439        }440        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()441        val slot = wrapper?.get("a")442        buildString {443            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)444            append(" | blobBase64=").append(vaultBlobBase64.length)445            append(" wrapperChars=").append(wrapperJson.length)446            append(" wrapperParses=").append(wrapper != null)447            append(" keys=").append(wrapper?.keys)448            append(" slotAKind=").append(449                when {450                    slot == null -> "absent"451                    slot is JsonPrimitive && slot.isString -> "string"452                    slot is JsonObject -> "object:" + slot.keys453                    else -> slot::class.simpleName454                },455            )456            if (window != null) append(" masked@").append(offset).append("=").append(window)457        }458    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }459460    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {461        val expectedEpoch = currentEpoch()462        var key: ByteArray? = null463        return try {464            val salt = VaultCrypto.generateSalt()465            key = VaultCrypto.deriveKey(password, salt)466            if (mutateIfCurrentEpoch(expectedEpoch) {467                entryList = mutableListOf()468                duressConfigured = false469                recoveryWordHash = null470                recoveryWordsSet = false471                decoyMode = false472                sessionDataAvailable = false473                rootKey = generateRecoverableRootKey()474            } == null) return CreateVaultResult.Error("stale_create")475            // M-04: the fields above have to be live before sealSlotA, since it476            // seals currentPayload(). That stages a root key the session would477            // otherwise keep after a failed create — [lock] discards the whole478            // staged session on every non-success exit below.479            val slotA = sealSlotA(key!!, salt)480            // P1-3 (codex): every later step must roll the slot pair back if it481            // fails — leaving a half-built vault is worse than refusing to make it.482            beforeDurableCommit()483            val created = withEpochCommit(expectedEpoch) {484                if (!store.writeSlotA(slotA)) return@withEpochCommit false485                try {486                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")487                    // H-04: duress slot must be present from the start so its488                    // existence on disk is independent of duressConfigured.489                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")490                    store.stampLocalMutation()491                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")492                    true493                } catch (_: Exception) {494                    // Half-built vault on disk is worse than no vault — refuse to495                    // create if any step past slot A fails.496                    store.clearVault()497                    false498                }499            } ?: false500            if (!created) {501                lock()502                return CreateVaultResult.Error("storage_write_failed")503            }504            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")505            if (mutateIfCurrentEpoch(expectedEpoch) {506                derivedKey = sessionKey507                key = null508                publish()509                _isUnlocked.value = true510                sessionDataAvailable = true511                sessionEpoch = nextSessionEpoch(sessionEpoch)512            } == null) return CreateVaultResult.Error("stale_create")513            CreateVaultResult.Success514        } catch (e: Exception) {515            lock()516            CreateVaultResult.Error(e.message ?: "create_failed")517        } finally {518            key?.let(VaultCrypto::zero)519        }520    }521522    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {523        val expectedEpoch = currentEpoch()524        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")525        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")526        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {527            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")528        }529        var adoptedRootKey: ByteArray? = null530        var nextDerivedKey: ByteArray? = null531        var sessionRootKey: ByteArray? = null532        return try {533            adoptedRootKey = hexToRootKey(rootKeyHex)534            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {535                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")536            }537            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)538            val salt = VaultCrypto.generateSalt()539            nextDerivedKey = VaultCrypto.deriveKey(password, salt)540            sessionRootKey = adoptedRootKey.copyOf()541            val payload = VaultPayload(542                entries = emptyList(),543                duressConfigured = false,544                rootKeyB64 = b64(sessionRootKey),545                recoveryWordHash = null,546                recoveryWordsSet = false,547            )548            val plaintext = VaultCodec.encode(payload)549            val blob = try {550                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))551            } finally {552                VaultCrypto.zero(plaintext)553            }554            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)555            // P1-3 (codex): same partial-commit guard as createVault — any failure556            // past slot A wipes the half-built vault and refuses adoption.557            beforeDurableCommit()558            val created = withEpochCommit(expectedEpoch) {559                if (!store.writeSlotA(slotA)) return@withEpochCommit false560                try {561                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")562                    // H-04: duress slot must be present from the start so its563                    // existence on disk is independent of duressConfigured.564                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")565                    store.stampLocalMutation()566                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")567                    true568                } catch (_: Exception) {569                    store.clearVault()570                    false571                }572            } ?: false573            if (!created) return AdoptVaultResult.Error("storage_write_failed")574575            val adopted = mutateIfCurrentEpoch(expectedEpoch) {576                val previousDerivedKey = derivedKey577                val previousRootKey = rootKey578                derivedKey = nextDerivedKey579                rootKey = sessionRootKey580                nextDerivedKey = null581                sessionRootKey = null582                entryList = mutableListOf()583                duressConfigured = false584                recoveryWordHash = null585                recoveryWordsSet = false586                decoyMode = false587                sessionDataAvailable = true588                publish()589                _isUnlocked.value = true590                sessionEpoch = nextSessionEpoch(sessionEpoch)591                previousDerivedKey to previousRootKey592            }593            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")594            val (previousDerivedKey, previousRootKey) = adopted595            previousDerivedKey?.let(VaultCrypto::zero)596            previousRootKey?.let(VaultCrypto::zero)597            AdoptVaultResult.Success(vaultId)598        } catch (e: Exception) {599            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")600        } finally {601            adoptedRootKey?.let(VaultCrypto::zero)602            nextDerivedKey?.let(VaultCrypto::zero)603            sessionRootKey?.let(VaultCrypto::zero)604        }605    }606607    suspend fun unlock(password: String): UnlockResult = mutex.withLock {608        unlockLocked(password, currentEpoch())609    }610611    /** Unlock body without acquiring the mutex — callers must already hold it. */612    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {613        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")614        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))615        val payload = try {616            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)617            try {618                VaultCodec.decode(plaintext)619            } finally {620                VaultCrypto.zero(plaintext)621            }622        } catch (_: Exception) {623            VaultCrypto.zero(key)624            // LV-1: a vault sealed before NFC normalization with a non-NFC625            // password derives a different key now. If the supplied password is626            // not already in NFC form, retry with the raw (un-normalized) bytes.627            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {628                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)629                if (legacy != null) return legacy630            }631            return UnlockResult.WrongPassword632        }633        return adoptSlotA(key, payload, expectedEpoch)634    }635636    /**637     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw638     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed639     * under the NFC-normalized key — so every future unlock takes the normal640     * path. Returns null when the raw key also fails (genuinely wrong password)641     * OR when any step of the heal commits fails (the original slot A is restored642     * so disk and session stay aligned).643     */644    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {645        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))646        var newKey: ByteArray? = null647        return try {648            val payload = try {649                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)650                try {651                    VaultCodec.decode(plaintext)652                } finally {653                    VaultCrypto.zero(plaintext)654                }655            } catch (_: Exception) {656                return null657            }658            // Heal: re-seal slot A under the NFC key (mirrors changePassword's659            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.660            val newSalt = VaultCrypto.generateSalt()661            newKey = VaultCrypto.deriveKey(password, newSalt)662            val plaintext = VaultCodec.encode(payload)663            val blob = try {664                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))665            } finally {666                VaultCrypto.zero(plaintext)667            }668            val next = StoredVault(669                version = slotA.version,670                salt = b64(newSalt),671                blob = blob,672                recoveryEscrow = slotA.recoveryEscrow,673                rootKeyEscrow = slotA.rootKeyEscrow,674            )675            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,676            // or stampLocalMutation must restore the prior slot A AND slot B so disk677            // and the eventual session key never disagree on the wrapping.678            val priorB = store.loadSlotB()679            beforeDurableCommit()680            val healed = withEpochCommit(expectedEpoch) {681                if (!store.writeSlotA(next)) {682                    false683                } else try {684                    if (!writeMatchingNoiseSlotB(next)) {685                        runCatching { store.writeSlotA(slotA) }686                        if (priorB != null) runCatching { store.writeSlotB(priorB) }687                        false688                    } else if (!writeCompanionDuressSlot(next)) {689                        runCatching { store.writeSlotA(slotA) }690                        if (priorB != null) runCatching { store.writeSlotB(priorB) }691                        false692                    } else {693                        store.stampLocalMutation()694                        true695                    }696                } catch (_: Exception) {697                    runCatching { store.writeSlotA(slotA) }698                    if (priorB != null) runCatching { store.writeSlotB(priorB) }699                    false700                }701            } ?: false702            if (healed) {703                VaultCrypto.zero(rawKey!!)704                rawKey = null705                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot706                newKey = null707                result708            } else {709                VaultCrypto.zero(newKey!!)710                newKey = null711                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key712                rawKey = null713                result714            }715        } finally {716            // Cover codec, encryption, storage, and stale-epoch failures before717            // either candidate key can be transferred into the live session.718            rawKey?.let(VaultCrypto::zero)719            newKey?.let(VaultCrypto::zero)720        }721    }722723    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */724    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {725        return try {726            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they727            // can throw after the fields above are live. There is no prior session728            // worth restoring — adoption has already replaced it — so a failed729            // adoption discards everything rather than leaving entries and keys730            // resident behind _isUnlocked=false.731            val adopted = mutateIfCurrentEpoch(expectedEpoch) {732                derivedKey = key733                decoyMode = false734                entryList = payload.entries.toMutableList()735                duressConfigured = payload.duressConfigured736                recoveryWordHash = payload.recoveryWordHash737                recoveryWordsSet = payload.recoveryWordsSet738                sessionDataAvailable = true739                _settings.value = store.loadSettings()740                ensureRootKey(payload)741                autoPurgeExpiredTrash()742                publish()743                _isUnlocked.value = true744                // A successful adoption starts a new generation. A sibling745                // SlotAUnlock captured in the prior epoch will fail closed at746                // the next commit instead of replacing this session.747                sessionEpoch = nextSessionEpoch(sessionEpoch)748            }749            if (adopted == null) {750                VaultCrypto.zero(key)751                UnlockResult.Error("stale_unlock")752            } else {753                UnlockResult.Success754            }755        } catch (e: Exception) {756            // Do not call lock() here: a concurrent newer session may already757            // own this repository. Only invalidate the failed epoch's fields.758            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }759            VaultCrypto.zero(key)760            UnlockResult.Error(e.message ?: "unlock_failed")761        }762    }763764    // ---- Constant-time unlock (MF-5) --------------------------------------765766    /**767     * A slot-A unlock that has been derived but not yet adopted. Holds the live768     * Argon2id key and decoded payload so the caller can derive several candidates769     * concurrently and commit only the winner. The key is owned by this token until770     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.771     */772    class SlotAUnlock internal constructor(773        key: ByteArray,774        internal val payload: VaultPayload,775        internal val epoch: Long,776    ) {777        private var ownedKey: ByteArray? = key778        private var consumed = false779780        /** Transfer the key exactly once to the adopter. */781        internal fun takeKey(): ByteArray? = synchronized(this) {782            if (consumed) return@synchronized null783            consumed = true784            ownedKey.also { ownedKey = null }785        }786787        /** Discard an uncommitted token without touching a transferred key. */788        internal fun discard() = synchronized(this) {789            if (consumed) return@synchronized790            consumed = true791            ownedKey?.let(VaultCrypto::zero)792            ownedKey = null793        }794    }795796    /**797     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.798     * Pure computation (no mutex), so it can run concurrently with other candidate799     * derivations before the caller decides which to commit (MF-5). Returns null on800     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).801     */802    fun tryUnlockSlotA(password: String): SlotAUnlock? {803        val epoch = currentEpoch()804        val slotA = store.loadSlotA() ?: return null805        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))806        return try {807            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)808            val payload = try {809                VaultCodec.decode(plaintext)810            } finally {811                VaultCrypto.zero(plaintext)812            }813            SlotAUnlock(key, payload, epoch)814        } catch (_: Exception) {815            VaultCrypto.zero(key)816            null817        }818    }819820    /** Adopt a previously-derived [SlotAUnlock] as the live session. */821    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {822        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")823        adoptSlotA(key, unlock.payload, unlock.epoch)824    }825826    /** Zero a derived [SlotAUnlock] that will not be adopted. */827    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()828829    // ---- Duress slot B (MF-6) ---------------------------------------------830831    /**832     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key833     * and decoded decoy payload so it can be derived concurrently with slot A and834     * only committed if the duress passphrase was the one entered (MF-5/MF-6).835     */836    class DuressUnlock internal constructor(837        key: ByteArray,838        internal val payload: VaultPayload,839        internal val epoch: Long,840    ) {841        private var ownedKey: ByteArray? = key842        private var consumed = false843844        internal fun takeKey(): ByteArray? = synchronized(this) {845            if (consumed) return@synchronized null846            consumed = true847            ownedKey.also { ownedKey = null }848        }849850        internal fun discard() = synchronized(this) {851            if (consumed) return@synchronized852            consumed = true853            ownedKey?.let(VaultCrypto::zero)854            ownedKey = null855        }856    }857858    /**859     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the860     * session. Success means the entered password is the duress passphrase that keys861     * slot B — indistinguishable from the real slot A at the lock screen. Pure862     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].863     * Returns null when no duress slot exists or the password is not the duress one.864     */865    fun tryUnlockDuress(password: String): DuressUnlock? {866        val epoch = currentEpoch()867        val slot = store.loadDecoySlot() ?: return null868        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))869        return try {870            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)871            val payload = try {872                VaultCodec.decode(plaintext)873            } finally {874                VaultCrypto.zero(plaintext)875            }876            DuressUnlock(key, payload, epoch)877        } catch (_: Exception) {878            VaultCrypto.zero(key)879            null880        }881    }882883    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */884    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {885        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")886        if (currentEpoch() != unlock.epoch) {887            VaultCrypto.zero(key)888            return@withLock UnlockResult.Error("stale_unlock")889        }890        return try {891            val adopted = mutateIfCurrentEpoch(unlock.epoch) {892                derivedKey = key893                decoyMode = true894                entryList = unlock.payload.entries.toMutableList()895                duressConfigured = false896                recoveryWordHash = unlock.payload.recoveryWordHash897                recoveryWordsSet = unlock.payload.recoveryWordsSet898                sessionDataAvailable = true899                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)900                          else VaultCrypto.generateRootKey()901                _settings.value = store.loadSettings()902                publish()903                _isUnlocked.value = true904                sessionEpoch = nextSessionEpoch(sessionEpoch)905            }906            if (adopted == null) {907                VaultCrypto.zero(key)908                UnlockResult.Error("stale_unlock")909            } else {910                UnlockResult.Success911            }912        } catch (e: Exception) {913            // A payload/root/settings failure can occur after the candidate key914            // has been assigned to derivedKey. Tear down only this epoch so a915            // newer session is not disturbed, and scrub the token reference on916            // every error path.917            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }918            VaultCrypto.zero(key)919            UnlockResult.Error(e.message ?: "decoy_unlock_failed")920        }921    }922923    /** Zero a derived [DuressUnlock] that will not be adopted. */924    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()925926    /**927     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an928     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The929     * passphrase is never stored — only its ability to decrypt slot B proves it.930     * Requires the real vault to be unlocked and not itself a decoy session.931     */932    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {933        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().934        val expectedEpoch = mutateState {935            if (derivedKey != null && !decoyMode) sessionEpoch else null936        } ?: return false937        // Reject a duress passphrase that also opens the real vault: it would make the938        // lock screen ambiguous (both slots would decrypt for the same input).939        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }940        var key: ByteArray? = null941        var basePlaintext: ByteArray? = null942        return try {943            val salt = VaultCrypto.generateSalt()944            key = VaultCrypto.deriveKey(duressPassphrase, salt)945            // An empty decoy vault with its own fresh sync root key — nothing real leaks.946            val decoyRoot = VaultCrypto.generateRootKey()947            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }948            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)949            basePlaintext = VaultCodec.encode(decoyPayload)950            // H-04: pad the duress plaintext to slot A's ciphertext length so the951            // duress blob matches slot A's blob on disk. VaultCodec.decode strips952            // trailing NULs, so the extra padding survives encrypt but never953            // reaches the JSON parser.954            // ponytail: only re-pads at configuration time — if slot A grows past955            // its initial bucket (entries cross 4096/16384/etc.), the real decoy956            // stays at the smaller bucket. Re-padding on every persist requires957            // keeping the duress passphrase in memory, which we don't.958            val slotA = store.loadSlotA()959            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0960            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!961            val blob = try {962                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))963            } finally {964                VaultCrypto.zero(plaintext)965                // Padding creates a second array; scrub the original encoded966                // payload too rather than dropping its last reference untouched.967                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)968                basePlaintext = null969                key?.let(VaultCrypto::zero)970                key = null971            }972            beforeDurableCommit()973            val ok = withEpochCommit(expectedEpoch) {974                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))975            } ?: false976            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {977                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }978            } else if (ok) {979                return false980            }981            ok982        } catch (_: Exception) {983            false984        } finally {985            // Cover failures before the encrypt block is entered (for example a986            // codec/copy exception) as well as any early return after key derivation.987            key?.let(VaultCrypto::zero)988            basePlaintext?.let(VaultCrypto::zero)989        }990    }991992993    /**994     * Export the decoy slot as browser-shaped JSON for adoption snapshots.995     *996     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all997     * lowercase hex — identical to the browser's `duress_slot` wire format — so998     * any receiver (browser or Android) can install it via the existing999     * passthrough. Returns null when duress is not configured, no decoy exists,1000     * or any conversion failure occurs. Never decrypts.1001     */1002    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1003        if (!duressConfigured) return null1004        val slot = store.loadDecoySlot() ?: return null1005        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1006    }10071008    /**1009     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1010     *1011     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1012     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1013     * byte arrays are zeroed in finally.1014     */1015    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1016        var salt: ByteArray? = null1017        var blob: ByteArray? = null1018        var nonce: ByteArray? = null1019        var ct: ByteArray? = null1020        var tag: ByteArray? = null1021        try {1022            salt = unb64(slot.salt)1023            blob = unb64(slot.blob)1024            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1025            val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1026            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1027            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1028            val browserSlot = BrowserSlotA(1029                version = slot.version,1030                salt = salt.joinToString("") { "%02x".format(it) },1031                nonce = nonce.joinToString("") { "%02x".format(it) },1032                ciphertext = ct.joinToString("") { "%02x".format(it) },1033                tag = tag.joinToString("") { "%02x".format(it) },1034            )1035            return BrowserSlotJson.encodeToString(BrowserSlotA.serializer(), browserSlot)1036        } finally {1037            salt?.let(VaultCrypto::zero)1038            blob?.let(VaultCrypto::zero)1039            nonce?.let(VaultCrypto::zero)1040            ct?.let(VaultCrypto::zero)1041            tag?.let(VaultCrypto::zero)1042        }1043    }10441045    /**1046     * Install a browser-shaped duress slot received through adoption.1047     *1048     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1049     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1050     * writes the result to the decoy slot. Epoch-fenced like the Codex1051     * restore path so a concurrent lock() cannot strand the write.1052     *1053     * Returns false on invalid input, no live session, or write failure.1054     */1055    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1056        val b = BrowserSlot.parse(json) ?: return false1057        var stored: StoredVault? = null1058        var salt: ByteArray? = null1059        var nonce: ByteArray? = null1060        var ct: ByteArray? = null1061        var tag: ByteArray? = null1062        var blob: ByteArray? = null1063        try {1064            salt = unhex(b.salt)1065            nonce = unhex(b.nonce)1066            ct = unhex(b.ciphertext)1067            tag = unhex(b.tag)1068            blob = ByteArray(nonce.size + ct.size + tag.size).also {1069                System.arraycopy(nonce, 0, it, 0, nonce.size)1070                System.arraycopy(ct, 0, it, nonce.size, ct.size)1071                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1072            }1073            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1074            val expectedEpoch = sessionEpoch1075            beforeDurableCommit()1076            withEpochCommit(expectedEpoch) {1077                store.writeDecoySlot(stored)1078            } ?: false1079        } catch (_: Exception) {1080            false1081        } finally {1082            salt?.let(VaultCrypto::zero)1083            nonce?.let(VaultCrypto::zero)1084            ct?.let(VaultCrypto::zero)1085            tag?.let(VaultCrypto::zero)1086            blob?.let(VaultCrypto::zero)1087        }1088    }10891090    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1091    suspend fun clearDuress(): Boolean = mutex.withLock {1092        // H-04: presence-indistinguishable — replace the real decoy with a fake1093        // blob of slot A's size rather than removing the duress record. The1094        // record stays on disk at the same length, so an at-rest observer1095        // cannot tell "duress cleared" from "no duress ever configured".1096        val expectedEpoch = currentEpoch()1097        val replaced = withEpochCommit(expectedEpoch) {1098            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1099                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1100        } ?: false1101        if (!replaced) return@withLock false1102        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1103        // duressConfigured write so a concurrent lock() cannot land between them.1104        if (!isSessionEpochCurrent(expectedEpoch)) return false1105        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1106        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1107            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1108        }1109        true1110    }11111112    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1113    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1114        var current: Throwable? = error1115        while (current != null) {1116            if (current is javax.crypto.AEADBadTagException) return true1117            // Some Android providers expose a GCM tag failure as the parent1118            // BadPaddingException rather than the AEAD-specific subtype.  A1119            // short ciphertext is an IllegalBlockSizeException and therefore1120            // remains an internal/malformed-payload Error below.1121            if (current is javax.crypto.BadPaddingException) return true1122            current = current.cause1123        }1124        return false1125    }11261127    /** Unlock with an already-derived AES-256 key (biometric path). */1128    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1129        val expectedEpoch = currentEpoch()1130        val slotA = try {1131            store.loadSlotA()1132        } catch (_: Exception) {1133            return@withLock UnlockResult.Error("unlock_key_storage_failed")1134        } ?: return@withLock UnlockResult.Error("no_vault")1135        // Keep the caller-owned key separate from the staged session key. The1136        // biometric caller always zeroes its input in a finally block; adoption1137        // must therefore own a copy and use the same all-or-nothing path as a1138        // password unlock rather than publishing a partially decoded session.1139        var stagedKey: ByteArray? = key.copyOf()1140        return try {1141            val encrypted = try {1142                unb64(slotA.blob)1143            } catch (_: Exception) {1144                return@withLock UnlockResult.Error("invalid_vault_blob")1145            }1146            // A GCM record must include nonce + authentication tag.  Providers1147            // differ on whether a shorter record is reported as BadPadding or1148            // AEADBadTag; reject it before decrypt so malformed storage cannot be1149            // misreported as a user-entered wrong key.1150            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1151                return@withLock UnlockResult.Error("invalid_vault_blob")1152            }1153            val plaintext = try {1154                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1155            } catch (e: Exception) {1156                if (isGcmAuthenticationFailure(e)) {1157                    return@withLock UnlockResult.WrongPassword1158                }1159                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1160            }1161            val payload = try {1162                VaultCodec.decode(plaintext)1163            } catch (_: Exception) {1164                return@withLock UnlockResult.Error("invalid_vault_payload")1165            } finally {1166                VaultCrypto.zero(plaintext)1167            }1168            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1169            val result = adoptSlotA(candidate, payload, expectedEpoch)1170            if (result is UnlockResult.Success) stagedKey = null1171            result1172        } finally {1173            stagedKey?.let(VaultCrypto::zero)1174        }1175    }11761177    fun lock() {1178        // H-03: must observe derivedKey/entryList atomically with any in-flight1179        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1180        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1181        // guards just the brief state reset. Advancing the epoch first invalidates1182        // every operation that captured this session before it reaches a later1183        // durable commit or publication.1184        stateLock.lock()1185        try {1186            sessionEpoch = nextSessionEpoch(sessionEpoch)1187            clearSessionStateLocked()1188        } finally {1189            stateLock.unlock()1190        }1191    }11921193    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11941195    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1196    // is a stub off-device).1197    @VisibleForTesting1198    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11991200    /**1201     * Call immediately before launching a system Activity (a SAF file1202     * picker/save dialog, a share sheet) that briefly takes the whole process1203     * out of the foreground as an expected part of an in-app flow — not a1204     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1205     * two apart on its own (it fires for both), and without this, exporting or1206     * importing a file silently locked and wiped the vault mid-flow the moment1207     * the picker took over, before its result ever reached the app.1208     *1209     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1210     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1211     */1212    fun expectTransientBackground() {1213        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1214    }12151216    /**1217     * Consumed once by the next background-lock check; see [expectTransientBackground].1218     * Returns false (and clears the flag) if the suppression has expired or was1219     * never set — a genuine background event must always lock.1220     */1221    fun consumeTransientBackgroundSuppression(): Boolean {1222        val until = suppressNextBackgroundLockUntil1223        val active = until != 0L && nowMs() < until1224        suppressNextBackgroundLockUntil = 0L1225        return active1226    }12271228    // ---- Sync identity ----------------------------------------------------12291230    /**1231     * The vault's stable sync master key (a copy). Caller must zero it. Backs1232     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1233     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1234     */1235    /**1236     * HOLE 5 — the duress vault never syncs.1237     *1238     * [commitDuressUnlock] gives the decoy session a root key so its own codex1239     * and recovery paths work, which means (unlike the browser, where the decoy1240     * path simply has no root key) the sync surface has to refuse explicitly.1241     * Without this the decoy vault would present a second, different vault_id1242     * to a paired device, and a coerced user opening it would make sync visibly1243     * fail on their other devices — a duress indicator, which is the one thing1244     * a duress vault must never be.1245     */1246    // M-04: the "null when locked" half of the contract was documented but never1247    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1248    // [unlockWithRecoveryWords], which installs a root key with no password key1249    // yet) and in a create that failed after staging. Sync must fail closed1250    // there, so the authoritative unlocked flag gates it, not just decoyMode.1251    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12521253    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1254        if (decoyMode || !_isUnlocked.value) null1255        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1256    }12571258    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1259        mutateState { sessionEpoch == epoch }12601261    internal fun unlockedSessionEpoch(): Long? = mutateState {1262        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1263    }12641265    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12661267    /**1268     * The current session's root key, decoy included. For local-only uses that1269     * must behave identically in a decoy session — Codex backup and recovery1270     * words — because a feature that errors under duress is itself a duress1271     * tell. In a decoy session this is the DECOY's root key (see1272     * [commitDuressUnlock]), never the real vault's, so neither surface can1273     * leak the real vault. Anything that leaves the device must use1274     * [syncMasterKey] instead.1275     */1276    fun sessionRootKey(): ByteArray? = mutateState {1277        if (!sessionDataAvailable) null else rootKey?.copyOf()1278    }12791280    /** Stable per-vault id two paired devices must share to sync. Null when1281     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1282    fun syncVaultId(): String? = mutateState {1283        if (decoyMode || !_isUnlocked.value) null1284        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1285    }12861287    /**1288     * Browser parity (MV-8): rotate the sync identity by replacing the stable1289     * root key. Entries remain under the master-password key, while sync1290     * credentials, vault_id, and recovery words all change.1291     */1292    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1293        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1294            ?: return false1295        installRootKey(generateRecoverableRootKey(), expectedEpoch)1296    }12971298    /**1299     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1300     * keeping every local entry.1301     *1302     * Sync identity (vault id, signing keys) derives from the root key, and1303     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1304     * created vaults can never reconcile. Taking the other side's root key is1305     * what makes them one vault; from the next sync on, both entry sets merge.1306     *1307     * Entries survive because they are sealed under the password-derived key,1308     * not the root key — the same property [rotateSyncIdentity] relies on.1309     *1310     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1311     * this device's previous recovery words stop working for good, and its new1312     * words are the source vault's words, because words are a deterministic1313     * encoding of the root key.1314     */1315    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1316        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1317            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1318        }1319        val expectedEpoch = mutateState {1320            when {1321                derivedKey == null -> null1322                decoyMode -> Long.MIN_VALUE1323                else -> sessionEpoch1324            }1325        }1326        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1327        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1328        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13291330        val incoming = hexToRootKey(rootKeyHex)1331        if (incoming.size != VaultCrypto.KEY_SIZE) {1332            VaultCrypto.zero(incoming)1333            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1334        }1335        // Refuse before committing rather than leave this device holding a vault1336        // whose recovery words cannot be typed back. generateRecoverableRootKey1337        // screens its own keys for this; a key arriving from a peer has not been.1338        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1339        if (!roundTrips) {1340            VaultCrypto.zero(incoming)1341            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1342        }13431344        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1345        return if (installRootKey(incoming, expectedEpoch)) {1346            AdoptVaultResult.Success(vaultId)1347        } else {1348            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1349        }1350    }13511352    /**1353     * The recovery words [rootKeyHex] will produce once joined, or null if it is1354     * not a usable root key. Lets the join flow show the user the words that are1355     * about to replace theirs *before* anything is committed.1356     */1357    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1358        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1359        val bytes = hexToRootKey(rootKeyHex)1360        return try {1361            val words = rootKeyToWords(bytes)1362            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1363        } catch (_: Exception) {1364            null1365        } finally {1366            VaultCrypto.zero(bytes)1367        }1368    }13691370    /**1371     * Swap the vault's root key, keeping every entry, and re-escrow under the1372     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1373     * [mutex].1374     */1375    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1376        // H-03: snapshot the live fields under stateLock so the success and the1377        // rollback paths both see consistent prior/new state. Writing the new1378        // rootKey/recovery fields is inside its own mutateState at the end.1379        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1380        // — holding live references would let a zero-on-failure operation also1381        // wipe the session's still-live key arrays.1382        data class Snapshot(1383            val key: ByteArray,1384            val priorRootKey: ByteArray,1385            val priorRecoveryHash: String?,1386            val priorRecoverySet: Boolean,1387        )1388        val snap = mutateIfCurrentEpoch(expectedEpoch) {1389            val k = derivedKey ?: return@mutateIfCurrentEpoch null1390            if (decoyMode) return@mutateIfCurrentEpoch null1391            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1392            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1393        } ?: run {1394            VaultCrypto.zero(nextRootKey)1395            return false1396        }1397        val priorSlot = store.loadSlotA() ?: run {1398            VaultCrypto.zero(snap.key)1399            VaultCrypto.zero(snap.priorRootKey)1400            VaultCrypto.zero(nextRootKey)1401            return false1402        }1403        val nextWords = rootKeyToWords(nextRootKey)14041405        return try {1406            beforeDurableCommit()1407            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1408            if (mutateIfCurrentEpoch(expectedEpoch) {1409                rootKey = nextRootKey1410                recoveryWordHash = recoveryWordsHash(nextWords)1411                recoveryWordsSet = false1412            } == null) throw IllegalStateException("stale_root_rotation")14131414            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1415                ?: throw IllegalStateException("recovery_escrow_failed")1416            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1417                ?: throw IllegalStateException("root_escrow_failed")1418            val plaintext = VaultCodec.encode(currentPayload())1419            val blob = try {1420                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1421            } finally {1422                VaultCrypto.zero(plaintext)1423            }1424            val nextSlot = priorSlot.copy(1425                blob = blob,1426                recoveryEscrow = nextRecoveryEscrow,1427                rootKeyEscrow = nextRootKeyEscrow,1428            )1429            val committed = withEpochCommit(expectedEpoch) {1430                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1431                try {1432                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1433                    // H-04: a root-key rotation re-seals slot A, so the duress slot1434                    // (real or fake) must be re-padded to the new ciphertext length.1435                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1436                    store.stampLocalMutation()1437                } catch (_: Exception) {1438                    store.writeSlotA(priorSlot)1439                    return@withEpochCommit false1440                }1441                true1442            } ?: false1443            if (!committed) throw IllegalStateException("storage_write_failed")1444            mutateIfCurrentEpoch(expectedEpoch) { publish() }1445            true1446        } catch (_: Exception) {1447            val restored = mutateIfCurrentEpoch(expectedEpoch) {1448                rootKey?.let(VaultCrypto::zero)1449                rootKey = snap.priorRootKey.copyOf()1450                recoveryWordHash = snap.priorRecoveryHash1451                recoveryWordsSet = snap.priorRecoverySet1452            }1453            if (restored != null) {1454                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1455            }1456            false1457        } finally {1458            VaultCrypto.zero(snap.key)1459            VaultCrypto.zero(snap.priorRootKey)1460            // On success nextRootKey is owned by rootKey. On every other path1461            // it is not, including a stale epoch after lock(); scrub it while1462            // the local reference is still available.1463            val stillOwnedBySession = mutateState {1464                sessionEpoch == expectedEpoch && rootKey === nextRootKey1465            }1466            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1467        }1468    }14691470    /**1471     * A copy of the live Argon2id-derived vault key, for biometric escrow only1472     * (wrapped under a hardware KeyStore key on biometric enable). Null when1473     * locked. Caller must [VaultCrypto.zero] it after use.1474     */1475    fun currentVaultKey(): ByteArray? = mutateState {1476        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1477    }14781479    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1480        val expectedEpoch = currentEpoch()1481        val slotA = store.loadSlotA() ?: return false1482        var oldKey: ByteArray? = null1483        var newKey: ByteArray? = null1484        var plaintext: ByteArray? = null1485        return try {1486            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1487            // Keep ownership of the decrypted vault plaintext until decoding has1488            // finished.  Passing decrypt() inline made the byte array unreachable1489            // before the finally block, so a successful or failed password change1490            // could leave the full plaintext resident in the heap.1491            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1492            val payload = try {1493                VaultCodec.decode(decrypted)1494            } finally {1495                VaultCrypto.zero(decrypted)1496            }1497            val newSalt = VaultCrypto.generateSalt()1498            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1499            // Re-seal the existing payload under the new key/salt.1500            plaintext = VaultCodec.encode(payload)1501            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1502            // The escrow seals the slot-A PLAINTEXT, which a password change does not1503            // alter — only the wrapping changes — so carry it forward; rebuilding the1504            // StoredVault from scratch would silently drop it.1505            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1506            // content that a password change does not alter — only the slot-A1507            // wrapping rotates. The root-key escrow staying stable across password1508            // changes matches the browser (`vault.ts:363-364`).1509            val next = StoredVault(1510                version = slotA.version,1511                salt = b64(newSalt),1512                blob = blob,1513                recoveryEscrow = slotA.recoveryEscrow,1514                rootKeyEscrow = slotA.rootKeyEscrow,1515            )1516            // H-01: durably commit slot A + size-matched noise + version stamp1517            // before swapping the session key. A partial commit (new A on disk,1518            // old key in memory) would have the next save encrypt under the old1519            // key while matching disk on the new one.1520            val priorB = store.loadSlotB()1521            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1522            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1523            // inside the same stateLock critical section, so a concurrent lock()1524            // can't observe the in-between (zeroed but not yet reassigned).1525            if (mutateIfCurrentEpoch(expectedEpoch) {1526                oldKey?.let(VaultCrypto::zero); oldKey = null1527                derivedKey?.let(VaultCrypto::zero)1528                derivedKey = newKey; newKey = null1529            } == null) return false1530            true1531        } catch (_: Exception) {1532            false1533        } finally {1534            oldKey?.let(VaultCrypto::zero)1535            newKey?.let(VaultCrypto::zero)1536            plaintext?.let(VaultCrypto::zero)1537        }1538    }15391540    /**1541     * Irreversible wipe. Locks the session and clears every vault key. The Pro1542     * license is in a separate store and is preserved by construction.1543     */1544    suspend fun destroyVault() = mutex.withLock {1545        lock()1546        store.clearVault()1547        store.clearDecoySlot()1548    }15491550    // ---- Entry CRUD -------------------------------------------------------15511552    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1553        val epoch = requireUnlocked()1554        val stamp = VaultCrypto.monotonicEntryTimestamp()1555        val now = System.currentTimeMillis()1556        val entry = withDerivedLast4(draft.copy(1557            id = draft.id.ifBlank { UUID.randomUUID().toString() },1558            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1559            modifiedAt = stamp,1560            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1561        ))1562        // H-03: capture rollback AND write entryList under stateLock so a1563        // concurrent lock() cannot zero the session between the read and the write.1564        val rollback = mutateCurrentSession(epoch) {1565            val r = entryList.toMutableList()1566            entryList.add(entry)1567            r1568        } ?: throw IllegalStateException("vault_locked")1569        persistOrRollback(rollback, epoch)1570        entry1571    }15721573    /**1574     * Insert many entries with a single encrypt-and-write at the end.1575     *1576     * Importing a real vault through [addEntry] was quadratic: every entry took1577     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1578     * restore did 806 full-vault writes and hung the app long enough to ANR.1579     * Rolls back to the pre-import list if the one write fails, matching1580     * [addEntry]'s all-or-nothing behaviour.1581     */1582    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1583        val epoch = requireUnlocked()1584        if (drafts.isEmpty()) return 01585        val now = System.currentTimeMillis()1586        val ready = drafts.map { draft ->1587            val stamp = VaultCrypto.monotonicEntryTimestamp()1588            withDerivedLast4(1589                draft.copy(1590                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1591                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1592                    modifiedAt = stamp,1593                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1594                ),1595            )1596        }1597        // H-03: capture rollback AND append every entry under stateLock.1598        val rollback = mutateCurrentSession(epoch) {1599            val r = entryList.toMutableList()1600            entryList.addAll(ready)1601            r1602        } ?: throw IllegalStateException("vault_locked")1603        persistOrRollback(rollback, epoch)1604        return drafts.size1605    }16061607    /**1608     * M-01: replace the whole active set with [newEntries] in one write.1609     *1610     * Import's REPLACE mode used to soft-delete every current entry one durable1611     * write at a time (swallowing each failure) and then batch-add, so a failed1612     * add left the old entries stranded in Trash with nothing restored. Staging1613     * the entire next list in memory and committing it through1614     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1615     * pre-import list — active entries included — is restored.1616     *1617     * Existing tombstones are preserved so paired devices still see the1618     * deletions they already know about.1619     */1620    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1621        val epoch = requireUnlocked()1622        val now = System.currentTimeMillis()1623        val ready = newEntries.map { draft ->1624            val stamp = VaultCrypto.monotonicEntryTimestamp()1625            withDerivedLast4(1626                draft.copy(1627                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1628                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1629                    modifiedAt = stamp,1630                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1631                ),1632            )1633        }1634        // M-03: an incoming entry that shares an id with an existing live entry1635        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1636        // the old one and then appending the new one would leave the list with1637        // two entries of the same id — the tombstoned one would be returned by1638        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1639        // superseded entries from the tombstone set instead.1640        val incomingIds = ready.map { it.id }.toHashSet()1641        val rollback = mutateCurrentSession(epoch) {1642            val r = entryList.toMutableList()1643            val replaced = entryList.map { entry ->1644                if (entry.deletedAt != null) return@map entry1645                if (entry.id in incomingIds) return@map null1646                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1647                entry.copy(deletedAt = stamp, modifiedAt = stamp)1648            }.filterNotNull()1649            entryList = (replaced + ready).toMutableList()1650            r1651        } ?: throw IllegalStateException("vault_locked")1652        persistOrRollback(rollback, epoch)1653        return ready.size1654    }16551656    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1657        val epoch = requireUnlocked()1658        // H-03: read entryList, compute the patched entry, and write it under a1659        // single stateLock so a concurrent lock() cannot interleave between the1660        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1661        // list, or clobber the entry onto the wrong id after a relock).1662        val prepared = mutateCurrentSession(epoch) {1663            val idx = entryList.indexOfFirst { it.id == patch.id }1664            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1665            val current = entryList[idx]1666            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1667            // Only refresh passwordChangedAt when the password value actually changes.1668            val passwordChanged = patch.password != null && patch.password != current.password1669            // Capture the outgoing password (newest first) so the user can reveal/copy/1670            // restore it later. History is computed authoritatively from `current`, not1671            // the patch — updateEntry is a full-entry replace, so trusting patch's value1672            // would let a stale form clobber the real history. Record the age the old1673            // password carried, skip an empty prior, and skip an immediate exact dupe1674            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1675            val priorPassword = current.password1676            val shouldCapture = passwordChanged &&1677                !priorPassword.isNullOrEmpty() &&1678                current.passwordHistory?.firstOrNull()?.password != priorPassword1679            val nextHistory = if (shouldCapture) {1680                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1681                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1682            } else {1683                current.passwordHistory1684            }1685            val updated = withDerivedLast4(patch.copy(1686                createdAt = current.createdAt,1687                modifiedAt = stamp,1688                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1689                passwordHistory = nextHistory,1690            ))1691            val rollback = entryList.toMutableList()1692            entryList[idx] = updated1693            updated to rollback1694        } ?: throw IllegalStateException("vault_locked")1695        val (updated, rollback) = prepared1696        persistOrRollback(rollback, epoch)1697        updated1698    }16991700    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1701    suspend fun deleteEntry(id: String) = mutex.withLock {1702        val epoch = requireUnlocked()1703        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1704        // the list out from under the indexOfFirst.1705        val rollback = mutateCurrentSession(epoch) {1706            val idx = entryList.indexOfFirst { it.id == id }1707            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1708            val current = entryList[idx]1709            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1710            val prior = entryList.toMutableList()1711            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1712            prior1713        } ?: throw IllegalStateException("vault_locked")1714        persistOrRollback(rollback, epoch)1715    }17161717    suspend fun restoreEntry(id: String) = mutex.withLock {1718        val epoch = requireUnlocked()1719        val rollback = mutateCurrentSession(epoch) {1720            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1721            if (idx == -1) throw IllegalStateException("deleted entry not found")1722            val current = entryList[idx]1723            val prior = entryList.toMutableList()1724            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1725            prior1726        } ?: throw IllegalStateException("vault_locked")1727        persistOrRollback(rollback, epoch)1728    }17291730    /** Permanent delete — no tombstone, no recovery. */1731    suspend fun purgeEntry(id: String) = mutex.withLock {1732        val epoch = requireUnlocked()1733        val rollback = mutateCurrentSession(epoch) {1734            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1735            if (idx == -1) throw IllegalStateException("deleted entry not found")1736            val prior = entryList.toMutableList()1737            entryList.removeAt(idx)1738            prior1739        } ?: throw IllegalStateException("vault_locked")1740        persistOrRollback(rollback, epoch)1741    }17421743    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1744    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1745        val epoch = requireUnlocked()1746        // H-03: read+remove under stateLock so lock() can't reorder the remove.1747        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1748        val r = mutateCurrentSession(epoch) {1749            val rollback = entryList.toMutableList()1750            val before = entryList.size1751            entryList.removeAll { it.deletedAt != null }1752            R(rollback, before - entryList.size)1753        } ?: throw IllegalStateException("vault_locked")1754        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1755        r.removed1756    }17571758    /** Browser parity: number of active entries that dedupe would move to trash. */1759    suspend fun countDuplicateEntries(): Int = mutex.withLock {1760        val epoch = requireUnlocked()1761        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1762        // lock() can't race the read with state writes.1763        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1764            ?: throw IllegalStateException("vault_locked")1765    }17661767    /**1768     * Browser parity: soft-delete duplicate entries, keeping the most recently1769     * modified member of each group. Refuse in decoy mode so the duress vault is1770     * not reshaped into a forensically distinct state.1771     */1772    suspend fun deduplicateEntries(): Int = mutex.withLock {1773        val epoch = requireUnlocked()1774        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1775        val ready = mutateCurrentSession(epoch) {1776            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1777            val active = entryList.filter { it.deletedAt == null }1778            val keepIds = mutableSetOf<String>()1779            active.groupBy { it.duplicateKey() }.values.forEach { group ->1780                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1781            }1782            val rollback = entryList.toMutableList()1783            var removed = 01784            entryList = entryList.map { entry ->1785                if (entry.deletedAt == null && entry.id !in keepIds) {1786                    removed++1787                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1788                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1789                } else {1790                    entry1791                }1792            }.toMutableList()1793            R(rollback, removed)1794        } ?: throw IllegalStateException("vault_locked")1795        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1796        ready.removed1797    }17981799    fun getEntries(): List<VaultEntry> = mutateState {1800        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1801    }1802    fun getEntry(id: String): VaultEntry? = mutateState {1803        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1804    }1805    fun getDeletedEntries(): List<VaultEntry> = mutateState {1806        if (!sessionDataAvailable) emptyList()1807        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1808    }18091810    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1811    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1812        return mutateState {1813            if (!sessionDataAvailable) return@mutateState emptyList()1814            val active = entryList.filter { it.deletedAt == null }1815            if (tagFilter == null) return@mutateState active1816            val needle = tagFilter.trim().lowercase()1817            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1818        }1819    }18201821    /** Sorted, de-duplicated tags across all active entries. */1822    fun getDistinctTags(): List<String> = mutateState {1823        if (!sessionDataAvailable) return@mutateState emptyList()1824        entryList.asSequence()1825            .filter { it.deletedAt == null }1826            .flatMap { it.tags?.asSequence() ?: emptySequence() }1827            .map { it.trim().lowercase() }1828            .filter { it.isNotEmpty() }1829            .distinct()1830            .sorted()1831            .toList()1832    }18331834    /**1835     * Distinct non-blank values for an identity field across active entries,1836     * most-frequently-used first. Supports "username" and "email"; unknown field1837     * names return an empty list. Values keep original casing (deduped1838     * case-insensitively) so a suggestion matches what the user typed.1839     */1840    fun getDistinctFieldValues(field: String): List<String> {1841        val select: (VaultEntry) -> String? = when (field) {1842            "username" -> { e -> e.username }1843            "email" -> { e -> e.email }1844            else -> return emptyList()1845        }1846        return mutateState {1847            if (!sessionDataAvailable) return@mutateState emptyList()1848            entryList.asSequence()1849                .filter { it.deletedAt == null }1850                .mapNotNull { select(it)?.trim() }1851                .filter { it.isNotEmpty() }1852                .groupingBy { it.lowercase() }1853                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1854                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1855                }1856                .values1857                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1858                .map { it.first }1859        }1860    }18611862    fun getTombstones(): List<Tombstone> = mutateState {1863        if (!sessionDataAvailable) emptyList()1864        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1865    }1866    fun getVaultVersion(): Long = store.version()1867    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18681869    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1870        val expectedEpoch = currentEpoch()1871        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1872        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1873        // H-04: snapshots now carry the duress slot too — restore preserves1874        // the at-rest triad (A/B/D), not just the A/B pair.1875        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1876        val priorA = store.loadSlotA()1877        val priorB = store.loadSlotB()1878        val priorD = store.loadDecoySlot()1879        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1880        // slot write — the old code took this snapshot, then stranded it on1881        // every failure, polluting history with a "restore" entry that never1882        // restored anything. The history below is rolled back together with1883        // the slots if the commit fails.1884        val priorHistory = store.loadVaultHistory()1885        // P1-4 (codex): the recovery snapshot must land before we commit the1886        // slot pair, otherwise a failed restore cannot be rolled back to the1887        // pre-restore history. If the history write itself fails, refuse to1888        // proceed — the alternative is a successful restore with no undo path.1889        beforeDurableCommit()1890        if (!takeVaultSnapshot("restore", expectedEpoch)) {1891            return false1892        }1893        // H-04: legacy snapshots without vault_d, or vault_d that fails to1894        // decode, fall back to a fake duress of slot A's size — presence-1895        // indistinguishable from any other freshly-restored vault.1896        val nextD: StoredVault = restoredD ?: run {1897            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161898            val fakeBlob = ByteArray(target).also(rng::nextBytes)1899            val fakeSalt = VaultCrypto.generateSalt()1900            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1901        }1902        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1903            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1904            return false1905        }1906        lock()1907        true1908    }19091910    /**1911     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1912     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1913     * (a restore preserves the duress-shaped blob from history), not a fresh1914     * noise record. Any failure rolls back so the at-rest pair never holds a1915     * restored slot A against a pre-restore slot B. Caller is responsible for1916     * restoring the pre-snapshot history on a false return.1917     *1918     * H-04: duress slot is now part of the atomic triad — restored last so1919     * a duress-write failure rolls the A/B pair back too.1920     */1921    private fun commitRestoreAtomic(1922        nextA: StoredVault,1923        nextB: String,1924        nextD: StoredVault,1925        priorA: StoredVault?,1926        priorB: String?,1927        priorD: StoredVault?,1928        expectedEpoch: Long,1929    ): Boolean {1930        return withEpochCommit(expectedEpoch) {1931            if (!store.writeSlotA(nextA)) return@withEpochCommit false1932            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1933            // must trigger rollback; previously a throw escaped without restoring1934            // the prior slot A and history.1935            try {1936                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1937                // H-04: duress slot is part of the restore triad. Failures here1938                // roll A+B back so the at-rest state stays consistent.1939                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1940                store.stampLocalMutation()1941            } catch (_: Exception) {1942                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1943                return@withEpochCommit false1944            }1945            true1946        } ?: false1947    }19481949    // ---- Sync apply (does NOT advance the version) ------------------------19501951    /**1952     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1953     * WITHOUT bumping the version counter, so a sync round cannot cascade into1954     * another outbound sync. Returns the number of locally-applied changes.1955     */1956    suspend fun applyMergedRemoteEntries(1957        remoteEntries: List<VaultEntry>,1958        remoteTombstones: List<Tombstone>,1959        expectedEpoch: Long? = null,1960    ): Int = mutex.withLock {1961        val epoch = expectedEpoch ?: requireUnlocked()1962        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01963        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1964        // BEFORE any comparison against a local one. Without this a peer can1965        // send a far-future modifiedAt and permanently pin its version of an1966        // entry against every honest local edit. The browser clamps in the1967        // matching place (vault.ts applyMergedRemoteEntries).1968        val mergeNow = System.currentTimeMillis()1969        val clamped = remoteEntries.map { entry ->1970            entry.copy(1971                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1972                deletedAt = entry.deletedAt?.let {1973                    SyncLimits.clampSyncTimestamp(it, mergeNow)1974                },1975            )1976        }1977        val tombMap = remoteTombstones.associate {1978            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1979        }1980        // H-03: build the new entryList under stateLock so a concurrent lock()1981        // cannot zero derivedKey/entryList between the byId read and the merge.1982        val applied = mutateCurrentSession(epoch) {1983            var n = 01984            val byId = entryList.associateBy { it.id }.toMutableMap()19851986            for (remote in clamped) {1987                val tomb = tombMap[remote.id]1988                if (tomb != null && tomb >= remote.modifiedAt) continue1989                val local = byId[remote.id]1990                if (local == null) {1991                    entryList.add(remote); byId[remote.id] = remote; n++1992                } else if (remote.modifiedAt > local.modifiedAt) {1993                    val i = entryList.indexOfFirst { it.id == local.id }1994                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1995                }1996            }19971998            for ((id, deletedAt) in tombMap) {1999                val local = byId[id] ?: continue2000                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2001                if (deletedAt < local.modifiedAt) continue2002                val i = entryList.indexOfFirst { it.id == id }2003                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2004            }2005            n2006        } ?: return@withLock 020072008        if (applied > 0) {2009            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2010            mutateIfCurrentEpoch(epoch) { publish() }2011        }2012        applied2013    }20142015    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2016        // Require a fully live session before exposing a settings mutation.  The2017        // epoch is captured with the read so a lock/new-session boundary cannot2018        // let stale work publish into the replacement session.2019        val epoch = requireUnlocked()2020        val prior = mutateCurrentSession(epoch) {2021            val old = _settings.value2022            _settings.value = settings2023            old2024        } ?: throw IllegalStateException("vault_locked")20252026        val committed = try {2027            beforeDurableCommit()2028            // The durable write is inside the same epoch fence as the in-memory2029            // publication.  If lock() won first, this returns null and no stale2030            // settings reach storage or the public StateFlow.2031            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2032        } catch (_: Exception) {2033            false2034        }2035        if (!committed) {2036            // A live-session storage failure rolls back the public value.  A2037            // stale epoch is already reset by lock()/the new session and must2038            // not be allowed to overwrite that safe state.2039            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2040            throw IllegalStateException("settings_write_failed")2041        }2042    }20432044    // ---- Recovery words (vault metadata) ----------------------------------20452046    /** True once the user has confirmed their recovery words for this vault. */2047    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20482049    /**2050     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2051     * Persisted without bumping the version — recovery metadata is local-only and2052     * is not an entry mutation paired devices should sync against.2053     */2054    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2055        val epoch = requireUnlocked()2056        // H-03: hash + escrow-related state fields set under stateLock so a2057        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2058        // M-04: the flags have to be live before the escrows are built — both seal2059        // currentPayload() — so capture the prior pair and put it back if the write2060        // never lands. Otherwise the session reports words set against a slot A2061        // that still holds no escrow, and the recovery screen never offers a retry.2062        val prior = mutateCurrentSession(epoch) {2063            val priorPair = recoveryWordHash to recoveryWordsSet2064            recoveryWordHash = recoveryWordsHash(words)2065            recoveryWordsSet = true2066            priorPair2067        } ?: return@withLock false2068        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2069        // (cross-platform recovery of the sync root key) and Android's whole-payload2070        // escrow (offline forgot-password restore of entries). Both derive their key2071        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2072        val persisted = runCatching {2073            persistSlotAWithEscrows(2074                buildRecoveryEscrow(words),2075                buildRootKeyEscrow(words),2076                expectedEpoch = epoch,2077            )2078        }.getOrDefault(false)2079        if (!persisted) {2080            mutateIfCurrentEpoch(epoch) {2081                recoveryWordHash = prior.first2082                recoveryWordsSet = prior.second2083            }2084        }2085        persisted2086    }20872088    /** Verify entered words against the stored recovery hash. */2089    fun verifyRecoveryWords(words: List<String>): Boolean {2090        return mutateState {2091            val stored = recoveryWordHash ?: return@mutateState false2092            stored == recoveryWordsHash(words)2093        }2094    }20952096    // ---- Recovery escrow (forgot-password) --------------------------------20972098    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2099    fun hasRecoveryEscrow(): Boolean =2100        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21012102    /**2103     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2104     * the master password. On success the slot-A plaintext sealed in the escrow is2105     * adopted as the live session (entries, rootKey, settings), but no Argon2id2106     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2107     * new password and finish unlocking. Returns false when there is no escrow, the2108     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2109     *2110     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2111     * detached vault key to return here — slot A is wrapped under the rotating2112     * Argon2id key — so this opens the escrowed payload directly.2113     */2114    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2115        val expectedEpoch = currentEpoch()2116        val slotA = store.loadSlotA() ?: return false2117        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21182119        // Prefer Android's whole-payload escrow: a full offline restore of entries,2120        // root key, and settings. Present for Android-created vaults.2121        val fullEscrow = slotA.recoveryEscrow2122        if (fullEscrow != null) {2123            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2124            if (material != null) {2125                var keyBytes: ByteArray? = null2126                var plaintext: ByteArray? = null2127                var candidateRoot: ByteArray? = null2128                var stateCommitStarted = false2129                try {2130                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2131                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2132                    val payload = VaultCodec.decode(plaintext)2133                    // Stage every fallible operation before touching live session2134                    // fields.  In particular, a settings/provider/decode failure2135                    // cannot leave entries, flags, or a root key resident while the2136                    // public session remains locked.2137                    val candidateSettings = store.loadSettings()2138                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2139                        val decodedRoot = unb64(payload.rootKeyB64)2140                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2141                            // Do not let a malformed authenticated payload abandon2142                            // this decoded root-key candidate before the outer2143                            // finally block owns it.2144                            VaultCrypto.zero(decodedRoot)2145                            throw IllegalArgumentException("invalid recovery root key")2146                        }2147                        decodedRoot2148                    } else {2149                        generateRecoverableRootKey()2150                    }2151                    stateCommitStarted = true2152                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2153                        clearSessionStateLocked()2154                        entryList = payload.entries.toMutableList()2155                        duressConfigured = payload.duressConfigured2156                        recoveryWordHash = payload.recoveryWordHash2157                        recoveryWordsSet = payload.recoveryWordsSet2158                        rootKey = candidateRoot2159                        candidateRoot = null2160                        _settings.value = candidateSettings2161                        publish()2162                        // This is the authenticated recovery half-unlock boundary:2163                        // only set it after every candidate field is installed.2164                        sessionDataAvailable = true2165                        _isUnlocked.value = false2166                        true2167                    }2168                    if (adopted == true) return@withLock true2169                } catch (_: Exception) {2170                    // Wrong words, malformed payloads, provider failures, and2171                    // settings failures all fall through to the browser-compatible2172                    // root-key escrow.  If the state commit had started, invalidate2173                    // that epoch so no partial candidate survives.2174                    if (stateCommitStarted) {2175                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2176                    }2177                } finally {2178                    plaintext?.let(VaultCrypto::zero)2179                    VaultCrypto.zero(material)2180                    keyBytes?.let(VaultCrypto::zero)2181                    candidateRoot?.let(VaultCrypto::zero)2182                }2183            }2184        }21852186        // Browser-format vault (only the root-key escrow): recover the 32-byte root2187        // key (sync identity) and reset the password; entries return via sync. This2188        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2189        val rootEscrow = slotA.rootKeyEscrow2190        if (rootEscrow != null) {2191            var candidateRoot: ByteArray? = null2192            var stateCommitStarted = false2193            try {2194                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2195                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2196                val candidateSettings = store.loadSettings()2197                stateCommitStarted = true2198                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2199                    clearSessionStateLocked()2200                    entryList = mutableListOf()2201                    duressConfigured = false2202                    recoveryWordHash = recoveryWordsHash(words)2203                    recoveryWordsSet = true2204                    rootKey = candidateRoot2205                    candidateRoot = null2206                    _settings.value = candidateSettings2207                    publish()2208                    sessionDataAvailable = true2209                    _isUnlocked.value = false2210                    true2211                }2212                if (adopted == true) return@withLock true2213            } catch (_: Exception) {2214                if (stateCommitStarted) {2215                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2216                }2217            } finally {2218                candidateRoot?.let(VaultCrypto::zero)2219            }2220        }2221        return false2222    }22232224    /**2225     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2226     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2227     * words, mark the session fully unlocked, and publish. Returns false if called2228     * without a prior successful recovery unlock (no in-memory root key).2229     *2230     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2231     * into one step, since there is no detached vault key here.2232     */2233    suspend fun resetPasswordWithRecoveryWords(2234        words: List<String>,2235        newPassword: String,2236    ): Boolean = mutex.withLock {2237        val expectedEpoch = currentEpoch()2238        // H-03: read rootKey + entryList under stateLock before computing the new2239        // plaintext, then re-publish + swap the derived key under stateLock so a2240        // concurrent lock() cannot straddle the two fences.2241        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2242        val priorA = store.loadSlotA() ?: return false2243        var newKey: ByteArray? = null2244        var plaintext: ByteArray? = null2245        return try {2246            val newSalt = VaultCrypto.generateSalt()2247            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2248            plaintext = VaultCodec.encode(currentPayload())2249            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2250            // Rebuild both escrows against the recovered session so their salts rotate2251            // with the password reset; fall back to the prior escrows if rebuild fails2252            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2253            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2254            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2255            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2256            val next = StoredVault(2257                version = priorA.version,2258                salt = b64(newSalt),2259                blob = blob,2260                recoveryEscrow = freshRecovery,2261                rootKeyEscrow = freshRootKey,2262            )2263            // H-01: see changePassword — both rotations share the same2264            // commit-or-keep-old-key contract so a partial write can't strand2265            // the session on a key that no longer matches disk.2266            val priorB = store.loadSlotB()2267            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2268            // H-03 (P1-2 codex): zero the old live key and install the new one2269            // inside the same stateLock critical section so a concurrent lock()2270            // can't observe the in-between.2271            if (mutateIfCurrentEpoch(expectedEpoch) {2272                derivedKey?.let(VaultCrypto::zero)2273                derivedKey = newKey; newKey = null2274                publish()2275                _isUnlocked.value = true2276            } == null) return false2277            true2278        } catch (_: Exception) {2279            false2280        } finally {2281            newKey?.let(VaultCrypto::zero)2282            plaintext?.let(VaultCrypto::zero)2283        }2284    }22852286    /**2287     * Seal the current slot-A plaintext under a key derived from the 12 BIP392288     * recovery [words] (Android offline-restore escrow). IKM is the browser's2289     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2290     * Returns null for a checksum-invalid phrase.2291     */2292    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2293        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2294        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2295        var keyBytes: ByteArray? = null2296        var plaintext: ByteArray? = null2297        return try {2298            val salt = VaultCrypto.generateSalt()2299            val recoveryKey = deriveRecoveryKey(material, salt)2300            keyBytes = recoveryKey.encoded2301            plaintext = VaultCodec.encode(currentPayload())2302            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2303            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2304        } finally {2305            VaultCrypto.zero(material)2306            keyBytes?.let(VaultCrypto::zero)2307            plaintext?.let(VaultCrypto::zero)2308        }2309    }23102311    /**2312     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2313     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2314     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2315     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2316     * the same root key on the browser. Returns null with no in-memory root key or2317     * a checksum-invalid phrase.2318     */2319    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2320        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2321        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2322        val rk = mutateState { rootKey?.copyOf() } ?: run {2323            VaultCrypto.zero(material)2324            return null2325        }2326        var keyBytes: ByteArray? = null2327        var plaintext: ByteArray? = null2328        return try {2329            val salt = VaultCrypto.generateSalt()2330            val recoveryKey = deriveRecoveryKey(material, salt)2331            keyBytes = recoveryKey.encoded2332            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2333            // combined = nonce(12) || ciphertext || tag(16)2334            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2335            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2336            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2337            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2338            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2339            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2340        } finally {2341            VaultCrypto.zero(rk)2342            VaultCrypto.zero(material)2343            keyBytes?.let(VaultCrypto::zero)2344            plaintext?.let(VaultCrypto::zero)2345        }2346    }23472348    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2349    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2350        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2351        var keyBytes: ByteArray? = null2352        var pt: ByteArray? = null2353        return try {2354            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2355            keyBytes = recoveryKey.encoded2356            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2357            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2358            hexToRootKey(String(pt, Charsets.US_ASCII))2359        } catch (_: Exception) {2360            null2361        } finally {2362            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2363            // IKM-equivalent of the vault identity. material+keyBytes were2364            // already zeroed; pt was leaking.2365            VaultCrypto.zero(material)2366            keyBytes?.let(VaultCrypto::zero)2367            pt?.let(VaultCrypto::zero)2368        }2369    }23702371    /**2372     * Re-seal slot A from the current in-memory state under the live key, carrying2373     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2374     * in decoy mode (recovery is never offered for the decoy) and persists without2375     * a version bump, like the recovery-words metadata write it replaces.2376     */2377    private fun persistSlotAWithEscrows(2378        recoveryEscrow: RecoveryEscrow?,2379        rootKeyEscrow: RootKeyEscrow?,2380        expectedEpoch: Long? = null,2381    ): Boolean {2382        val snapshot = snapshotForPersist(expectedEpoch)2383            ?: throw IllegalStateException("vault_locked_or_stale")2384        if (snapshot.decoy) {2385            VaultCrypto.zero(snapshot.key)2386            return false2387        }2388        try {2389            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2390            val plaintext = VaultCodec.encode(snapshot.payload)2391            val blob = try {2392                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2393            } finally {2394                // The escrow path used to drop this reference before zeroing it;2395                // keep it live through the fill on both success and failure.2396                VaultCrypto.zero(plaintext)2397            }2398            val next = prior.copy(2399                blob = blob,2400                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2401                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2402            )2403            beforeDurableCommit()2404            return withEpochCommit(snapshot.epoch) {2405                if (!store.writeSlotA(next)) return@withEpochCommit false2406                try {2407                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2408                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2409                    // the duress slot too. A real decoy is left alone; a fake is2410                    // re-sized to slot A's new ciphertext length.2411                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2412                } catch (e: Exception) {2413                    store.writeSlotA(prior)2414                    throw e2415                }2416                true2417            } ?: false2418        } finally {2419            VaultCrypto.zero(snapshot.key)2420        }2421    }24222423    // ---- Internals --------------------------------------------------------24242425    private fun requireUnlocked(): Long {2426        // H-03: read derivedKey atomically with [lock]'s reset; without it,2427        // [lock] from a background lifecycle event can run between this read2428        // and the caller's first state touch.2429        stateLock.lock()2430        try {2431            if (derivedKey == null) throw IllegalStateException("vault_locked")2432            return sessionEpoch2433        } finally {2434            stateLock.unlock()2435        }2436    }24372438    /** A locked or half-adopted session must not expose secret-bearing fields. */2439    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24402441    private fun countDuplicateEntriesLocked(): Int {2442        val seen = mutableMapOf<String, VaultEntry>()2443        var duplicates = 02444        for (entry in entryList.filter { it.deletedAt == null }) {2445            val key = entry.duplicateKey()2446            val existing = seen[key]2447            if (existing == null) {2448                seen[key] = entry2449            } else {2450                duplicates++2451                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2452                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2453                if (entryStamp > existingStamp) seen[key] = entry2454            }2455        }2456        return duplicates2457    }24582459    private fun VaultEntry.duplicateKey(): String {2460        if (type == "note") return "note|${title.lowercase()}"2461        val passkeyData = passkey2462        if (passkeyData != null) {2463            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2464        }2465        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2466    }24672468    /**2469     * Capture the current slot pair (and metadata) into the history list.2470     * Returns true when the history write landed, false when it didn't — callers2471     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2472     * must check the result and roll back if it does not.2473     */2474    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2475        val epoch = expectedEpoch ?: currentEpoch()2476        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2477        val slotA = store.loadSlotA() ?: return false2478        val vaultA = VaultCodec.encodeSlot(slotA)2479        val vaultB = store.loadSlotB() ?: slotA.blob2480        // H-04: capture the duress slot so a restore preserves the at-rest2481        // triad, not just the A/B pair. The duress slot is always present2482        // (real or fake), so loadDecoySlot() returning null is a logic error2483        // — fall through to a fake duress in that case rather than silently2484        // losing the record from the snapshot.2485        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2486            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162487            val fakeBlob = ByteArray(target).also(rng::nextBytes)2488            val fakeSalt = VaultCrypto.generateSalt()2489            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2490        }2491        val history = store.loadVaultHistory()2492        val latest = history.snapshots.firstOrNull()2493        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2494        val entryCount = mutateIfCurrentEpoch(epoch) {2495            if (derivedKey != null && !decoyMode) {2496                bucketEntryCount(entryList.count { it.deletedAt == null })2497            } else {2498                null2499            }2500        } ?: return false2501        return try {2502            val snapshot = VaultSnapshot(2503                id = UUID.randomUUID().toString(),2504                savedAt = System.currentTimeMillis(),2505                reason = reason,2506                vault_a = vaultA,2507                vault_b = vaultB,2508                vault_d = vaultD,2509                entryCount = entryCount,2510            )2511            // P1-4 (codex): return the history-write boolean to callers so a2512            // false return no longer leaves the snapshot out of history while2513            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2514            withEpochCommit(epoch) {2515                store.writeVaultHistory(2516                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2517                )2518            } ?: false2519        } catch (_: Exception) {2520            false2521        }2522    }25232524    private fun bucketEntryCount(n: Int): Int = when {2525        n <= 0 -> 02526        n <= 10 -> 102527        n <= 25 -> 252528        n <= 50 -> 502529        n <= 100 -> 1002530        n <= 250 -> 2502531        n <= 500 -> 5002532        else -> ((n + 499) / 500) * 5002533    }25342535    /**2536     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2537     * so it is stored explicitly and survives the sync wire format rather than being2538     * re-derived at render time. Non-card entries pass through unchanged.2539     *2540     * M-03: never overwrite a stored last4 with null. A card kept only as last42541     * (e.g. after a PeachScript import that had no PAN) must keep that value;2542     * re-deriving from an absent cardNumber would silently zero the field.2543     */2544    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2545        if (!entry.isCard()) return entry2546        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2547        val derived = if (digits.length >= 4) digits.takeLast(4) else null2548        return entry.copy(last4 = derived ?: entry.last4)2549    }25502551    /**2552     * On unlock, hard-delete trashed entries older than the retention window.2553     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2554     * (the app's "Never" option), so nothing is purged. Persists without a2555     * version bump and without touching the publish state (caller publishes).2556     */2557    private fun autoPurgeExpiredTrash() {2558        val days = _settings.value.trashRetentionDays ?: return2559        if (days <= 0) return2560        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2561        val before = entryList.size2562        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2563        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2564    }25652566    /** The slot-A plaintext envelope for the current in-memory session. */2567    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25682569    private fun currentPayloadLocked(): VaultPayload =2570        VaultPayload(2571            entries = entryList.toList(),2572            duressConfigured = duressConfigured,2573            rootKeyB64 = rootKey?.let(::b64) ?: "",2574            recoveryWordHash = recoveryWordHash,2575            recoveryWordsSet = recoveryWordsSet,2576        )25772578    /**2579     * Adopt the payload's root key, lazily minting one for legacy payloads.2580     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2581     * a root key is not a mutation paired devices should sync against.2582     */2583    private fun ensureRootKey(payload: VaultPayload) {2584        if (payload.rootKeyB64.isNotBlank()) {2585            rootKey = unb64(payload.rootKeyB64)2586            return2587        }2588        rootKey = generateRecoverableRootKey()2589        runCatching { persistSlotA(bumpVersion = false) }2590    }25912592    private fun publish() {2593        _entries.value = entryList.filter { it.deletedAt == null }2594    }25952596    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2597        var plaintext = VaultCodec.encode(currentPayload())2598        return try {2599            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2600            StoredVault(version = 1, salt = b64(salt), blob = blob)2601        } finally {2602            // M-05: full vault plaintext is allocated here once per createVault.2603            // Zeroing in finally keeps the transient copy off the heap after the2604            // caller either succeeds or rolls back via lock().2605            VaultCrypto.zero(plaintext)2606        }2607    }26082609    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2610        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2611        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2612        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2613        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2614        val noise = ByteArray(approxBytes).also(rng::nextBytes)2615        return store.writeSlotB(b64(noise))2616    }26172618    /** H-04: the ciphertext length slot A is using right now, so the duress2619     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2620     *  length equals ciphertext length, so this also matches slot A's plaintext. */2621    private fun matchingCiphertextLength(slotA: StoredVault): Int =2622        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26232624    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2625     *  bytes. Replaces a real decoy when the user clears duress, and is the2626     *  initial duress slot when no decoy is configured — so the record's2627     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2628     *  matches slot A's blob length. */2629    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2630        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162631        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2632        val fakeSalt = VaultCrypto.generateSalt()2633        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2634    }26352636    /** H-04: keep the duress record presence- and size-indistinguishable from2637     *  slot A on every persist. A real decoy is left alone (it was padded at2638     *  setDuressPassphrase time, and we don't have the duress key here to2639     *  re-pad on entry growth — that's an accepted residual tell, see the2640     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2641     *  A's size. */2642    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2643        val isConfigured = mutateState { duressConfigured }2644        return if (isConfigured) true else writeFakeDuressSlot(slotA)2645    }26462647    /**2648     * H-04: convert a browser-shaped duress slot to Android StoredVault2649     * without decrypting. The browser seals slot "b" under the duress2650     * passphrase; decrypting with the main password silently drops it.2651     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2652     * the salt from hex to base64.2653     */2654    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2655        val salt = unhex(b.salt)2656        val nonce = unhex(b.nonce)2657        val ct = unhex(b.ciphertext)2658        val tag = unhex(b.tag)2659        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2660            System.arraycopy(nonce, 0, it, 0, nonce.size)2661            System.arraycopy(ct, 0, it, nonce.size, ct.size)2662            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2663        }2664        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2665    }26662667    /** Re-seal slot A from the current in-memory state under the live key. */2668    private fun persistSlotA(2669        bumpVersion: Boolean,2670        snapshotReason: String? = "local_save",2671        expectedEpoch: Long? = null,2672    ): Boolean {2673        val snapshot = snapshotForPersist(expectedEpoch)2674            ?: throw IllegalStateException("vault_locked_or_stale")2675        return try {2676            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2677            if (snapshot.decoy) {2678                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2679                val plaintext = VaultCodec.encode(snapshot.payload)2680                val blob = try {2681                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2682                } finally {2683                    // Keep the last reference alive until the zero, including the2684                    // successful encrypt path (M-05).2685                    VaultCrypto.zero(plaintext)2686                }2687                beforeDurableCommit()2688                return withEpochCommit(snapshot.epoch) {2689                    store.writeDecoySlot(prior.copy(blob = blob))2690                } ?: false2691            }2692            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2693            // H-01: captured BEFORE the noise write so a stamp failure can restore2694            // the prior slot B alongside the prior slot A. Slot B is always2695            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2696            // leaves (new A, new B, old version) on disk — partial commit.2697            val priorB = store.loadSlotB()2698            beforeDurableCommit()2699            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2700            val plaintext = VaultCodec.encode(snapshot.payload)2701            val blob = try {2702                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2703            } finally {2704                // Do not drop [plaintext] before fill(0): the serialized vault2705                // contains every entry password and is the primary M-05 target.2706                VaultCrypto.zero(plaintext)2707            }2708            val next = prior.copy(blob = blob)2709            // The epoch check and the entire A/B/D/version commit share one brief2710            // stateLock critical section. lock() either wins before this block or2711            // waits until the already-committed write is complete; it can never2712            // clear the session between the check and the durable write.2713            withEpochCommit(snapshot.epoch) {2714                if (!store.writeSlotA(next)) return@withEpochCommit false2715                try {2716                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2717                    // H-04: duress slot stays present (real one untouched, fake one2718                    // re-padded to slot A's current size on every persist).2719                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2720                    if (bumpVersion) store.stampLocalMutation()2721                } catch (e: Exception) {2722                    store.writeSlotA(prior)2723                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2724                    throw e2725                }2726                true2727            } ?: false2728        } finally {2729            // Snapshot keys are copies, so lock() may zero the live key while this2730            // operation is still deriving/encrypting; always scrub our copy too.2731            VaultCrypto.zero(snapshot.key)2732        }2733    }27342735    /**2736     * H-01: durably commit a slot-A rotation by writing slot A, its2737     * size-matched noise slot B, and the local-mutation stamp in order. Any2738     * failure rolls the previously-committed steps back so the at-rest pair2739     * matches what was there before the call. Returns true only when every2740     * step succeeded — callers must NOT swap their session key on false.2741     */2742    private fun commitVaultRotation(2743        priorA: StoredVault,2744        priorB: String?,2745        nextA: StoredVault,2746        expectedEpoch: Long,2747    ): Boolean {2748        beforeDurableCommit()2749        return withEpochCommit(expectedEpoch) {2750            if (!store.writeSlotA(nextA)) return@withEpochCommit false2751            // P1-1 (codex): the noise write's boolean AND any thrown exception from2752            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2753            // escaped the helper and left slot A committed.2754            try {2755                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2756                // H-04: a password rotation changes slot A's plaintext size (new2757                // salt → re-encrypted blob), so the duress slot must be re-padded2758                // alongside. writeCompanionDuressSlot leaves a real decoy alone2759                // and re-emits a fake of slot A's size otherwise.2760                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2761                store.stampLocalMutation()2762            } catch (_: Exception) {2763                rollbackRotation(priorA, priorB)2764                return@withEpochCommit false2765            }2766            true2767        } ?: false2768    }27692770    /**2771     * Best-effort undo of the writes [commitVaultRotation] may have committed.2772     * Each step runs independently — a failed rollback must not mask the2773     * original commit failure. Log is a throwing stub off-device.2774     */2775    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2776        val failures = mutableListOf<String>()2777        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2778            .onFailure { failures += "writeSlotA: $it" }2779        if (priorB != null) {2780            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2781                .onFailure { failures += "writeSlotB: $it" }2782        }2783        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2784    }27852786    /** Persist a local mutation, rolling the in-memory list back on failure. */2787    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2788        try {2789            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2790                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2791                throw IllegalStateException("storage_write_failed")2792            }2793        } catch (e: Exception) {2794            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2795            throw e2796        }2797        // A lock can win in the small interval after the durable commit and2798        // before publication. The epoch check prevents pre-lock entries from2799        // being republished into a newly locked (or newly unlocked) session.2800        mutateIfCurrentEpoch(expectedEpoch) { publish() }2801    }2802}