VaultRepository.kt

136.8 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton2526/** Lenient parser for the Codex vault-blob wrapper only. */27private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)3132/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */33private const val PASSWORD_HISTORY_MAX = 5034private const val VAULT_HISTORY_MAX = 53536// How long the transient-background-lock suppression stays valid. If the37// launched system Activity never reports the expected lifecycle stop (picker38// crashed, user backed out into another app, OEM killed the picker process),39// the flag expires and the next genuine background event locks the vault40// rather than consuming a stale suppression.41private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4243sealed interface UnlockResult {44    data object Success : UnlockResult45    data object WrongPassword : UnlockResult46    data class Error(val reason: String) : UnlockResult47}4849sealed interface CreateVaultResult {50    data object Success : CreateVaultResult51    data class Error(val reason: String) : CreateVaultResult52}5354sealed interface AdoptVaultResult {55    data class Success(val vaultId: String) : AdoptVaultResult56    data class Error(val reason: String) : AdoptVaultResult57}5859/**60 * In-memory vault session backed by [VaultStore]. Faithful port of the browser61 * `vault.ts` semantics that matter for correctness:62 *63 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched64 *    noise blob so a fresh install shows two similarly-shaped records.65 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a66 *    deletion always wins last-write-wins against an in-flight edit.67 *  - Local writes bump the version counter; sync-applied writes do NOT (so a68 *    sync round never cascades into another outbound sync).69 *  - add/update/delete roll back in-memory state if the disk write fails.70 *  - destroyVault never touches the Pro license (separate store).71 */72@Singleton73class VaultRepository @Inject constructor(74    private val store: VaultStorage,75) {76    private val mutex = Mutex()77    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend78    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync79    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so80    // lock() can serialize against an in-flight suspend op without blocking on81    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and82    // take [stateLock] only around the state-write portions. Acquisition order83    // is always mutex → stateLock.84    private val stateLock = ReentrantLock()85    private val rng = SecureRandom()8687    /**88     * Monotonic session generation.  [lock] advances this before clearing any89     * secret state; work that captured the previous generation is therefore90     * stale even if it is still running its (non-suspending) storage step.91     */92    private var sessionEpoch: Long = 0L9394    private fun nextSessionEpoch(epoch: Long): Long =95        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9697    /** Test seam for deterministic "lock while waiting to commit" races. */98    @VisibleForTesting99    internal var beforeDurableCommit: () -> Unit = {}100101    /** Test seam after a failed Codex rollback, while the epoch fence is held. */102    @VisibleForTesting103    internal var afterCodexRollback: () -> Unit = {}104105    /**106     * Run [block] with [stateLock] held. Used to fence the small state-write107     * portions of suspend operations against [lock] and [requireUnlocked]108     * racing from another thread.109     */110    private inline fun <T> mutateState(block: () -> T): T {111        stateLock.lock()112        return try { block() } finally { stateLock.unlock() }113    }114115    /** Run a durable commit only while the session generation is still live. */116    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {117        stateLock.lock()118        return try {119            if (sessionEpoch != epoch) null else block()120        } finally {121            stateLock.unlock()122        }123    }124125    /** Test seam for a caller that must stage work before the epoch fence. */126    @VisibleForTesting127    internal var beforeSessionEpochCommit: () -> Unit = {}128129    /**130     * Test seam for the narrower H-03 race: a suspend mutation has authenticated131     * the session and staged its candidate state, but has not yet entered the132     * epoch-fenced in-memory write. Production callers leave this as a no-op.133     */134    @VisibleForTesting135    internal var beforeSessionStateMutation: () -> Unit = {}136137    /**138     * Run an external session-bound commit while the repository's epoch fence is139     * held. The callback is deliberately non-suspending: [lock] cannot advance140     * the epoch until the whole external batch has reached its linearization141     * point.142     */143    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {144        beforeSessionEpochCommit()145        return withEpochCommit(epoch, block)146    }147148    private fun currentEpoch(): Long = mutateState { sessionEpoch }149150    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =151        mutateState { if (sessionEpoch == epoch) block() else null }152153    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */154    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {155        beforeSessionStateMutation()156        return mutateIfCurrentEpoch(epoch, block)157    }158159    private data class PersistSnapshot(160        val epoch: Long,161        val key: ByteArray,162        val payload: VaultPayload,163        val decoy: Boolean,164    )165166    /** Copy all session material while fenced; callers own and must zero [key]. */167    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {168        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null169        val liveKey = derivedKey ?: return@mutateState null170        PersistSnapshot(171            epoch = sessionEpoch,172            key = liveKey.copyOf(),173            payload = currentPayloadLocked(),174            decoy = decoyMode,175        )176    }177178    private var derivedKey: ByteArray? = null179    private var rootKey: ByteArray? = null180    private var entryList: MutableList<VaultEntry> = mutableListOf()181    private var duressConfigured: Boolean = false182    private var recoveryWordHash: String? = null183    private var recoveryWordsSet: Boolean = false184    /** True when an authenticated vault payload is available for local reads. */185    private var sessionDataAvailable: Boolean = false186    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */187    private var decoyMode: Boolean = false188189    private val _isUnlocked = MutableStateFlow(false)190    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()191192193    /**194     * UI-only gate for sync-retained background mode. The vault stays unlocked195     * in memory (sync can proceed) but the UI shows the lock screen until the196     * user authenticates on resume. Cleared by a full [lock] or an explicit197     * [clearUiGate] from the lock screen.198     */199    private val _uiGateLocked = MutableStateFlow(false)200    val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()201202    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())203    /** Active (non-deleted) entryList. */204    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()205206    private val _settings = MutableStateFlow(VaultSettings())207    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()208209    /** Clear every in-memory session field while [stateLock] is held. */210    private fun clearSessionStateLocked() {211        derivedKey?.let(VaultCrypto::zero)212        rootKey?.let(VaultCrypto::zero)213        derivedKey = null214        rootKey = null215        entryList = mutableListOf()216        duressConfigured = false217        recoveryWordHash = null218        recoveryWordsSet = false219        sessionDataAvailable = false220        decoyMode = false221        _settings.value = VaultSettings()222        _entries.value = emptyList()223        _isUnlocked.value = false224    }225226    fun hasVault(): Boolean = store.hasVault()227228    // ---- Codex (printed paper backup) -------------------------------------229230    /**231     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the232     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's233     * A/B naming where slot B is the duress decoy); the noise slot is local-only234     * and never crosses devices. Restorable on any device that has the master235     * password. Null when no vault exists.236     */237    fun codexVaultBlobBase64(): String? {238        val slotA = store.loadSlotA() ?: return null239        // H-04: duress slot is always present after createVault (real or fake),240        // so the wrapper can rely on it being loadable.241        val slotD = store.loadDecoySlot() ?: return null242        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""243        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))244    }245246    /**247     * Replace the local vault from a Codex blob, then unlock with [password].248     * Writes slot A (and a size-matched noise slot B) before unlocking so the249     * restored vault is at rest exactly as a freshly-created one would be.250     */251    /** A slot is stored either as a JSON string (browser) or inline object (Android). */252    private fun JsonElement?.asSlotJson(): String? = when {253        this == null || this is JsonNull -> null254        this is JsonPrimitive && isString -> content255        else -> toString()256    }257258    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {259        val expectedEpoch = currentEpoch()260        var candidateKey: ByteArray? = null261        var plaintext: ByteArray? = null262        return try {263            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)264            // Parsed as JSON, not matched with a regex. The wrapper is265            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which266            // generates most real Codexes — writes each slot as a JSON267            // *string*, with "b" present. A regex looking for an object268            // literal under "a" at the very end of the wrapper matches269            // neither of those, so every browser-generated Codex failed here270            // with "invalid_codex_blob" after decoding perfectly.271            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject272                ?: return UnlockResult.Error("invalid_codex_blob")273            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")274            // A Codex printed from the browser carries a browser-shaped slot,275            // which is the common case — most Codexes are printed there. It is276            // translated to Android's shape (see BrowserSlot) rather than277            // rejected. A translation failure is a wrong password, not a bad278            // blob: the codes decoded fine, the password just didn't open them.279            val slotA = VaultCodec.decodeSlot(slotJson)280                ?: BrowserSlot.parse(slotJson)?.let {281                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword282                }283                ?: return UnlockResult.Error("invalid_codex_blob")284            // C-01: authenticate the candidate ENTIRELY in memory before touching285            // storage. VaultCodec.decodeSlot is structural — an Android-shaped286            // Codex parses under ANY password — so writing slot A first and287            // unlocking afterwards destroyed the device's existing vault every288            // time the user mistyped the password.289            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))290            plaintext = decryptSlotABlob(slotA, candidateKey)291            // LV-1: a Codex printed from a vault sealed before NFC normalization292            // opens only under the raw password bytes. unlockLocked used to supply293            // this fallback; authenticating up front here has to keep it, or those294            // Codexes become unrestorable.295            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {296                VaultCrypto.zero(candidateKey)297                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))298                plaintext = decryptSlotABlob(slotA, candidateKey)299            }300            if (plaintext == null) return UnlockResult.WrongPassword301            val payload = try {302                VaultCodec.decode(plaintext)303            } catch (_: Exception) {304                return UnlockResult.Error("invalid_codex_blob")305            }306307            val priorA = store.loadSlotA()308            val priorB = store.loadSlotB()309            val priorD = store.loadDecoySlot()310            // Carry the real duress slot across when the Codex has one — the311            // browser writes "b" as its duress-shaped slot, and dropping it312            // here would silently destroy a configured decoy.313            // H-04: write browser B into the duress slot (not the noise slot),314            // translating browser-shaped slots to Android's StoredVault shape.315            val codexDuressJson = wrapper["b"].asSlotJson()316            val codexDuress: StoredVault? = codexDuressJson?.let {317                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->318                    // H-04: pass-through — the browser duress slot is sealed319                    // under the duress passphrase, not the main password. Trying320                    // to decrypt with the slot-A password fails the GCM tag and321                    // silently drops a real decoy. Re-encode the browser hex322                    // fields as Android base64 salt/blob so tryUnlockDuress can323                    // still open it with the duress passphrase.324                    browserDuressPassthrough(b)325                }326            }327328            beforeDurableCommit()329            val committed = runCatching {330                withEpochCommit(expectedEpoch) {331                    try {332                        // Keep every slot write, including the inverse, under the333                        // same epoch fence. Releasing stateLock after a partial334                        // write and reacquiring it for rollback lets lock() advance335                        // the epoch in between and strand the incoming slot A.336                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")337                        // Noise slot is always size-matched to slot A — local-only,338                        // never written from a Codex.339                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")340                        // H-04: duress slot always present; real one from the Codex or341                        // a fake the size of slot A.342                        val duressOk = if (codexDuress != null) {343                            store.writeDecoySlot(codexDuress)344                        } else {345                            writeFakeDuressSlot(slotA)346                        }347                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")348                        store.stampLocalMutation()349                        true350                    } catch (_: Exception) {351                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))352                        // Keep the fence held through the final rollback353                        // observation. A concurrent lock() may queue here, but354                        // cannot invalidate the restored A/B/D triad.355                        afterCodexRollback()356                        false357                    }358                } ?: false359            }.getOrDefault(false)360            if (!committed) {361                return UnlockResult.Error("storage_write_failed")362            }363364            val ownedKey = candidateKey!!365            candidateKey = null // the session owns the key from here on366            adoptSlotA(ownedKey, payload, expectedEpoch)367        } catch (e: Exception) {368            // A raw kotlinx parse error ("Unexpected JSON token at offset N")369            // reaching the user says nothing about WHICH json failed — the370            // wrapper, or the payload decrypted out of it. Codex screens are371            // FLAG_SECURE so this can't be screenshotted either. Debug builds372            // log the structure around the failure; alphanumerics are masked so373            // no ciphertext or vault content lands in a log.374            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))375            UnlockResult.Error(e.message ?: "restore_failed")376        } finally {377            plaintext?.let(VaultCrypto::zero)378            candidateKey?.let(VaultCrypto::zero)379        }380    }381382    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */383    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =384        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()385386    /**387     * Undo a partially-committed Codex restore while [stateLock] is already held.388     * Every step is attempted independently so one failure cannot strand the389     * others. Callers that need epoch validation must enter through390     * [withEpochCommit] first; this helper deliberately does not reacquire the391     * lock, avoiding a reentrant gap between a failed write and its inverse.392     * With no prior vault the accurate inverse is having none at all, rather393     * than a half-written slot A that [hasVault] would report as a real vault.394     */395    private fun rollbackRestoreLocked(396        priorA: StoredVault?,397        priorB: String?,398        priorD: StoredVault? = null,399    ): List<String> {400        val failures = mutableListOf<String>()401        if (priorA == null) {402            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }403        } else {404            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }405                .onFailure { failures += "writeSlotA: $it" }406            if (priorB != null) {407                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }408                    .onFailure { failures += "writeSlotB: $it" }409            }410            // H-04: restore the prior duress slot — a half-committed restore that411            // left the duress record on the Codex side and slot A on the prior412            // side would put the at-rest pair in a state that never existed.413            if (priorD != null) {414                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }415                    .onFailure { failures += "writeDecoySlot: $it" }416            } else {417                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }418            }419        }420        return failures421    }422423    private fun reportRollbackFailures(failures: List<String>) {424        // Log is a throwing stub off-device, and a failed log must never mask425        // the rollback result the caller is about to report.426        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }427    }428429    /** Debug-only: structure of a failed Codex restore, with content masked. */430    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {431        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)432        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()433        val window = offset?.let {434            val from = (it - 40).coerceAtLeast(0)435            val to = (it + 40).coerceAtMost(wrapperJson.length)436            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")437        }438        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()439        val slot = wrapper?.get("a")440        buildString {441            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)442            append(" | blobBase64=").append(vaultBlobBase64.length)443            append(" wrapperChars=").append(wrapperJson.length)444            append(" wrapperParses=").append(wrapper != null)445            append(" keys=").append(wrapper?.keys)446            append(" slotAKind=").append(447                when {448                    slot == null -> "absent"449                    slot is JsonPrimitive && slot.isString -> "string"450                    slot is JsonObject -> "object:" + slot.keys451                    else -> slot::class.simpleName452                },453            )454            if (window != null) append(" masked@").append(offset).append("=").append(window)455        }456    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }457458    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {459        val expectedEpoch = currentEpoch()460        var key: ByteArray? = null461        return try {462            val salt = VaultCrypto.generateSalt()463            key = VaultCrypto.deriveKey(password, salt)464            if (mutateIfCurrentEpoch(expectedEpoch) {465                entryList = mutableListOf()466                duressConfigured = false467                recoveryWordHash = null468                recoveryWordsSet = false469                decoyMode = false470                sessionDataAvailable = false471                rootKey = generateRecoverableRootKey()472            } == null) return CreateVaultResult.Error("stale_create")473            // M-04: the fields above have to be live before sealSlotA, since it474            // seals currentPayload(). That stages a root key the session would475            // otherwise keep after a failed create — [lock] discards the whole476            // staged session on every non-success exit below.477            val slotA = sealSlotA(key!!, salt)478            // P1-3 (codex): every later step must roll the slot pair back if it479            // fails — leaving a half-built vault is worse than refusing to make it.480            beforeDurableCommit()481            val created = withEpochCommit(expectedEpoch) {482                if (!store.writeSlotA(slotA)) return@withEpochCommit false483                try {484                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")485                    // H-04: duress slot must be present from the start so its486                    // existence on disk is independent of duressConfigured.487                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")488                    store.stampLocalMutation()489                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")490                    true491                } catch (_: Exception) {492                    // Half-built vault on disk is worse than no vault — refuse to493                    // create if any step past slot A fails.494                    store.clearVault()495                    false496                }497            } ?: false498            if (!created) {499                lock()500                return CreateVaultResult.Error("storage_write_failed")501            }502            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")503            if (mutateIfCurrentEpoch(expectedEpoch) {504                derivedKey = sessionKey505                key = null506                publish()507                _isUnlocked.value = true508                sessionDataAvailable = true509                sessionEpoch = nextSessionEpoch(sessionEpoch)510            } == null) return CreateVaultResult.Error("stale_create")511            CreateVaultResult.Success512        } catch (e: Exception) {513            lock()514            CreateVaultResult.Error(e.message ?: "create_failed")515        } finally {516            key?.let(VaultCrypto::zero)517        }518    }519520    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {521        val expectedEpoch = currentEpoch()522        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")523        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")524        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {525            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")526        }527        var adoptedRootKey: ByteArray? = null528        var nextDerivedKey: ByteArray? = null529        var sessionRootKey: ByteArray? = null530        return try {531            adoptedRootKey = hexToRootKey(rootKeyHex)532            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {533                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")534            }535            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)536            val salt = VaultCrypto.generateSalt()537            nextDerivedKey = VaultCrypto.deriveKey(password, salt)538            sessionRootKey = adoptedRootKey.copyOf()539            val payload = VaultPayload(540                entries = emptyList(),541                duressConfigured = false,542                rootKeyB64 = b64(sessionRootKey),543                recoveryWordHash = null,544                recoveryWordsSet = false,545            )546            val plaintext = VaultCodec.encode(payload)547            val blob = try {548                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))549            } finally {550                VaultCrypto.zero(plaintext)551            }552            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)553            // P1-3 (codex): same partial-commit guard as createVault — any failure554            // past slot A wipes the half-built vault and refuses adoption.555            beforeDurableCommit()556            val created = withEpochCommit(expectedEpoch) {557                if (!store.writeSlotA(slotA)) return@withEpochCommit false558                try {559                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")560                    // H-04: duress slot must be present from the start so its561                    // existence on disk is independent of duressConfigured.562                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")563                    store.stampLocalMutation()564                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")565                    true566                } catch (_: Exception) {567                    store.clearVault()568                    false569                }570            } ?: false571            if (!created) return AdoptVaultResult.Error("storage_write_failed")572573            val adopted = mutateIfCurrentEpoch(expectedEpoch) {574                val previousDerivedKey = derivedKey575                val previousRootKey = rootKey576                derivedKey = nextDerivedKey577                rootKey = sessionRootKey578                nextDerivedKey = null579                sessionRootKey = null580                entryList = mutableListOf()581                duressConfigured = false582                recoveryWordHash = null583                recoveryWordsSet = false584                decoyMode = false585                sessionDataAvailable = true586                publish()587                _isUnlocked.value = true588                sessionEpoch = nextSessionEpoch(sessionEpoch)589                previousDerivedKey to previousRootKey590            }591            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")592            val (previousDerivedKey, previousRootKey) = adopted593            previousDerivedKey?.let(VaultCrypto::zero)594            previousRootKey?.let(VaultCrypto::zero)595            // Write the recovery escrow so the forgot-password flow works on596            // this device. The words are deterministic from the root key and597            // were already confirmed on the source device.598            runCatching {599                val words = rootKeyToWords(rootKey!!)600                val escrow = buildRecoveryEscrow(words)601                val rkEscrow = buildRootKeyEscrow(words)602                if (escrow != null || rkEscrow != null) {603                    val prior = store.loadSlotA()604                    if (prior != null) {605                        store.writeSlotA(prior.copy(606                            recoveryEscrow = escrow ?: prior.recoveryEscrow,607                            rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,608                        ))609                    }610                    mutateState {611                        recoveryWordHash = recoveryWordsHash(words)612                        recoveryWordsSet = true613                    }614                }615            }616            AdoptVaultResult.Success(vaultId)617        } catch (e: Exception) {618            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")619        } finally {620            adoptedRootKey?.let(VaultCrypto::zero)621            nextDerivedKey?.let(VaultCrypto::zero)622            sessionRootKey?.let(VaultCrypto::zero)623        }624    }625626    suspend fun unlock(password: String): UnlockResult = mutex.withLock {627        val result = unlockLocked(password, currentEpoch())628        if (result is UnlockResult.Success) ensureRecoveryEscrow()629        result630    }631632    /** Unlock body without acquiring the mutex — callers must already hold it. */633    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {634        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")635        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))636        val payload = try {637            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)638            try {639                VaultCodec.decode(plaintext)640            } finally {641                VaultCrypto.zero(plaintext)642            }643        } catch (_: Exception) {644            VaultCrypto.zero(key)645            // LV-1: a vault sealed before NFC normalization with a non-NFC646            // password derives a different key now. If the supplied password is647            // not already in NFC form, retry with the raw (un-normalized) bytes.648            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {649                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)650                if (legacy != null) return legacy651            }652            return UnlockResult.WrongPassword653        }654        return adoptSlotA(key, payload, expectedEpoch)655    }656657    /**658     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw659     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed660     * under the NFC-normalized key — so every future unlock takes the normal661     * path. Returns null when the raw key also fails (genuinely wrong password)662     * OR when any step of the heal commits fails (the original slot A is restored663     * so disk and session stay aligned).664     */665    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {666        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))667        var newKey: ByteArray? = null668        return try {669            val payload = try {670                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)671                try {672                    VaultCodec.decode(plaintext)673                } finally {674                    VaultCrypto.zero(plaintext)675                }676            } catch (_: Exception) {677                return null678            }679            // Heal: re-seal slot A under the NFC key (mirrors changePassword's680            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.681            val newSalt = VaultCrypto.generateSalt()682            newKey = VaultCrypto.deriveKey(password, newSalt)683            val plaintext = VaultCodec.encode(payload)684            val blob = try {685                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))686            } finally {687                VaultCrypto.zero(plaintext)688            }689            val next = StoredVault(690                version = slotA.version,691                salt = b64(newSalt),692                blob = blob,693                recoveryEscrow = slotA.recoveryEscrow,694                rootKeyEscrow = slotA.rootKeyEscrow,695            )696            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,697            // or stampLocalMutation must restore the prior slot A AND slot B so disk698            // and the eventual session key never disagree on the wrapping.699            val priorB = store.loadSlotB()700            beforeDurableCommit()701            val healed = withEpochCommit(expectedEpoch) {702                if (!store.writeSlotA(next)) {703                    false704                } else try {705                    if (!writeMatchingNoiseSlotB(next)) {706                        runCatching { store.writeSlotA(slotA) }707                        if (priorB != null) runCatching { store.writeSlotB(priorB) }708                        false709                    } else if (!writeCompanionDuressSlot(next)) {710                        runCatching { store.writeSlotA(slotA) }711                        if (priorB != null) runCatching { store.writeSlotB(priorB) }712                        false713                    } else {714                        store.stampLocalMutation()715                        true716                    }717                } catch (_: Exception) {718                    runCatching { store.writeSlotA(slotA) }719                    if (priorB != null) runCatching { store.writeSlotB(priorB) }720                    false721                }722            } ?: false723            if (healed) {724                VaultCrypto.zero(rawKey!!)725                rawKey = null726                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot727                newKey = null728                result729            } else {730                VaultCrypto.zero(newKey!!)731                newKey = null732                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key733                rawKey = null734                result735            }736        } finally {737            // Cover codec, encryption, storage, and stale-epoch failures before738            // either candidate key can be transferred into the live session.739            rawKey?.let(VaultCrypto::zero)740            newKey?.let(VaultCrypto::zero)741        }742    }743744    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */745    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {746        return try {747            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they748            // can throw after the fields above are live. There is no prior session749            // worth restoring — adoption has already replaced it — so a failed750            // adoption discards everything rather than leaving entries and keys751            // resident behind _isUnlocked=false.752            val adopted = mutateIfCurrentEpoch(expectedEpoch) {753                derivedKey = key754                decoyMode = false755                entryList = payload.entries.toMutableList()756                duressConfigured = payload.duressConfigured757                recoveryWordHash = payload.recoveryWordHash758                recoveryWordsSet = payload.recoveryWordsSet759                sessionDataAvailable = true760                _settings.value = store.loadSettings()761                ensureRootKey(payload)762                autoPurgeExpiredTrash()763                publish()764                _isUnlocked.value = true765                // A successful adoption starts a new generation. A sibling766                // SlotAUnlock captured in the prior epoch will fail closed at767                // the next commit instead of replacing this session.768                sessionEpoch = nextSessionEpoch(sessionEpoch)769            }770            if (adopted == null) {771                VaultCrypto.zero(key)772                UnlockResult.Error("stale_unlock")773            } else {774                UnlockResult.Success775            }776        } catch (e: Exception) {777            // Do not call lock() here: a concurrent newer session may already778            // own this repository. Only invalidate the failed epoch's fields.779            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }780            VaultCrypto.zero(key)781            UnlockResult.Error(e.message ?: "unlock_failed")782        }783    }784785    // ---- Constant-time unlock (MF-5) --------------------------------------786787    /**788     * A slot-A unlock that has been derived but not yet adopted. Holds the live789     * Argon2id key and decoded payload so the caller can derive several candidates790     * concurrently and commit only the winner. The key is owned by this token until791     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.792     */793    class SlotAUnlock internal constructor(794        key: ByteArray,795        internal val payload: VaultPayload,796        internal val epoch: Long,797    ) {798        private var ownedKey: ByteArray? = key799        private var consumed = false800801        /** Transfer the key exactly once to the adopter. */802        internal fun takeKey(): ByteArray? = synchronized(this) {803            if (consumed) return@synchronized null804            consumed = true805            ownedKey.also { ownedKey = null }806        }807808        /** Discard an uncommitted token without touching a transferred key. */809        internal fun discard() = synchronized(this) {810            if (consumed) return@synchronized811            consumed = true812            ownedKey?.let(VaultCrypto::zero)813            ownedKey = null814        }815    }816817    /**818     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.819     * Pure computation (no mutex), so it can run concurrently with other candidate820     * derivations before the caller decides which to commit (MF-5). Returns null on821     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).822     */823    fun tryUnlockSlotA(password: String): SlotAUnlock? {824        val epoch = currentEpoch()825        val slotA = store.loadSlotA() ?: return null826        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))827        return try {828            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)829            val payload = try {830                VaultCodec.decode(plaintext)831            } finally {832                VaultCrypto.zero(plaintext)833            }834            SlotAUnlock(key, payload, epoch)835        } catch (_: Exception) {836            VaultCrypto.zero(key)837            null838        }839    }840841    /** Adopt a previously-derived [SlotAUnlock] as the live session. */842    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {843        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")844        adoptSlotA(key, unlock.payload, unlock.epoch)845    }846847    /** Zero a derived [SlotAUnlock] that will not be adopted. */848    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()849850    // ---- Duress slot B (MF-6) ---------------------------------------------851852    /**853     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key854     * and decoded decoy payload so it can be derived concurrently with slot A and855     * only committed if the duress passphrase was the one entered (MF-5/MF-6).856     */857    class DuressUnlock internal constructor(858        key: ByteArray,859        internal val payload: VaultPayload,860        internal val epoch: Long,861    ) {862        private var ownedKey: ByteArray? = key863        private var consumed = false864865        internal fun takeKey(): ByteArray? = synchronized(this) {866            if (consumed) return@synchronized null867            consumed = true868            ownedKey.also { ownedKey = null }869        }870871        internal fun discard() = synchronized(this) {872            if (consumed) return@synchronized873            consumed = true874            ownedKey?.let(VaultCrypto::zero)875            ownedKey = null876        }877    }878879    /**880     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the881     * session. Success means the entered password is the duress passphrase that keys882     * slot B — indistinguishable from the real slot A at the lock screen. Pure883     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].884     * Returns null when no duress slot exists or the password is not the duress one.885     */886    fun tryUnlockDuress(password: String): DuressUnlock? {887        val epoch = currentEpoch()888        val slot = store.loadDecoySlot() ?: return null889        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))890        return try {891            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)892            val payload = try {893                VaultCodec.decode(plaintext)894            } finally {895                VaultCrypto.zero(plaintext)896            }897            DuressUnlock(key, payload, epoch)898        } catch (_: Exception) {899            VaultCrypto.zero(key)900            null901        }902    }903904    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */905    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {906        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")907        if (currentEpoch() != unlock.epoch) {908            VaultCrypto.zero(key)909            return@withLock UnlockResult.Error("stale_unlock")910        }911        return try {912            val adopted = mutateIfCurrentEpoch(unlock.epoch) {913                derivedKey = key914                decoyMode = true915                entryList = unlock.payload.entries.toMutableList()916                duressConfigured = false917                recoveryWordHash = unlock.payload.recoveryWordHash918                recoveryWordsSet = unlock.payload.recoveryWordsSet919                sessionDataAvailable = true920                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)921                          else VaultCrypto.generateRootKey()922                _settings.value = store.loadSettings()923                publish()924                _isUnlocked.value = true925                sessionEpoch = nextSessionEpoch(sessionEpoch)926            }927            if (adopted == null) {928                VaultCrypto.zero(key)929                UnlockResult.Error("stale_unlock")930            } else {931                UnlockResult.Success932            }933        } catch (e: Exception) {934            // A payload/root/settings failure can occur after the candidate key935            // has been assigned to derivedKey. Tear down only this epoch so a936            // newer session is not disturbed, and scrub the token reference on937            // every error path.938            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }939            VaultCrypto.zero(key)940            UnlockResult.Error(e.message ?: "decoy_unlock_failed")941        }942    }943944    /** Zero a derived [DuressUnlock] that will not be adopted. */945    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()946947    /**948     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an949     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The950     * passphrase is never stored — only its ability to decrypt slot B proves it.951     * Requires the real vault to be unlocked and not itself a decoy session.952     */953    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {954        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().955        val expectedEpoch = mutateState {956            if (derivedKey != null && !decoyMode) sessionEpoch else null957        } ?: return false958        // Reject a duress passphrase that also opens the real vault: it would make the959        // lock screen ambiguous (both slots would decrypt for the same input).960        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }961        var key: ByteArray? = null962        var basePlaintext: ByteArray? = null963        return try {964            val salt = VaultCrypto.generateSalt()965            key = VaultCrypto.deriveKey(duressPassphrase, salt)966            // An empty decoy vault with its own fresh sync root key — nothing real leaks.967            val decoyRoot = VaultCrypto.generateRootKey()968            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }969            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)970            basePlaintext = VaultCodec.encode(decoyPayload)971            // H-04: pad the duress plaintext to slot A's ciphertext length so the972            // duress blob matches slot A's blob on disk. VaultCodec.decode strips973            // trailing NULs, so the extra padding survives encrypt but never974            // reaches the JSON parser.975            // ponytail: only re-pads at configuration time — if slot A grows past976            // its initial bucket (entries cross 4096/16384/etc.), the real decoy977            // stays at the smaller bucket. Re-padding on every persist requires978            // keeping the duress passphrase in memory, which we don't.979            val slotA = store.loadSlotA()980            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0981            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!982            val blob = try {983                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))984            } finally {985                VaultCrypto.zero(plaintext)986                // Padding creates a second array; scrub the original encoded987                // payload too rather than dropping its last reference untouched.988                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)989                basePlaintext = null990                key?.let(VaultCrypto::zero)991                key = null992            }993            beforeDurableCommit()994            val ok = withEpochCommit(expectedEpoch) {995                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))996            } ?: false997            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {998                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }999            } else if (ok) {1000                return false1001            }1002            ok1003        } catch (_: Exception) {1004            false1005        } finally {1006            // Cover failures before the encrypt block is entered (for example a1007            // codec/copy exception) as well as any early return after key derivation.1008            key?.let(VaultCrypto::zero)1009            basePlaintext?.let(VaultCrypto::zero)1010        }1011    }101210131014    /**1015     * Export the decoy slot as browser-shaped JSON for adoption snapshots.1016     *1017     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all1018     * lowercase hex — identical to the browser's `duress_slot` wire format — so1019     * any receiver (browser or Android) can install it via the existing1020     * passthrough. Returns null when duress is not configured, no decoy exists,1021     * or any conversion failure occurs. Never decrypts.1022     */1023    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1024        if (!duressConfigured) return null1025        val slot = store.loadDecoySlot() ?: return null1026        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1027    }10281029    /**1030     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1031     *1032     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1033     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1034     * byte arrays are zeroed in finally.1035     */1036    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1037        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1038        // serialized — kotlinx-serialization's encodeDefaults = false would1039        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1040        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1041        // silently dropping the duress slot on Android→browser adoptions.1042        var salt: ByteArray? = null1043        var blob: ByteArray? = null1044        var nonce: ByteArray? = null1045        var ctAndTag: ByteArray? = null1046        var ct: ByteArray? = null1047        var tag: ByteArray? = null1048        try {1049            salt = unb64(slot.salt)1050            blob = unb64(slot.blob)1051            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1052            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1053            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1054            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1055            val obj = JsonObject(mapOf(1056                "version" to JsonPrimitive(1),1057                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1058                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1059                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1060                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1061            ))1062            return obj.toString()1063        } finally {1064            salt?.let(VaultCrypto::zero)1065            blob?.let(VaultCrypto::zero)1066            nonce?.let(VaultCrypto::zero)1067            ctAndTag?.let(VaultCrypto::zero)1068            ct?.let(VaultCrypto::zero)1069            tag?.let(VaultCrypto::zero)1070        }1071    }10721073    /**1074     * Install a browser-shaped duress slot received through adoption.1075     *1076     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1077     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1078     * writes the result to the decoy slot. Epoch-fenced like the Codex1079     * restore path so a concurrent lock() cannot strand the write.1080     *1081     * Returns false on invalid input, no live session, or write failure.1082     */1083    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1084        val b = BrowserSlot.parse(json) ?: return false1085        var stored: StoredVault? = null1086        var salt: ByteArray? = null1087        var nonce: ByteArray? = null1088        var ct: ByteArray? = null1089        var tag: ByteArray? = null1090        var blob: ByteArray? = null1091        try {1092            salt = unhex(b.salt)1093            nonce = unhex(b.nonce)1094            ct = unhex(b.ciphertext)1095            tag = unhex(b.tag)1096            blob = ByteArray(nonce.size + ct.size + tag.size).also {1097                System.arraycopy(nonce, 0, it, 0, nonce.size)1098                System.arraycopy(ct, 0, it, nonce.size, ct.size)1099                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1100            }1101            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1102            val expectedEpoch = sessionEpoch1103            beforeDurableCommit()1104            withEpochCommit(expectedEpoch) {1105                store.writeDecoySlot(stored)1106            } ?: false1107        } catch (_: Exception) {1108            false1109        } finally {1110            salt?.let(VaultCrypto::zero)1111            nonce?.let(VaultCrypto::zero)1112            ct?.let(VaultCrypto::zero)1113            tag?.let(VaultCrypto::zero)1114            blob?.let(VaultCrypto::zero)1115        }1116    }11171118    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1119    suspend fun clearDuress(): Boolean = mutex.withLock {1120        // H-04: presence-indistinguishable — replace the real decoy with a fake1121        // blob of slot A's size rather than removing the duress record. The1122        // record stays on disk at the same length, so an at-rest observer1123        // cannot tell "duress cleared" from "no duress ever configured".1124        val expectedEpoch = currentEpoch()1125        val replaced = withEpochCommit(expectedEpoch) {1126            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1127                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1128        } ?: false1129        if (!replaced) return@withLock false1130        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1131        // duressConfigured write so a concurrent lock() cannot land between them.1132        if (!isSessionEpochCurrent(expectedEpoch)) return false1133        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1134        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1135            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1136        }1137        true1138    }11391140    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1141    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1142        var current: Throwable? = error1143        while (current != null) {1144            if (current is javax.crypto.AEADBadTagException) return true1145            // Some Android providers expose a GCM tag failure as the parent1146            // BadPaddingException rather than the AEAD-specific subtype.  A1147            // short ciphertext is an IllegalBlockSizeException and therefore1148            // remains an internal/malformed-payload Error below.1149            if (current is javax.crypto.BadPaddingException) return true1150            current = current.cause1151        }1152        return false1153    }11541155    /** Unlock with an already-derived AES-256 key (biometric path). */1156    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1157        val expectedEpoch = currentEpoch()1158        val slotA = try {1159            store.loadSlotA()1160        } catch (_: Exception) {1161            return@withLock UnlockResult.Error("unlock_key_storage_failed")1162        } ?: return@withLock UnlockResult.Error("no_vault")1163        // Keep the caller-owned key separate from the staged session key. The1164        // biometric caller always zeroes its input in a finally block; adoption1165        // must therefore own a copy and use the same all-or-nothing path as a1166        // password unlock rather than publishing a partially decoded session.1167        var stagedKey: ByteArray? = key.copyOf()1168        return try {1169            val encrypted = try {1170                unb64(slotA.blob)1171            } catch (_: Exception) {1172                return@withLock UnlockResult.Error("invalid_vault_blob")1173            }1174            // A GCM record must include nonce + authentication tag.  Providers1175            // differ on whether a shorter record is reported as BadPadding or1176            // AEADBadTag; reject it before decrypt so malformed storage cannot be1177            // misreported as a user-entered wrong key.1178            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1179                return@withLock UnlockResult.Error("invalid_vault_blob")1180            }1181            val plaintext = try {1182                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1183            } catch (e: Exception) {1184                if (isGcmAuthenticationFailure(e)) {1185                    return@withLock UnlockResult.WrongPassword1186                }1187                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1188            }1189            val payload = try {1190                VaultCodec.decode(plaintext)1191            } catch (_: Exception) {1192                return@withLock UnlockResult.Error("invalid_vault_payload")1193            } finally {1194                VaultCrypto.zero(plaintext)1195            }1196            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1197            val result = adoptSlotA(candidate, payload, expectedEpoch)1198            if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1199            result1200        } finally {1201            stagedKey?.let(VaultCrypto::zero)1202        }1203    }12041205    fun lock() {1206        // H-03: must observe derivedKey/entryList atomically with any in-flight1207        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1208        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1209        // guards just the brief state reset. Advancing the epoch first invalidates1210        // every operation that captured this session before it reaches a later1211        // durable commit or publication.1212        stateLock.lock()1213        try {1214            sessionEpoch = nextSessionEpoch(sessionEpoch)1215            clearSessionStateLocked()1216            _uiGateLocked.value = false1217        } finally {1218            stateLock.unlock()1219        }1220    }12211222    /**1223     * Enter sync-retained background mode: the UI shows the lock screen but the1224     * vault stays fully unlocked in memory so [PeachSyncService] can complete1225     * sync sessions. Called from [PeachApplication.onStop] when background sync1226     * is enabled. A subsequent full [lock] (auto-lock timer, manual lock)1227     * supersedes this and zeroes everything.1228     */1229    fun lockUiGate() {1230        if (_isUnlocked.value) _uiGateLocked.value = true1231    }12321233    /** Dismiss the UI gate after the user authenticates on resume. */1234    fun clearUiGate() {1235        _uiGateLocked.value = false1236    }12371238    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12391240    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1241    // is a stub off-device).1242    @VisibleForTesting1243    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12441245    /**1246     * Call immediately before launching a system Activity (a SAF file1247     * picker/save dialog, a share sheet) that briefly takes the whole process1248     * out of the foreground as an expected part of an in-app flow — not a1249     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1250     * two apart on its own (it fires for both), and without this, exporting or1251     * importing a file silently locked and wiped the vault mid-flow the moment1252     * the picker took over, before its result ever reached the app.1253     *1254     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1255     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1256     */1257    fun expectTransientBackground() {1258        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1259    }12601261    /**1262     * Consumed once by the next background-lock check; see [expectTransientBackground].1263     * Returns false (and clears the flag) if the suppression has expired or was1264     * never set — a genuine background event must always lock.1265     */1266    fun consumeTransientBackgroundSuppression(): Boolean {1267        val until = suppressNextBackgroundLockUntil1268        val active = until != 0L && nowMs() < until1269        suppressNextBackgroundLockUntil = 0L1270        return active1271    }12721273    // ---- Sync identity ----------------------------------------------------12741275    /**1276     * The vault's stable sync master key (a copy). Caller must zero it. Backs1277     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1278     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1279     */1280    /**1281     * HOLE 5 — the duress vault never syncs.1282     *1283     * [commitDuressUnlock] gives the decoy session a root key so its own codex1284     * and recovery paths work, which means (unlike the browser, where the decoy1285     * path simply has no root key) the sync surface has to refuse explicitly.1286     * Without this the decoy vault would present a second, different vault_id1287     * to a paired device, and a coerced user opening it would make sync visibly1288     * fail on their other devices — a duress indicator, which is the one thing1289     * a duress vault must never be.1290     */1291    // M-04: the "null when locked" half of the contract was documented but never1292    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1293    // [unlockWithRecoveryWords], which installs a root key with no password key1294    // yet) and in a create that failed after staging. Sync must fail closed1295    // there, so the authoritative unlocked flag gates it, not just decoyMode.1296    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12971298    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1299        if (decoyMode || !_isUnlocked.value) null1300        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1301    }13021303    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1304        mutateState { sessionEpoch == epoch }13051306    internal fun unlockedSessionEpoch(): Long? = mutateState {1307        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1308    }13091310    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13111312    /**1313     * The current session's root key, decoy included. For local-only uses that1314     * must behave identically in a decoy session — Codex backup and recovery1315     * words — because a feature that errors under duress is itself a duress1316     * tell. In a decoy session this is the DECOY's root key (see1317     * [commitDuressUnlock]), never the real vault's, so neither surface can1318     * leak the real vault. Anything that leaves the device must use1319     * [syncMasterKey] instead.1320     */1321    fun sessionRootKey(): ByteArray? = mutateState {1322        if (!sessionDataAvailable) null else rootKey?.copyOf()1323    }13241325    /** Stable per-vault id two paired devices must share to sync. Null when1326     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1327    fun syncVaultId(): String? = mutateState {1328        if (decoyMode || !_isUnlocked.value) null1329        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1330    }13311332    /**1333     * Browser parity (MV-8): rotate the sync identity by replacing the stable1334     * root key. Entries remain under the master-password key, while sync1335     * credentials, vault_id, and recovery words all change.1336     */1337    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1338        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1339            ?: return false1340        installRootKey(generateRecoverableRootKey(), expectedEpoch)1341    }13421343    /**1344     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1345     * keeping every local entry.1346     *1347     * Sync identity (vault id, signing keys) derives from the root key, and1348     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1349     * created vaults can never reconcile. Taking the other side's root key is1350     * what makes them one vault; from the next sync on, both entry sets merge.1351     *1352     * Entries survive because they are sealed under the password-derived key,1353     * not the root key — the same property [rotateSyncIdentity] relies on.1354     *1355     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1356     * this device's previous recovery words stop working for good, and its new1357     * words are the source vault's words, because words are a deterministic1358     * encoding of the root key.1359     */1360    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1361        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1362            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1363        }1364        val expectedEpoch = mutateState {1365            when {1366                derivedKey == null -> null1367                decoyMode -> Long.MIN_VALUE1368                else -> sessionEpoch1369            }1370        }1371        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1372        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1373        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13741375        val incoming = hexToRootKey(rootKeyHex)1376        if (incoming.size != VaultCrypto.KEY_SIZE) {1377            VaultCrypto.zero(incoming)1378            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1379        }1380        // Refuse before committing rather than leave this device holding a vault1381        // whose recovery words cannot be typed back. generateRecoverableRootKey1382        // screens its own keys for this; a key arriving from a peer has not been.1383        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1384        if (!roundTrips) {1385            VaultCrypto.zero(incoming)1386            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1387        }13881389        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1390        return if (installRootKey(incoming, expectedEpoch)) {1391            AdoptVaultResult.Success(vaultId)1392        } else {1393            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1394        }1395    }13961397    /**1398     * The recovery words [rootKeyHex] will produce once joined, or null if it is1399     * not a usable root key. Lets the join flow show the user the words that are1400     * about to replace theirs *before* anything is committed.1401     */1402    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1403        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1404        val bytes = hexToRootKey(rootKeyHex)1405        return try {1406            val words = rootKeyToWords(bytes)1407            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1408        } catch (_: Exception) {1409            null1410        } finally {1411            VaultCrypto.zero(bytes)1412        }1413    }14141415    /**1416     * Swap the vault's root key, keeping every entry, and re-escrow under the1417     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1418     * [mutex].1419     */1420    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1421        // H-03: snapshot the live fields under stateLock so the success and the1422        // rollback paths both see consistent prior/new state. Writing the new1423        // rootKey/recovery fields is inside its own mutateState at the end.1424        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1425        // — holding live references would let a zero-on-failure operation also1426        // wipe the session's still-live key arrays.1427        data class Snapshot(1428            val key: ByteArray,1429            val priorRootKey: ByteArray,1430            val priorRecoveryHash: String?,1431            val priorRecoverySet: Boolean,1432        )1433        val snap = mutateIfCurrentEpoch(expectedEpoch) {1434            val k = derivedKey ?: return@mutateIfCurrentEpoch null1435            if (decoyMode) return@mutateIfCurrentEpoch null1436            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1437            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1438        } ?: run {1439            VaultCrypto.zero(nextRootKey)1440            return false1441        }1442        val priorSlot = store.loadSlotA() ?: run {1443            VaultCrypto.zero(snap.key)1444            VaultCrypto.zero(snap.priorRootKey)1445            VaultCrypto.zero(nextRootKey)1446            return false1447        }1448        val nextWords = rootKeyToWords(nextRootKey)14491450        return try {1451            beforeDurableCommit()1452            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1453            if (mutateIfCurrentEpoch(expectedEpoch) {1454                rootKey = nextRootKey1455                recoveryWordHash = recoveryWordsHash(nextWords)1456                recoveryWordsSet = false1457            } == null) throw IllegalStateException("stale_root_rotation")14581459            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1460                ?: throw IllegalStateException("recovery_escrow_failed")1461            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1462                ?: throw IllegalStateException("root_escrow_failed")1463            val plaintext = VaultCodec.encode(currentPayload())1464            val blob = try {1465                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1466            } finally {1467                VaultCrypto.zero(plaintext)1468            }1469            val nextSlot = priorSlot.copy(1470                blob = blob,1471                recoveryEscrow = nextRecoveryEscrow,1472                rootKeyEscrow = nextRootKeyEscrow,1473            )1474            val committed = withEpochCommit(expectedEpoch) {1475                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1476                try {1477                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1478                    // H-04: a root-key rotation re-seals slot A, so the duress slot1479                    // (real or fake) must be re-padded to the new ciphertext length.1480                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1481                    store.stampLocalMutation()1482                } catch (_: Exception) {1483                    store.writeSlotA(priorSlot)1484                    return@withEpochCommit false1485                }1486                true1487            } ?: false1488            if (!committed) throw IllegalStateException("storage_write_failed")1489            mutateIfCurrentEpoch(expectedEpoch) { publish() }1490            true1491        } catch (_: Exception) {1492            val restored = mutateIfCurrentEpoch(expectedEpoch) {1493                rootKey?.let(VaultCrypto::zero)1494                rootKey = snap.priorRootKey.copyOf()1495                recoveryWordHash = snap.priorRecoveryHash1496                recoveryWordsSet = snap.priorRecoverySet1497            }1498            if (restored != null) {1499                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1500            }1501            false1502        } finally {1503            VaultCrypto.zero(snap.key)1504            VaultCrypto.zero(snap.priorRootKey)1505            // On success nextRootKey is owned by rootKey. On every other path1506            // it is not, including a stale epoch after lock(); scrub it while1507            // the local reference is still available.1508            val stillOwnedBySession = mutateState {1509                sessionEpoch == expectedEpoch && rootKey === nextRootKey1510            }1511            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1512        }1513    }15141515    /**1516     * A copy of the live Argon2id-derived vault key, for biometric escrow only1517     * (wrapped under a hardware KeyStore key on biometric enable). Null when1518     * locked. Caller must [VaultCrypto.zero] it after use.1519     */1520    fun currentVaultKey(): ByteArray? = mutateState {1521        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1522    }15231524    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1525        val expectedEpoch = currentEpoch()1526        val slotA = store.loadSlotA() ?: return false1527        var oldKey: ByteArray? = null1528        var newKey: ByteArray? = null1529        var plaintext: ByteArray? = null1530        return try {1531            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1532            // Keep ownership of the decrypted vault plaintext until decoding has1533            // finished.  Passing decrypt() inline made the byte array unreachable1534            // before the finally block, so a successful or failed password change1535            // could leave the full plaintext resident in the heap.1536            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1537            val payload = try {1538                VaultCodec.decode(decrypted)1539            } finally {1540                VaultCrypto.zero(decrypted)1541            }1542            val newSalt = VaultCrypto.generateSalt()1543            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1544            // Re-seal the existing payload under the new key/salt.1545            plaintext = VaultCodec.encode(payload)1546            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1547            // The escrow seals the slot-A PLAINTEXT, which a password change does not1548            // alter — only the wrapping changes — so carry it forward; rebuilding the1549            // StoredVault from scratch would silently drop it.1550            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1551            // content that a password change does not alter — only the slot-A1552            // wrapping rotates. The root-key escrow staying stable across password1553            // changes matches the browser (`vault.ts:363-364`).1554            val next = StoredVault(1555                version = slotA.version,1556                salt = b64(newSalt),1557                blob = blob,1558                recoveryEscrow = slotA.recoveryEscrow,1559                rootKeyEscrow = slotA.rootKeyEscrow,1560            )1561            // H-01: durably commit slot A + size-matched noise + version stamp1562            // before swapping the session key. A partial commit (new A on disk,1563            // old key in memory) would have the next save encrypt under the old1564            // key while matching disk on the new one.1565            val priorB = store.loadSlotB()1566            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1567            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1568            // inside the same stateLock critical section, so a concurrent lock()1569            // can't observe the in-between (zeroed but not yet reassigned).1570            if (mutateIfCurrentEpoch(expectedEpoch) {1571                oldKey?.let(VaultCrypto::zero); oldKey = null1572                derivedKey?.let(VaultCrypto::zero)1573                derivedKey = newKey; newKey = null1574            } == null) return false1575            true1576        } catch (_: Exception) {1577            false1578        } finally {1579            oldKey?.let(VaultCrypto::zero)1580            newKey?.let(VaultCrypto::zero)1581            plaintext?.let(VaultCrypto::zero)1582        }1583    }15841585    /**1586     * Irreversible wipe. Locks the session and clears every vault key. The Pro1587     * license is in a separate store and is preserved by construction.1588     */1589    suspend fun destroyVault() = mutex.withLock {1590        lock()1591        store.clearVault()1592        store.clearDecoySlot()1593    }15941595    // ---- Entry CRUD -------------------------------------------------------15961597    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1598        val epoch = requireUnlocked()1599        val stamp = VaultCrypto.monotonicEntryTimestamp()1600        val now = System.currentTimeMillis()1601        val entry = withDerivedLast4(draft.copy(1602            id = draft.id.ifBlank { UUID.randomUUID().toString() },1603            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1604            modifiedAt = stamp,1605            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1606        ))1607        // H-03: capture rollback AND write entryList under stateLock so a1608        // concurrent lock() cannot zero the session between the read and the write.1609        val rollback = mutateCurrentSession(epoch) {1610            val r = entryList.toMutableList()1611            entryList.add(entry)1612            r1613        } ?: throw IllegalStateException("vault_locked")1614        persistOrRollback(rollback, epoch)1615        entry1616    }16171618    /**1619     * Insert many entries with a single encrypt-and-write at the end.1620     *1621     * Importing a real vault through [addEntry] was quadratic: every entry took1622     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1623     * restore did 806 full-vault writes and hung the app long enough to ANR.1624     * Rolls back to the pre-import list if the one write fails, matching1625     * [addEntry]'s all-or-nothing behaviour.1626     */1627    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1628        val epoch = requireUnlocked()1629        if (drafts.isEmpty()) return 01630        val now = System.currentTimeMillis()1631        val ready = drafts.map { draft ->1632            val stamp = VaultCrypto.monotonicEntryTimestamp()1633            withDerivedLast4(1634                draft.copy(1635                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1636                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1637                    modifiedAt = stamp,1638                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1639                ),1640            )1641        }1642        // H-03: capture rollback AND append every entry under stateLock.1643        val rollback = mutateCurrentSession(epoch) {1644            val r = entryList.toMutableList()1645            entryList.addAll(ready)1646            r1647        } ?: throw IllegalStateException("vault_locked")1648        persistOrRollback(rollback, epoch)1649        return drafts.size1650    }16511652    /**1653     * M-01: replace the whole active set with [newEntries] in one write.1654     *1655     * Import's REPLACE mode used to soft-delete every current entry one durable1656     * write at a time (swallowing each failure) and then batch-add, so a failed1657     * add left the old entries stranded in Trash with nothing restored. Staging1658     * the entire next list in memory and committing it through1659     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1660     * pre-import list — active entries included — is restored.1661     *1662     * Existing tombstones are preserved so paired devices still see the1663     * deletions they already know about.1664     */1665    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1666        val epoch = requireUnlocked()1667        val now = System.currentTimeMillis()1668        val ready = newEntries.map { draft ->1669            val stamp = VaultCrypto.monotonicEntryTimestamp()1670            withDerivedLast4(1671                draft.copy(1672                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1673                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1674                    modifiedAt = stamp,1675                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1676                ),1677            )1678        }1679        // M-03: an incoming entry that shares an id with an existing live entry1680        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1681        // the old one and then appending the new one would leave the list with1682        // two entries of the same id — the tombstoned one would be returned by1683        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1684        // superseded entries from the tombstone set instead.1685        val incomingIds = ready.map { it.id }.toHashSet()1686        val rollback = mutateCurrentSession(epoch) {1687            val r = entryList.toMutableList()1688            val replaced = entryList.map { entry ->1689                if (entry.deletedAt != null) return@map entry1690                if (entry.id in incomingIds) return@map null1691                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1692                entry.copy(deletedAt = stamp, modifiedAt = stamp)1693            }.filterNotNull()1694            entryList = (replaced + ready).toMutableList()1695            r1696        } ?: throw IllegalStateException("vault_locked")1697        persistOrRollback(rollback, epoch)1698        return ready.size1699    }17001701    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1702        val epoch = requireUnlocked()1703        // H-03: read entryList, compute the patched entry, and write it under a1704        // single stateLock so a concurrent lock() cannot interleave between the1705        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1706        // list, or clobber the entry onto the wrong id after a relock).1707        val prepared = mutateCurrentSession(epoch) {1708            val idx = entryList.indexOfFirst { it.id == patch.id }1709            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1710            val current = entryList[idx]1711            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1712            // Only refresh passwordChangedAt when the password value actually changes.1713            val passwordChanged = patch.password != null && patch.password != current.password1714            // Capture the outgoing password (newest first) so the user can reveal/copy/1715            // restore it later. History is computed authoritatively from `current`, not1716            // the patch — updateEntry is a full-entry replace, so trusting patch's value1717            // would let a stale form clobber the real history. Record the age the old1718            // password carried, skip an empty prior, and skip an immediate exact dupe1719            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1720            val priorPassword = current.password1721            val shouldCapture = passwordChanged &&1722                !priorPassword.isNullOrEmpty() &&1723                current.passwordHistory?.firstOrNull()?.password != priorPassword1724            val nextHistory = if (shouldCapture) {1725                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1726                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1727            } else {1728                current.passwordHistory1729            }1730            val updated = withDerivedLast4(patch.copy(1731                createdAt = current.createdAt,1732                modifiedAt = stamp,1733                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1734                passwordHistory = nextHistory,1735            ))1736            val rollback = entryList.toMutableList()1737            entryList[idx] = updated1738            updated to rollback1739        } ?: throw IllegalStateException("vault_locked")1740        val (updated, rollback) = prepared1741        persistOrRollback(rollback, epoch)1742        updated1743    }17441745    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1746    suspend fun deleteEntry(id: String) = mutex.withLock {1747        val epoch = requireUnlocked()1748        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1749        // the list out from under the indexOfFirst.1750        val rollback = mutateCurrentSession(epoch) {1751            val idx = entryList.indexOfFirst { it.id == id }1752            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1753            val current = entryList[idx]1754            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1755            val prior = entryList.toMutableList()1756            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1757            prior1758        } ?: throw IllegalStateException("vault_locked")1759        persistOrRollback(rollback, epoch)1760    }17611762    suspend fun restoreEntry(id: String) = mutex.withLock {1763        val epoch = requireUnlocked()1764        val rollback = mutateCurrentSession(epoch) {1765            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1766            if (idx == -1) throw IllegalStateException("deleted entry not found")1767            val current = entryList[idx]1768            val prior = entryList.toMutableList()1769            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1770            prior1771        } ?: throw IllegalStateException("vault_locked")1772        persistOrRollback(rollback, epoch)1773    }17741775    /** Permanent delete — no tombstone, no recovery. */1776    suspend fun purgeEntry(id: String) = mutex.withLock {1777        val epoch = requireUnlocked()1778        val rollback = mutateCurrentSession(epoch) {1779            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1780            if (idx == -1) throw IllegalStateException("deleted entry not found")1781            val prior = entryList.toMutableList()1782            entryList.removeAt(idx)1783            prior1784        } ?: throw IllegalStateException("vault_locked")1785        persistOrRollback(rollback, epoch)1786    }17871788    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1789    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1790        val epoch = requireUnlocked()1791        // H-03: read+remove under stateLock so lock() can't reorder the remove.1792        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1793        val r = mutateCurrentSession(epoch) {1794            val rollback = entryList.toMutableList()1795            val before = entryList.size1796            entryList.removeAll { it.deletedAt != null }1797            R(rollback, before - entryList.size)1798        } ?: throw IllegalStateException("vault_locked")1799        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1800        r.removed1801    }18021803    /** Browser parity: number of active entries that dedupe would move to trash. */1804    suspend fun countDuplicateEntries(): Int = mutex.withLock {1805        val epoch = requireUnlocked()1806        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1807        // lock() can't race the read with state writes.1808        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1809            ?: throw IllegalStateException("vault_locked")1810    }18111812    /**1813     * Browser parity: soft-delete duplicate entries, keeping the most recently1814     * modified member of each group. Refuse in decoy mode so the duress vault is1815     * not reshaped into a forensically distinct state.1816     */1817    suspend fun deduplicateEntries(): Int = mutex.withLock {1818        val epoch = requireUnlocked()1819        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1820        val ready = mutateCurrentSession(epoch) {1821            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1822            val active = entryList.filter { it.deletedAt == null }1823            val keepIds = mutableSetOf<String>()1824            active.groupBy { it.duplicateKey() }.values.forEach { group ->1825                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1826            }1827            val rollback = entryList.toMutableList()1828            var removed = 01829            entryList = entryList.map { entry ->1830                if (entry.deletedAt == null && entry.id !in keepIds) {1831                    removed++1832                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1833                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1834                } else {1835                    entry1836                }1837            }.toMutableList()1838            R(rollback, removed)1839        } ?: throw IllegalStateException("vault_locked")1840        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1841        ready.removed1842    }18431844    fun getEntries(): List<VaultEntry> = mutateState {1845        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1846    }1847    fun getEntry(id: String): VaultEntry? = mutateState {1848        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1849    }1850    fun getDeletedEntries(): List<VaultEntry> = mutateState {1851        if (!sessionDataAvailable) emptyList()1852        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1853    }18541855    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1856    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1857        return mutateState {1858            if (!sessionDataAvailable) return@mutateState emptyList()1859            val active = entryList.filter { it.deletedAt == null }1860            if (tagFilter == null) return@mutateState active1861            val needle = tagFilter.trim().lowercase()1862            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1863        }1864    }18651866    /** Sorted, de-duplicated tags across all active entries. */1867    fun getDistinctTags(): List<String> = mutateState {1868        if (!sessionDataAvailable) return@mutateState emptyList()1869        entryList.asSequence()1870            .filter { it.deletedAt == null }1871            .flatMap { it.tags?.asSequence() ?: emptySequence() }1872            .map { it.trim().lowercase() }1873            .filter { it.isNotEmpty() }1874            .distinct()1875            .sorted()1876            .toList()1877    }18781879    /**1880     * Distinct non-blank values for an identity field across active entries,1881     * most-frequently-used first. Supports "username" and "email"; unknown field1882     * names return an empty list. Values keep original casing (deduped1883     * case-insensitively) so a suggestion matches what the user typed.1884     */1885    fun getDistinctFieldValues(field: String): List<String> {1886        val select: (VaultEntry) -> String? = when (field) {1887            "username" -> { e -> e.username }1888            "email" -> { e -> e.email }1889            else -> return emptyList()1890        }1891        return mutateState {1892            if (!sessionDataAvailable) return@mutateState emptyList()1893            entryList.asSequence()1894                .filter { it.deletedAt == null }1895                .mapNotNull { select(it)?.trim() }1896                .filter { it.isNotEmpty() }1897                .groupingBy { it.lowercase() }1898                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1899                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1900                }1901                .values1902                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1903                .map { it.first }1904        }1905    }19061907    fun getTombstones(): List<Tombstone> = mutateState {1908        if (!sessionDataAvailable) emptyList()1909        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1910    }1911    fun getVaultVersion(): Long = store.version()1912    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19131914    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1915        val expectedEpoch = currentEpoch()1916        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1917        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1918        // H-04: snapshots now carry the duress slot too — restore preserves1919        // the at-rest triad (A/B/D), not just the A/B pair.1920        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1921        val priorA = store.loadSlotA()1922        val priorB = store.loadSlotB()1923        val priorD = store.loadDecoySlot()1924        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1925        // slot write — the old code took this snapshot, then stranded it on1926        // every failure, polluting history with a "restore" entry that never1927        // restored anything. The history below is rolled back together with1928        // the slots if the commit fails.1929        val priorHistory = store.loadVaultHistory()1930        // P1-4 (codex): the recovery snapshot must land before we commit the1931        // slot pair, otherwise a failed restore cannot be rolled back to the1932        // pre-restore history. If the history write itself fails, refuse to1933        // proceed — the alternative is a successful restore with no undo path.1934        beforeDurableCommit()1935        if (!takeVaultSnapshot("restore", expectedEpoch)) {1936            return false1937        }1938        // H-04: legacy snapshots without vault_d, or vault_d that fails to1939        // decode, fall back to a fake duress of slot A's size — presence-1940        // indistinguishable from any other freshly-restored vault.1941        val nextD: StoredVault = restoredD ?: run {1942            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161943            val fakeBlob = ByteArray(target).also(rng::nextBytes)1944            val fakeSalt = VaultCrypto.generateSalt()1945            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1946        }1947        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1948            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1949            return false1950        }1951        lock()1952        true1953    }19541955    /**1956     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1957     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1958     * (a restore preserves the duress-shaped blob from history), not a fresh1959     * noise record. Any failure rolls back so the at-rest pair never holds a1960     * restored slot A against a pre-restore slot B. Caller is responsible for1961     * restoring the pre-snapshot history on a false return.1962     *1963     * H-04: duress slot is now part of the atomic triad — restored last so1964     * a duress-write failure rolls the A/B pair back too.1965     */1966    private fun commitRestoreAtomic(1967        nextA: StoredVault,1968        nextB: String,1969        nextD: StoredVault,1970        priorA: StoredVault?,1971        priorB: String?,1972        priorD: StoredVault?,1973        expectedEpoch: Long,1974    ): Boolean {1975        return withEpochCommit(expectedEpoch) {1976            if (!store.writeSlotA(nextA)) return@withEpochCommit false1977            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1978            // must trigger rollback; previously a throw escaped without restoring1979            // the prior slot A and history.1980            try {1981                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1982                // H-04: duress slot is part of the restore triad. Failures here1983                // roll A+B back so the at-rest state stays consistent.1984                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1985                store.stampLocalMutation()1986            } catch (_: Exception) {1987                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1988                return@withEpochCommit false1989            }1990            true1991        } ?: false1992    }19931994    // ---- Sync apply (does NOT advance the version) ------------------------19951996    /**1997     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1998     * WITHOUT bumping the version counter, so a sync round cannot cascade into1999     * another outbound sync. Returns the number of locally-applied changes.2000     */2001    suspend fun applyMergedRemoteEntries(2002        remoteEntries: List<VaultEntry>,2003        remoteTombstones: List<Tombstone>,2004        expectedEpoch: Long? = null,2005    ): Int = mutex.withLock {2006        val epoch = expectedEpoch ?: requireUnlocked()2007        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02008        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS2009        // BEFORE any comparison against a local one. Without this a peer can2010        // send a far-future modifiedAt and permanently pin its version of an2011        // entry against every honest local edit. The browser clamps in the2012        // matching place (vault.ts applyMergedRemoteEntries).2013        val mergeNow = System.currentTimeMillis()2014        val clamped = remoteEntries.map { entry ->2015            entry.copy(2016                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2017                deletedAt = entry.deletedAt?.let {2018                    SyncLimits.clampSyncTimestamp(it, mergeNow)2019                },2020            )2021        }2022        val tombMap = remoteTombstones.associate {2023            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2024        }2025        // H-03: build the new entryList under stateLock so a concurrent lock()2026        // cannot zero derivedKey/entryList between the byId read and the merge.2027        val applied = mutateCurrentSession(epoch) {2028            var n = 02029            val byId = entryList.associateBy { it.id }.toMutableMap()20302031            for (remote in clamped) {2032                val tomb = tombMap[remote.id]2033                if (tomb != null && tomb >= remote.modifiedAt) continue2034                val local = byId[remote.id]2035                if (local == null) {2036                    entryList.add(remote); byId[remote.id] = remote; n++2037                } else if (remote.modifiedAt > local.modifiedAt) {2038                    val i = entryList.indexOfFirst { it.id == local.id }2039                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2040                }2041            }20422043            for ((id, deletedAt) in tombMap) {2044                val local = byId[id] ?: continue2045                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2046                if (deletedAt < local.modifiedAt) continue2047                val i = entryList.indexOfFirst { it.id == id }2048                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2049            }2050            n2051        } ?: return@withLock 020522053        if (applied > 0) {2054            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2055            mutateIfCurrentEpoch(epoch) { publish() }2056        }2057        applied2058    }20592060    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2061        // Require a fully live session before exposing a settings mutation.  The2062        // epoch is captured with the read so a lock/new-session boundary cannot2063        // let stale work publish into the replacement session.2064        val epoch = requireUnlocked()2065        val prior = mutateCurrentSession(epoch) {2066            val old = _settings.value2067            _settings.value = settings2068            old2069        } ?: throw IllegalStateException("vault_locked")20702071        val committed = try {2072            beforeDurableCommit()2073            // The durable write is inside the same epoch fence as the in-memory2074            // publication.  If lock() won first, this returns null and no stale2075            // settings reach storage or the public StateFlow.2076            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2077        } catch (_: Exception) {2078            false2079        }2080        if (!committed) {2081            // A live-session storage failure rolls back the public value.  A2082            // stale epoch is already reset by lock()/the new session and must2083            // not be allowed to overwrite that safe state.2084            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2085            throw IllegalStateException("settings_write_failed")2086        }2087    }20882089    // ---- Recovery words (vault metadata) ----------------------------------20902091    /** True once the user has confirmed their recovery words for this vault. */2092    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20932094    /**2095     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2096     * Persisted without bumping the version — recovery metadata is local-only and2097     * is not an entry mutation paired devices should sync against.2098     */2099    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2100        val epoch = requireUnlocked()2101        // H-03: hash + escrow-related state fields set under stateLock so a2102        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2103        // M-04: the flags have to be live before the escrows are built — both seal2104        // currentPayload() — so capture the prior pair and put it back if the write2105        // never lands. Otherwise the session reports words set against a slot A2106        // that still holds no escrow, and the recovery screen never offers a retry.2107        val prior = mutateCurrentSession(epoch) {2108            val priorPair = recoveryWordHash to recoveryWordsSet2109            recoveryWordHash = recoveryWordsHash(words)2110            recoveryWordsSet = true2111            priorPair2112        } ?: return@withLock false2113        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2114        // (cross-platform recovery of the sync root key) and Android's whole-payload2115        // escrow (offline forgot-password restore of entries). Both derive their key2116        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2117        val persisted = runCatching {2118            persistSlotAWithEscrows(2119                buildRecoveryEscrow(words),2120                buildRootKeyEscrow(words),2121                expectedEpoch = epoch,2122            )2123        }.getOrDefault(false)2124        if (!persisted) {2125            mutateIfCurrentEpoch(epoch) {2126                recoveryWordHash = prior.first2127                recoveryWordsSet = prior.second2128            }2129        }2130        persisted2131    }21322133    /** Verify entered words against the stored recovery hash. */2134    fun verifyRecoveryWords(words: List<String>): Boolean {2135        return mutateState {2136            val stored = recoveryWordHash ?: return@mutateState false2137            stored == recoveryWordsHash(words)2138        }2139    }21402141    // ---- Recovery escrow (forgot-password) --------------------------------21422143    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2144    fun hasRecoveryEscrow(): Boolean =2145        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21462147    /**2148     * One-time self-heal: if this vault has a root key but no recovery escrow2149     * on disk (adopted vaults created before the escrow fix), build and persist2150     * it now. Called on every successful unlock; no-ops once the escrow exists.2151     */2152    private fun ensureRecoveryEscrow() {2153        if (hasRecoveryEscrow()) return2154        val rk = mutateState { rootKey?.copyOf() } ?: return2155        runCatching {2156            val words = rootKeyToWords(rk)2157            val escrow = buildRecoveryEscrow(words)2158            val rkEscrow = buildRootKeyEscrow(words)2159            if (escrow != null || rkEscrow != null) {2160                val prior = store.loadSlotA() ?: return@runCatching2161                store.writeSlotA(prior.copy(2162                    recoveryEscrow = escrow ?: prior.recoveryEscrow,2163                    rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2164                ))2165                mutateState {2166                    recoveryWordHash = recoveryWordsHash(words)2167                    recoveryWordsSet = true2168                }2169            }2170        }.also { VaultCrypto.zero(rk) }2171    }21722173    /**2174     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2175     * the master password. On success the slot-A plaintext sealed in the escrow is2176     * adopted as the live session (entries, rootKey, settings), but no Argon2id2177     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2178     * new password and finish unlocking. Returns false when there is no escrow, the2179     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2180     *2181     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2182     * detached vault key to return here — slot A is wrapped under the rotating2183     * Argon2id key — so this opens the escrowed payload directly.2184     */2185    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2186        val expectedEpoch = currentEpoch()2187        val slotA = store.loadSlotA() ?: return false2188        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21892190        // Prefer Android's whole-payload escrow: a full offline restore of entries,2191        // root key, and settings. Present for Android-created vaults.2192        val fullEscrow = slotA.recoveryEscrow2193        if (fullEscrow != null) {2194            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2195            if (material != null) {2196                var keyBytes: ByteArray? = null2197                var plaintext: ByteArray? = null2198                var candidateRoot: ByteArray? = null2199                var stateCommitStarted = false2200                try {2201                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2202                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2203                    val payload = VaultCodec.decode(plaintext)2204                    // Stage every fallible operation before touching live session2205                    // fields.  In particular, a settings/provider/decode failure2206                    // cannot leave entries, flags, or a root key resident while the2207                    // public session remains locked.2208                    val candidateSettings = store.loadSettings()2209                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2210                        val decodedRoot = unb64(payload.rootKeyB64)2211                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2212                            // Do not let a malformed authenticated payload abandon2213                            // this decoded root-key candidate before the outer2214                            // finally block owns it.2215                            VaultCrypto.zero(decodedRoot)2216                            throw IllegalArgumentException("invalid recovery root key")2217                        }2218                        decodedRoot2219                    } else {2220                        generateRecoverableRootKey()2221                    }2222                    stateCommitStarted = true2223                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2224                        clearSessionStateLocked()2225                        entryList = payload.entries.toMutableList()2226                        duressConfigured = payload.duressConfigured2227                        recoveryWordHash = payload.recoveryWordHash2228                        recoveryWordsSet = payload.recoveryWordsSet2229                        rootKey = candidateRoot2230                        candidateRoot = null2231                        _settings.value = candidateSettings2232                        publish()2233                        // This is the authenticated recovery half-unlock boundary:2234                        // only set it after every candidate field is installed.2235                        sessionDataAvailable = true2236                        _isUnlocked.value = false2237                        true2238                    }2239                    if (adopted == true) return@withLock true2240                } catch (_: Exception) {2241                    // Wrong words, malformed payloads, provider failures, and2242                    // settings failures all fall through to the browser-compatible2243                    // root-key escrow.  If the state commit had started, invalidate2244                    // that epoch so no partial candidate survives.2245                    if (stateCommitStarted) {2246                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2247                    }2248                } finally {2249                    plaintext?.let(VaultCrypto::zero)2250                    VaultCrypto.zero(material)2251                    keyBytes?.let(VaultCrypto::zero)2252                    candidateRoot?.let(VaultCrypto::zero)2253                }2254            }2255        }22562257        // Browser-format vault (only the root-key escrow): recover the 32-byte root2258        // key (sync identity) and reset the password; entries return via sync. This2259        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2260        val rootEscrow = slotA.rootKeyEscrow2261        if (rootEscrow != null) {2262            var candidateRoot: ByteArray? = null2263            var stateCommitStarted = false2264            try {2265                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2266                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2267                val candidateSettings = store.loadSettings()2268                stateCommitStarted = true2269                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2270                    clearSessionStateLocked()2271                    entryList = mutableListOf()2272                    duressConfigured = false2273                    recoveryWordHash = recoveryWordsHash(words)2274                    recoveryWordsSet = true2275                    rootKey = candidateRoot2276                    candidateRoot = null2277                    _settings.value = candidateSettings2278                    publish()2279                    sessionDataAvailable = true2280                    _isUnlocked.value = false2281                    true2282                }2283                if (adopted == true) return@withLock true2284            } catch (_: Exception) {2285                if (stateCommitStarted) {2286                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2287                }2288            } finally {2289                candidateRoot?.let(VaultCrypto::zero)2290            }2291        }2292        return false2293    }22942295    /**2296     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2297     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2298     * words, mark the session fully unlocked, and publish. Returns false if called2299     * without a prior successful recovery unlock (no in-memory root key).2300     *2301     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2302     * into one step, since there is no detached vault key here.2303     */2304    suspend fun resetPasswordWithRecoveryWords(2305        words: List<String>,2306        newPassword: String,2307    ): Boolean = mutex.withLock {2308        val expectedEpoch = currentEpoch()2309        // H-03: read rootKey + entryList under stateLock before computing the new2310        // plaintext, then re-publish + swap the derived key under stateLock so a2311        // concurrent lock() cannot straddle the two fences.2312        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2313        val priorA = store.loadSlotA() ?: return false2314        var newKey: ByteArray? = null2315        var plaintext: ByteArray? = null2316        return try {2317            val newSalt = VaultCrypto.generateSalt()2318            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2319            plaintext = VaultCodec.encode(currentPayload())2320            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2321            // Rebuild both escrows against the recovered session so their salts rotate2322            // with the password reset; fall back to the prior escrows if rebuild fails2323            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2324            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2325            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2326            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2327            val next = StoredVault(2328                version = priorA.version,2329                salt = b64(newSalt),2330                blob = blob,2331                recoveryEscrow = freshRecovery,2332                rootKeyEscrow = freshRootKey,2333            )2334            // H-01: see changePassword — both rotations share the same2335            // commit-or-keep-old-key contract so a partial write can't strand2336            // the session on a key that no longer matches disk.2337            val priorB = store.loadSlotB()2338            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2339            // H-03 (P1-2 codex): zero the old live key and install the new one2340            // inside the same stateLock critical section so a concurrent lock()2341            // can't observe the in-between.2342            if (mutateIfCurrentEpoch(expectedEpoch) {2343                derivedKey?.let(VaultCrypto::zero)2344                derivedKey = newKey; newKey = null2345                publish()2346                _isUnlocked.value = true2347            } == null) return false2348            true2349        } catch (_: Exception) {2350            false2351        } finally {2352            newKey?.let(VaultCrypto::zero)2353            plaintext?.let(VaultCrypto::zero)2354        }2355    }23562357    /**2358     * Seal the current slot-A plaintext under a key derived from the 12 BIP392359     * recovery [words] (Android offline-restore escrow). IKM is the browser's2360     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2361     * Returns null for a checksum-invalid phrase.2362     */2363    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2364        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2365        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2366        var keyBytes: ByteArray? = null2367        var plaintext: ByteArray? = null2368        return try {2369            val salt = VaultCrypto.generateSalt()2370            val recoveryKey = deriveRecoveryKey(material, salt)2371            keyBytes = recoveryKey.encoded2372            plaintext = VaultCodec.encode(currentPayload())2373            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2374            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2375        } finally {2376            VaultCrypto.zero(material)2377            keyBytes?.let(VaultCrypto::zero)2378            plaintext?.let(VaultCrypto::zero)2379        }2380    }23812382    /**2383     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2384     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2385     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2386     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2387     * the same root key on the browser. Returns null with no in-memory root key or2388     * a checksum-invalid phrase.2389     */2390    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2391        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2392        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2393        val rk = mutateState { rootKey?.copyOf() } ?: run {2394            VaultCrypto.zero(material)2395            return null2396        }2397        var keyBytes: ByteArray? = null2398        var plaintext: ByteArray? = null2399        return try {2400            val salt = VaultCrypto.generateSalt()2401            val recoveryKey = deriveRecoveryKey(material, salt)2402            keyBytes = recoveryKey.encoded2403            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2404            // combined = nonce(12) || ciphertext || tag(16)2405            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2406            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2407            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2408            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2409            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2410            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2411        } finally {2412            VaultCrypto.zero(rk)2413            VaultCrypto.zero(material)2414            keyBytes?.let(VaultCrypto::zero)2415            plaintext?.let(VaultCrypto::zero)2416        }2417    }24182419    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2420    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2421        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2422        var keyBytes: ByteArray? = null2423        var pt: ByteArray? = null2424        return try {2425            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2426            keyBytes = recoveryKey.encoded2427            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2428            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2429            hexToRootKey(String(pt, Charsets.US_ASCII))2430        } catch (_: Exception) {2431            null2432        } finally {2433            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2434            // IKM-equivalent of the vault identity. material+keyBytes were2435            // already zeroed; pt was leaking.2436            VaultCrypto.zero(material)2437            keyBytes?.let(VaultCrypto::zero)2438            pt?.let(VaultCrypto::zero)2439        }2440    }24412442    /**2443     * Re-seal slot A from the current in-memory state under the live key, carrying2444     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2445     * in decoy mode (recovery is never offered for the decoy) and persists without2446     * a version bump, like the recovery-words metadata write it replaces.2447     */2448    private fun persistSlotAWithEscrows(2449        recoveryEscrow: RecoveryEscrow?,2450        rootKeyEscrow: RootKeyEscrow?,2451        expectedEpoch: Long? = null,2452    ): Boolean {2453        val snapshot = snapshotForPersist(expectedEpoch)2454            ?: throw IllegalStateException("vault_locked_or_stale")2455        if (snapshot.decoy) {2456            VaultCrypto.zero(snapshot.key)2457            return false2458        }2459        try {2460            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2461            val plaintext = VaultCodec.encode(snapshot.payload)2462            val blob = try {2463                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2464            } finally {2465                // The escrow path used to drop this reference before zeroing it;2466                // keep it live through the fill on both success and failure.2467                VaultCrypto.zero(plaintext)2468            }2469            val next = prior.copy(2470                blob = blob,2471                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2472                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2473            )2474            beforeDurableCommit()2475            return withEpochCommit(snapshot.epoch) {2476                if (!store.writeSlotA(next)) return@withEpochCommit false2477                try {2478                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2479                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2480                    // the duress slot too. A real decoy is left alone; a fake is2481                    // re-sized to slot A's new ciphertext length.2482                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2483                } catch (e: Exception) {2484                    store.writeSlotA(prior)2485                    throw e2486                }2487                true2488            } ?: false2489        } finally {2490            VaultCrypto.zero(snapshot.key)2491        }2492    }24932494    // ---- Internals --------------------------------------------------------24952496    private fun requireUnlocked(): Long {2497        // H-03: read derivedKey atomically with [lock]'s reset; without it,2498        // [lock] from a background lifecycle event can run between this read2499        // and the caller's first state touch.2500        stateLock.lock()2501        try {2502            if (derivedKey == null) throw IllegalStateException("vault_locked")2503            return sessionEpoch2504        } finally {2505            stateLock.unlock()2506        }2507    }25082509    /** A locked or half-adopted session must not expose secret-bearing fields. */2510    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25112512    private fun countDuplicateEntriesLocked(): Int {2513        val seen = mutableMapOf<String, VaultEntry>()2514        var duplicates = 02515        for (entry in entryList.filter { it.deletedAt == null }) {2516            val key = entry.duplicateKey()2517            val existing = seen[key]2518            if (existing == null) {2519                seen[key] = entry2520            } else {2521                duplicates++2522                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2523                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2524                if (entryStamp > existingStamp) seen[key] = entry2525            }2526        }2527        return duplicates2528    }25292530    private fun VaultEntry.duplicateKey(): String {2531        if (type == "note") return "note|${title.lowercase()}"2532        val passkeyData = passkey2533        if (passkeyData != null) {2534            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2535        }2536        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2537    }25382539    /**2540     * Capture the current slot pair (and metadata) into the history list.2541     * Returns true when the history write landed, false when it didn't — callers2542     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2543     * must check the result and roll back if it does not.2544     */2545    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2546        val epoch = expectedEpoch ?: currentEpoch()2547        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2548        val slotA = store.loadSlotA() ?: return false2549        val vaultA = VaultCodec.encodeSlot(slotA)2550        val vaultB = store.loadSlotB() ?: slotA.blob2551        // H-04: capture the duress slot so a restore preserves the at-rest2552        // triad, not just the A/B pair. The duress slot is always present2553        // (real or fake), so loadDecoySlot() returning null is a logic error2554        // — fall through to a fake duress in that case rather than silently2555        // losing the record from the snapshot.2556        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2557            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162558            val fakeBlob = ByteArray(target).also(rng::nextBytes)2559            val fakeSalt = VaultCrypto.generateSalt()2560            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2561        }2562        val history = store.loadVaultHistory()2563        val latest = history.snapshots.firstOrNull()2564        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2565        val entryCount = mutateIfCurrentEpoch(epoch) {2566            if (derivedKey != null && !decoyMode) {2567                bucketEntryCount(entryList.count { it.deletedAt == null })2568            } else {2569                null2570            }2571        } ?: return false2572        return try {2573            val snapshot = VaultSnapshot(2574                id = UUID.randomUUID().toString(),2575                savedAt = System.currentTimeMillis(),2576                reason = reason,2577                vault_a = vaultA,2578                vault_b = vaultB,2579                vault_d = vaultD,2580                entryCount = entryCount,2581            )2582            // P1-4 (codex): return the history-write boolean to callers so a2583            // false return no longer leaves the snapshot out of history while2584            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2585            withEpochCommit(epoch) {2586                store.writeVaultHistory(2587                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2588                )2589            } ?: false2590        } catch (_: Exception) {2591            false2592        }2593    }25942595    private fun bucketEntryCount(n: Int): Int = when {2596        n <= 0 -> 02597        n <= 10 -> 102598        n <= 25 -> 252599        n <= 50 -> 502600        n <= 100 -> 1002601        n <= 250 -> 2502602        n <= 500 -> 5002603        else -> ((n + 499) / 500) * 5002604    }26052606    /**2607     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2608     * so it is stored explicitly and survives the sync wire format rather than being2609     * re-derived at render time. Non-card entries pass through unchanged.2610     *2611     * M-03: never overwrite a stored last4 with null. A card kept only as last42612     * (e.g. after a PeachScript import that had no PAN) must keep that value;2613     * re-deriving from an absent cardNumber would silently zero the field.2614     */2615    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2616        if (!entry.isCard()) return entry2617        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2618        val derived = if (digits.length >= 4) digits.takeLast(4) else null2619        return entry.copy(last4 = derived ?: entry.last4)2620    }26212622    /**2623     * On unlock, hard-delete trashed entries older than the retention window.2624     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2625     * (the app's "Never" option), so nothing is purged. Persists without a2626     * version bump and without touching the publish state (caller publishes).2627     */2628    private fun autoPurgeExpiredTrash() {2629        val days = _settings.value.trashRetentionDays ?: return2630        if (days <= 0) return2631        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2632        val before = entryList.size2633        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2634        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2635    }26362637    /** The slot-A plaintext envelope for the current in-memory session. */2638    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }26392640    private fun currentPayloadLocked(): VaultPayload =2641        VaultPayload(2642            entries = entryList.toList(),2643            duressConfigured = duressConfigured,2644            rootKeyB64 = rootKey?.let(::b64) ?: "",2645            recoveryWordHash = recoveryWordHash,2646            recoveryWordsSet = recoveryWordsSet,2647        )26482649    /**2650     * Adopt the payload's root key, lazily minting one for legacy payloads.2651     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2652     * a root key is not a mutation paired devices should sync against.2653     */2654    private fun ensureRootKey(payload: VaultPayload) {2655        if (payload.rootKeyB64.isNotBlank()) {2656            rootKey = unb64(payload.rootKeyB64)2657            return2658        }2659        rootKey = generateRecoverableRootKey()2660        runCatching { persistSlotA(bumpVersion = false) }2661    }26622663    private fun publish() {2664        _entries.value = entryList.filter { it.deletedAt == null }2665    }26662667    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2668        var plaintext = VaultCodec.encode(currentPayload())2669        return try {2670            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2671            StoredVault(version = 1, salt = b64(salt), blob = blob)2672        } finally {2673            // M-05: full vault plaintext is allocated here once per createVault.2674            // Zeroing in finally keeps the transient copy off the heap after the2675            // caller either succeeds or rolls back via lock().2676            VaultCrypto.zero(plaintext)2677        }2678    }26792680    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2681        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2682        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2683        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2684        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2685        val noise = ByteArray(approxBytes).also(rng::nextBytes)2686        return store.writeSlotB(b64(noise))2687    }26882689    /** H-04: the ciphertext length slot A is using right now, so the duress2690     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2691     *  length equals ciphertext length, so this also matches slot A's plaintext. */2692    private fun matchingCiphertextLength(slotA: StoredVault): Int =2693        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26942695    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2696     *  bytes. Replaces a real decoy when the user clears duress, and is the2697     *  initial duress slot when no decoy is configured — so the record's2698     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2699     *  matches slot A's blob length. */2700    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2701        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162702        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2703        val fakeSalt = VaultCrypto.generateSalt()2704        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2705    }27062707    /** H-04: keep the duress record presence- and size-indistinguishable from2708     *  slot A on every persist. A real decoy is left alone (it was padded at2709     *  setDuressPassphrase time, and we don't have the duress key here to2710     *  re-pad on entry growth — that's an accepted residual tell, see the2711     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2712     *  A's size. */2713    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2714        val isConfigured = mutateState { duressConfigured }2715        return if (isConfigured) true else writeFakeDuressSlot(slotA)2716    }27172718    /**2719     * H-04: convert a browser-shaped duress slot to Android StoredVault2720     * without decrypting. The browser seals slot "b" under the duress2721     * passphrase; decrypting with the main password silently drops it.2722     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2723     * the salt from hex to base64.2724     */2725    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2726        val salt = unhex(b.salt)2727        val nonce = unhex(b.nonce)2728        val ct = unhex(b.ciphertext)2729        val tag = unhex(b.tag)2730        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2731            System.arraycopy(nonce, 0, it, 0, nonce.size)2732            System.arraycopy(ct, 0, it, nonce.size, ct.size)2733            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2734        }2735        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2736    }27372738    /** Re-seal slot A from the current in-memory state under the live key. */2739    private fun persistSlotA(2740        bumpVersion: Boolean,2741        snapshotReason: String? = "local_save",2742        expectedEpoch: Long? = null,2743    ): Boolean {2744        val snapshot = snapshotForPersist(expectedEpoch)2745            ?: throw IllegalStateException("vault_locked_or_stale")2746        return try {2747            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2748            if (snapshot.decoy) {2749                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2750                val plaintext = VaultCodec.encode(snapshot.payload)2751                val blob = try {2752                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2753                } finally {2754                    // Keep the last reference alive until the zero, including the2755                    // successful encrypt path (M-05).2756                    VaultCrypto.zero(plaintext)2757                }2758                beforeDurableCommit()2759                return withEpochCommit(snapshot.epoch) {2760                    store.writeDecoySlot(prior.copy(blob = blob))2761                } ?: false2762            }2763            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2764            // H-01: captured BEFORE the noise write so a stamp failure can restore2765            // the prior slot B alongside the prior slot A. Slot B is always2766            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2767            // leaves (new A, new B, old version) on disk — partial commit.2768            val priorB = store.loadSlotB()2769            beforeDurableCommit()2770            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2771            val plaintext = VaultCodec.encode(snapshot.payload)2772            val blob = try {2773                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2774            } finally {2775                // Do not drop [plaintext] before fill(0): the serialized vault2776                // contains every entry password and is the primary M-05 target.2777                VaultCrypto.zero(plaintext)2778            }2779            val next = prior.copy(blob = blob)2780            // The epoch check and the entire A/B/D/version commit share one brief2781            // stateLock critical section. lock() either wins before this block or2782            // waits until the already-committed write is complete; it can never2783            // clear the session between the check and the durable write.2784            withEpochCommit(snapshot.epoch) {2785                if (!store.writeSlotA(next)) return@withEpochCommit false2786                try {2787                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2788                    // H-04: duress slot stays present (real one untouched, fake one2789                    // re-padded to slot A's current size on every persist).2790                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2791                    if (bumpVersion) store.stampLocalMutation()2792                } catch (e: Exception) {2793                    store.writeSlotA(prior)2794                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2795                    throw e2796                }2797                true2798            } ?: false2799        } finally {2800            // Snapshot keys are copies, so lock() may zero the live key while this2801            // operation is still deriving/encrypting; always scrub our copy too.2802            VaultCrypto.zero(snapshot.key)2803        }2804    }28052806    /**2807     * H-01: durably commit a slot-A rotation by writing slot A, its2808     * size-matched noise slot B, and the local-mutation stamp in order. Any2809     * failure rolls the previously-committed steps back so the at-rest pair2810     * matches what was there before the call. Returns true only when every2811     * step succeeded — callers must NOT swap their session key on false.2812     */2813    private fun commitVaultRotation(2814        priorA: StoredVault,2815        priorB: String?,2816        nextA: StoredVault,2817        expectedEpoch: Long,2818    ): Boolean {2819        beforeDurableCommit()2820        return withEpochCommit(expectedEpoch) {2821            if (!store.writeSlotA(nextA)) return@withEpochCommit false2822            // P1-1 (codex): the noise write's boolean AND any thrown exception from2823            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2824            // escaped the helper and left slot A committed.2825            try {2826                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2827                // H-04: a password rotation changes slot A's plaintext size (new2828                // salt → re-encrypted blob), so the duress slot must be re-padded2829                // alongside. writeCompanionDuressSlot leaves a real decoy alone2830                // and re-emits a fake of slot A's size otherwise.2831                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2832                store.stampLocalMutation()2833            } catch (_: Exception) {2834                rollbackRotation(priorA, priorB)2835                return@withEpochCommit false2836            }2837            true2838        } ?: false2839    }28402841    /**2842     * Best-effort undo of the writes [commitVaultRotation] may have committed.2843     * Each step runs independently — a failed rollback must not mask the2844     * original commit failure. Log is a throwing stub off-device.2845     */2846    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2847        val failures = mutableListOf<String>()2848        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2849            .onFailure { failures += "writeSlotA: $it" }2850        if (priorB != null) {2851            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2852                .onFailure { failures += "writeSlotB: $it" }2853        }2854        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2855    }28562857    /** Persist a local mutation, rolling the in-memory list back on failure. */2858    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2859        try {2860            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2861                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2862                throw IllegalStateException("storage_write_failed")2863            }2864        } catch (e: Exception) {2865            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2866            throw e2867        }2868        // A lock can win in the small interval after the durable commit and2869        // before publication. The epoch check prevents pre-lock entries from2870        // being republished into a newly locked (or newly unlocked) session.2871        mutateIfCurrentEpoch(expectedEpoch) { publish() }2872    }2873}