VaultRepository.kt

140.7 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 com.peachpasswords.sync.LockedSyncSpoolHooks17import kotlinx.serialization.json.JsonElement18import kotlinx.serialization.json.JsonNull19import kotlinx.serialization.json.JsonObject20import kotlinx.serialization.json.JsonPrimitive21import java.util.Base6422import java.util.UUID23import java.util.concurrent.locks.ReentrantLock24import javax.inject.Inject25import javax.inject.Singleton2627/** Lenient parser for the Codex vault-blob wrapper only. */28private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2930private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)31private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)3233/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */34private const val PASSWORD_HISTORY_MAX = 5035private const val VAULT_HISTORY_MAX = 53637// How long the transient-background-lock suppression stays valid. If the38// launched system Activity never reports the expected lifecycle stop (picker39// crashed, user backed out into another app, OEM killed the picker process),40// the flag expires and the next genuine background event locks the vault41// rather than consuming a stale suppression.42private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4344sealed interface UnlockResult {45    data object Success : UnlockResult46    data object WrongPassword : UnlockResult47    data class Error(val reason: String) : UnlockResult48}4950sealed interface CreateVaultResult {51    data object Success : CreateVaultResult52    data class Error(val reason: String) : CreateVaultResult53}5455sealed interface AdoptVaultResult {56    data class Success(val vaultId: String) : AdoptVaultResult57    data class Error(val reason: String) : AdoptVaultResult58}5960/**61 * In-memory vault session backed by [VaultStore]. Faithful port of the browser62 * `vault.ts` semantics that matter for correctness:63 *64 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched65 *    noise blob so a fresh install shows two similarly-shaped records.66 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a67 *    deletion always wins last-write-wins against an in-flight edit.68 *  - Local writes bump the version counter; sync-applied writes do NOT (so a69 *    sync round never cascades into another outbound sync).70 *  - add/update/delete roll back in-memory state if the disk write fails.71 *  - destroyVault never touches the Pro license (separate store).72 */73@Singleton74class VaultRepository @Inject constructor(75    private val store: VaultStorage,76) {77    private val mutex = Mutex()78    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend79    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync80    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so81    // lock() can serialize against an in-flight suspend op without blocking on82    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and83    // take [stateLock] only around the state-write portions. Acquisition order84    // is always mutex → stateLock.85    private val stateLock = ReentrantLock()86    private val rng = SecureRandom()8788    /**89     * Monotonic session generation.  [lock] advances this before clearing any90     * secret state; work that captured the previous generation is therefore91     * stale even if it is still running its (non-suspending) storage step.92     */93    private var sessionEpoch: Long = 0L9495    private fun nextSessionEpoch(epoch: Long): Long =96        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9798    /** Test seam for deterministic "lock while waiting to commit" races. */99    @VisibleForTesting100    internal var beforeDurableCommit: () -> Unit = {}101102    /** Test seam after a failed Codex rollback, while the epoch fence is held. */103    @VisibleForTesting104    internal var afterCodexRollback: () -> Unit = {}105106    /**107     * Run [block] with [stateLock] held. Used to fence the small state-write108     * portions of suspend operations against [lock] and [requireUnlocked]109     * racing from another thread.110     */111    private inline fun <T> mutateState(block: () -> T): T {112        stateLock.lock()113        return try { block() } finally { stateLock.unlock() }114    }115116    /** Run a durable commit only while the session generation is still live. */117    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {118        stateLock.lock()119        return try {120            if (sessionEpoch != epoch) null else block()121        } finally {122            stateLock.unlock()123        }124    }125126    /** Test seam for a caller that must stage work before the epoch fence. */127    @VisibleForTesting128    internal var beforeSessionEpochCommit: () -> Unit = {}129130    /**131     * Test seam for the narrower H-03 race: a suspend mutation has authenticated132     * the session and staged its candidate state, but has not yet entered the133     * epoch-fenced in-memory write. Production callers leave this as a no-op.134     */135    @VisibleForTesting136    internal var beforeSessionStateMutation: () -> Unit = {}137138    /**139     * Run an external session-bound commit while the repository's epoch fence is140     * held. The callback is deliberately non-suspending: [lock] cannot advance141     * the epoch until the whole external batch has reached its linearization142     * point.143     */144    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {145        beforeSessionEpochCommit()146        return withEpochCommit(epoch, block)147    }148149    private fun currentEpoch(): Long = mutateState { sessionEpoch }150151    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =152        mutateState { if (sessionEpoch == epoch) block() else null }153154    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */155    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {156        beforeSessionStateMutation()157        return mutateIfCurrentEpoch(epoch, block)158    }159160    private data class PersistSnapshot(161        val epoch: Long,162        val key: ByteArray,163        val payload: VaultPayload,164        val decoy: Boolean,165    )166167    /** Copy all session material while fenced; callers own and must zero [key]. */168    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {169        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null170        val liveKey = derivedKey ?: return@mutateState null171        PersistSnapshot(172            epoch = sessionEpoch,173            key = liveKey.copyOf(),174            payload = currentPayloadLocked(),175            decoy = decoyMode,176        )177    }178179    private var derivedKey: ByteArray? = null180    private var rootKey: ByteArray? = null181    private var entryList: MutableList<VaultEntry> = mutableListOf()182    private var duressConfigured: Boolean = false183    private var recoveryWordHash: String? = null184    private var recoveryWordsSet: Boolean = false185    /** True when an authenticated vault payload is available for local reads. */186    private var sessionDataAvailable: Boolean = false187    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */188    private var decoyMode: Boolean = false189190    private val _isUnlocked = MutableStateFlow(false)191    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()192193194    /**195     * UI-only gate for sync-retained background mode. The vault stays unlocked196     * in memory (sync can proceed) but the UI shows the lock screen until the197     * user authenticates on resume. Cleared by a full [lock] or an explicit198     * [clearUiGate] from the lock screen.199     */200    private val _uiGateLocked = MutableStateFlow(false)201    val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()202203    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())204    /** Active (non-deleted) entryList. */205    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()206207    // Settings live in plain prefs, not inside the sealed vault — they hold no208    // secrets — so they are readable before unlock. Loading them here keeps the209    // lock screen at the user's chosen appearance instead of the defaults, which210    // otherwise snap to the real values the moment they authenticate.211    private val _settings = MutableStateFlow(store.loadSettings())212    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()213214    /** Clear every in-memory session field while [stateLock] is held. */215    private fun clearSessionStateLocked() {216        derivedKey?.let(VaultCrypto::zero)217        rootKey?.let(VaultCrypto::zero)218        derivedKey = null219        rootKey = null220        entryList = mutableListOf()221        duressConfigured = false222        recoveryWordHash = null223        recoveryWordsSet = false224        sessionDataAvailable = false225        decoyMode = false226        _settings.value = store.loadSettings()227        _entries.value = emptyList()228        _isUnlocked.value = false229    }230231    fun hasVault(): Boolean = store.hasVault()232233    // ---- Codex (printed paper backup) -------------------------------------234235    /**236     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the237     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's238     * A/B naming where slot B is the duress decoy); the noise slot is local-only239     * and never crosses devices. Restorable on any device that has the master240     * password. Null when no vault exists.241     */242    fun codexVaultBlobBase64(): String? {243        val slotA = store.loadSlotA() ?: return null244        // H-04: duress slot is always present after createVault (real or fake),245        // so the wrapper can rely on it being loadable.246        val slotD = store.loadDecoySlot() ?: return null247        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""248        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))249    }250251    /**252     * Replace the local vault from a Codex blob, then unlock with [password].253     * Writes slot A (and a size-matched noise slot B) before unlocking so the254     * restored vault is at rest exactly as a freshly-created one would be.255     */256    /** A slot is stored either as a JSON string (browser) or inline object (Android). */257    private fun JsonElement?.asSlotJson(): String? = when {258        this == null || this is JsonNull -> null259        this is JsonPrimitive && isString -> content260        else -> toString()261    }262263    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {264        val expectedEpoch = currentEpoch()265        var candidateKey: ByteArray? = null266        var plaintext: ByteArray? = null267        return try {268            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)269            // Parsed as JSON, not matched with a regex. The wrapper is270            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which271            // generates most real Codexes — writes each slot as a JSON272            // *string*, with "b" present. A regex looking for an object273            // literal under "a" at the very end of the wrapper matches274            // neither of those, so every browser-generated Codex failed here275            // with "invalid_codex_blob" after decoding perfectly.276            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject277                ?: return UnlockResult.Error("invalid_codex_blob")278            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")279            // A Codex printed from the browser carries a browser-shaped slot,280            // which is the common case — most Codexes are printed there. It is281            // translated to Android's shape (see BrowserSlot) rather than282            // rejected. A translation failure is a wrong password, not a bad283            // blob: the codes decoded fine, the password just didn't open them.284            val slotA = VaultCodec.decodeSlot(slotJson)285                ?: BrowserSlot.parse(slotJson)?.let {286                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword287                }288                ?: return UnlockResult.Error("invalid_codex_blob")289            // C-01: authenticate the candidate ENTIRELY in memory before touching290            // storage. VaultCodec.decodeSlot is structural — an Android-shaped291            // Codex parses under ANY password — so writing slot A first and292            // unlocking afterwards destroyed the device's existing vault every293            // time the user mistyped the password.294            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))295            plaintext = decryptSlotABlob(slotA, candidateKey)296            // LV-1: a Codex printed from a vault sealed before NFC normalization297            // opens only under the raw password bytes. unlockLocked used to supply298            // this fallback; authenticating up front here has to keep it, or those299            // Codexes become unrestorable.300            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {301                VaultCrypto.zero(candidateKey)302                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))303                plaintext = decryptSlotABlob(slotA, candidateKey)304            }305            if (plaintext == null) return UnlockResult.WrongPassword306            val payload = try {307                VaultCodec.decode(plaintext)308            } catch (_: Exception) {309                return UnlockResult.Error("invalid_codex_blob")310            }311312            val priorA = store.loadSlotA()313            val priorB = store.loadSlotB()314            val priorD = store.loadDecoySlot()315            // Carry the real duress slot across when the Codex has one — the316            // browser writes "b" as its duress-shaped slot, and dropping it317            // here would silently destroy a configured decoy.318            // H-04: write browser B into the duress slot (not the noise slot),319            // translating browser-shaped slots to Android's StoredVault shape.320            val codexDuressJson = wrapper["b"].asSlotJson()321            val codexDuress: StoredVault? = codexDuressJson?.let {322                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->323                    // H-04: pass-through — the browser duress slot is sealed324                    // under the duress passphrase, not the main password. Trying325                    // to decrypt with the slot-A password fails the GCM tag and326                    // silently drops a real decoy. Re-encode the browser hex327                    // fields as Android base64 salt/blob so tryUnlockDuress can328                    // still open it with the duress passphrase.329                    browserDuressPassthrough(b)330                }331            }332333            beforeDurableCommit()334            val committed = runCatching {335                withEpochCommit(expectedEpoch) {336                    try {337                        // Keep every slot write, including the inverse, under the338                        // same epoch fence. Releasing stateLock after a partial339                        // write and reacquiring it for rollback lets lock() advance340                        // the epoch in between and strand the incoming slot A.341                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")342                        // Noise slot is always size-matched to slot A — local-only,343                        // never written from a Codex.344                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")345                        // H-04: duress slot always present; real one from the Codex or346                        // a fake the size of slot A.347                        val duressOk = if (codexDuress != null) {348                            store.writeDecoySlot(codexDuress)349                        } else {350                            writeFakeDuressSlot(slotA)351                        }352                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")353                        store.stampLocalMutation()354                        true355                    } catch (_: Exception) {356                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))357                        // Keep the fence held through the final rollback358                        // observation. A concurrent lock() may queue here, but359                        // cannot invalidate the restored A/B/D triad.360                        afterCodexRollback()361                        false362                    }363                } ?: false364            }.getOrDefault(false)365            if (!committed) {366                return UnlockResult.Error("storage_write_failed")367            }368369            val ownedKey = candidateKey!!370            candidateKey = null // the session owns the key from here on371            adoptSlotA(ownedKey, payload, expectedEpoch)372        } catch (e: Exception) {373            // A raw kotlinx parse error ("Unexpected JSON token at offset N")374            // reaching the user says nothing about WHICH json failed — the375            // wrapper, or the payload decrypted out of it. Codex screens are376            // FLAG_SECURE so this can't be screenshotted either. Debug builds377            // log the structure around the failure; alphanumerics are masked so378            // no ciphertext or vault content lands in a log.379            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))380            UnlockResult.Error(e.message ?: "restore_failed")381        } finally {382            plaintext?.let(VaultCrypto::zero)383            candidateKey?.let(VaultCrypto::zero)384        }385    }386387    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */388    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =389        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()390391    /**392     * Undo a partially-committed Codex restore while [stateLock] is already held.393     * Every step is attempted independently so one failure cannot strand the394     * others. Callers that need epoch validation must enter through395     * [withEpochCommit] first; this helper deliberately does not reacquire the396     * lock, avoiding a reentrant gap between a failed write and its inverse.397     * With no prior vault the accurate inverse is having none at all, rather398     * than a half-written slot A that [hasVault] would report as a real vault.399     */400    private fun rollbackRestoreLocked(401        priorA: StoredVault?,402        priorB: String?,403        priorD: StoredVault? = null,404    ): List<String> {405        val failures = mutableListOf<String>()406        if (priorA == null) {407            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }408        } else {409            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }410                .onFailure { failures += "writeSlotA: $it" }411            if (priorB != null) {412                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }413                    .onFailure { failures += "writeSlotB: $it" }414            }415            // H-04: restore the prior duress slot — a half-committed restore that416            // left the duress record on the Codex side and slot A on the prior417            // side would put the at-rest pair in a state that never existed.418            if (priorD != null) {419                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }420                    .onFailure { failures += "writeDecoySlot: $it" }421            } else {422                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }423            }424        }425        return failures426    }427428    private fun reportRollbackFailures(failures: List<String>) {429        // Log is a throwing stub off-device, and a failed log must never mask430        // the rollback result the caller is about to report.431        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }432    }433434    /** Debug-only: structure of a failed Codex restore, with content masked. */435    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {436        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)437        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()438        val window = offset?.let {439            val from = (it - 40).coerceAtLeast(0)440            val to = (it + 40).coerceAtMost(wrapperJson.length)441            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")442        }443        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()444        val slot = wrapper?.get("a")445        buildString {446            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)447            append(" | blobBase64=").append(vaultBlobBase64.length)448            append(" wrapperChars=").append(wrapperJson.length)449            append(" wrapperParses=").append(wrapper != null)450            append(" keys=").append(wrapper?.keys)451            append(" slotAKind=").append(452                when {453                    slot == null -> "absent"454                    slot is JsonPrimitive && slot.isString -> "string"455                    slot is JsonObject -> "object:" + slot.keys456                    else -> slot::class.simpleName457                },458            )459            if (window != null) append(" masked@").append(offset).append("=").append(window)460        }461    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }462463    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {464        val expectedEpoch = currentEpoch()465        var key: ByteArray? = null466        return try {467            val salt = VaultCrypto.generateSalt()468            key = VaultCrypto.deriveKey(password, salt)469            if (mutateIfCurrentEpoch(expectedEpoch) {470                entryList = mutableListOf()471                duressConfigured = false472                recoveryWordHash = null473                recoveryWordsSet = false474                decoyMode = false475                sessionDataAvailable = false476                rootKey = generateRecoverableRootKey()477            } == null) return CreateVaultResult.Error("stale_create")478            // M-04: the fields above have to be live before sealSlotA, since it479            // seals currentPayload(). That stages a root key the session would480            // otherwise keep after a failed create — [lock] discards the whole481            // staged session on every non-success exit below.482            val slotA = sealSlotA(key!!, salt)483            // P1-3 (codex): every later step must roll the slot pair back if it484            // fails — leaving a half-built vault is worse than refusing to make it.485            beforeDurableCommit()486            val created = withEpochCommit(expectedEpoch) {487                if (!store.writeSlotA(slotA)) return@withEpochCommit false488                try {489                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")490                    // H-04: duress slot must be present from the start so its491                    // existence on disk is independent of duressConfigured.492                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")493                    store.stampLocalMutation()494                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")495                    true496                } catch (_: Exception) {497                    // Half-built vault on disk is worse than no vault — refuse to498                    // create if any step past slot A fails.499                    store.clearVault()500                    false501                }502            } ?: false503            if (!created) {504                lock()505                return CreateVaultResult.Error("storage_write_failed")506            }507            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")508            if (mutateIfCurrentEpoch(expectedEpoch) {509                derivedKey = sessionKey510                key = null511                publish()512                _isUnlocked.value = true513                sessionDataAvailable = true514                sessionEpoch = nextSessionEpoch(sessionEpoch)515            } == null) return CreateVaultResult.Error("stale_create")516            CreateVaultResult.Success517        } catch (e: Exception) {518            lock()519            CreateVaultResult.Error(e.message ?: "create_failed")520        } finally {521            key?.let(VaultCrypto::zero)522        }523    }524525    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {526        val expectedEpoch = currentEpoch()527        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")528        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")529        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {530            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")531        }532        var adoptedRootKey: ByteArray? = null533        var nextDerivedKey: ByteArray? = null534        var sessionRootKey: ByteArray? = null535        return try {536            adoptedRootKey = hexToRootKey(rootKeyHex)537            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {538                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")539            }540            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)541            val salt = VaultCrypto.generateSalt()542            nextDerivedKey = VaultCrypto.deriveKey(password, salt)543            sessionRootKey = adoptedRootKey.copyOf()544            val payload = VaultPayload(545                entries = emptyList(),546                duressConfigured = false,547                rootKeyB64 = b64(sessionRootKey),548                recoveryWordHash = null,549                recoveryWordsSet = false,550            )551            val plaintext = VaultCodec.encode(payload)552            val blob = try {553                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))554            } finally {555                VaultCrypto.zero(plaintext)556            }557            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)558            // P1-3 (codex): same partial-commit guard as createVault — any failure559            // past slot A wipes the half-built vault and refuses adoption.560            beforeDurableCommit()561            val created = withEpochCommit(expectedEpoch) {562                if (!store.writeSlotA(slotA)) return@withEpochCommit false563                try {564                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")565                    // H-04: duress slot must be present from the start so its566                    // existence on disk is independent of duressConfigured.567                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")568                    store.stampLocalMutation()569                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")570                    true571                } catch (_: Exception) {572                    store.clearVault()573                    false574                }575            } ?: false576            if (!created) return AdoptVaultResult.Error("storage_write_failed")577578            val adopted = mutateIfCurrentEpoch(expectedEpoch) {579                val previousDerivedKey = derivedKey580                val previousRootKey = rootKey581                derivedKey = nextDerivedKey582                rootKey = sessionRootKey583                nextDerivedKey = null584                sessionRootKey = null585                entryList = mutableListOf()586                duressConfigured = false587                recoveryWordHash = null588                recoveryWordsSet = false589                decoyMode = false590                sessionDataAvailable = true591                publish()592                _isUnlocked.value = true593                sessionEpoch = nextSessionEpoch(sessionEpoch)594                previousDerivedKey to previousRootKey595            }596            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")597            val (previousDerivedKey, previousRootKey) = adopted598            previousDerivedKey?.let(VaultCrypto::zero)599            previousRootKey?.let(VaultCrypto::zero)600            // Write the recovery escrow so the forgot-password flow works on601            // this device. The words are deterministic from the root key and602            // were already confirmed on the source device.603            runCatching {604                val words = rootKeyToWords(rootKey!!)605                val escrow = buildRecoveryEscrow(words)606                val rkEscrow = buildRootKeyEscrow(words)607                if (escrow != null || rkEscrow != null) {608                    val prior = store.loadSlotA()609                    if (prior != null) {610                        store.writeSlotA(prior.copy(611                            recoveryEscrow = escrow ?: prior.recoveryEscrow,612                            rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,613                        ))614                    }615                    mutateState {616                        recoveryWordHash = recoveryWordsHash(words)617                        recoveryWordsSet = true618                    }619                }620            }621            AdoptVaultResult.Success(vaultId)622        } catch (e: Exception) {623            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")624        } finally {625            adoptedRootKey?.let(VaultCrypto::zero)626            nextDerivedKey?.let(VaultCrypto::zero)627            sessionRootKey?.let(VaultCrypto::zero)628        }629    }630631    suspend fun unlock(password: String): UnlockResult = mutex.withLock {632        val result = unlockLocked(password, currentEpoch())633        if (result is UnlockResult.Success) ensureRecoveryEscrow()634        result635    }636637    /** Unlock body without acquiring the mutex — callers must already hold it. */638    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {639        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")640        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))641        val payload = try {642            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)643            try {644                VaultCodec.decode(plaintext)645            } finally {646                VaultCrypto.zero(plaintext)647            }648        } catch (_: Exception) {649            VaultCrypto.zero(key)650            // LV-1: a vault sealed before NFC normalization with a non-NFC651            // password derives a different key now. If the supplied password is652            // not already in NFC form, retry with the raw (un-normalized) bytes.653            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {654                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)655                if (legacy != null) return legacy656            }657            return UnlockResult.WrongPassword658        }659        return adoptSlotA(key, payload, expectedEpoch)660    }661662    /**663     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw664     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed665     * under the NFC-normalized key — so every future unlock takes the normal666     * path. Returns null when the raw key also fails (genuinely wrong password)667     * OR when any step of the heal commits fails (the original slot A is restored668     * so disk and session stay aligned).669     */670    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {671        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))672        var newKey: ByteArray? = null673        return try {674            val payload = try {675                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)676                try {677                    VaultCodec.decode(plaintext)678                } finally {679                    VaultCrypto.zero(plaintext)680                }681            } catch (_: Exception) {682                return null683            }684            // Heal: re-seal slot A under the NFC key (mirrors changePassword's685            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.686            val newSalt = VaultCrypto.generateSalt()687            newKey = VaultCrypto.deriveKey(password, newSalt)688            val plaintext = VaultCodec.encode(payload)689            val blob = try {690                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))691            } finally {692                VaultCrypto.zero(plaintext)693            }694            val next = StoredVault(695                version = slotA.version,696                salt = b64(newSalt),697                blob = blob,698                recoveryEscrow = slotA.recoveryEscrow,699                rootKeyEscrow = slotA.rootKeyEscrow,700            )701            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,702            // or stampLocalMutation must restore the prior slot A AND slot B so disk703            // and the eventual session key never disagree on the wrapping.704            val priorB = store.loadSlotB()705            beforeDurableCommit()706            val healed = withEpochCommit(expectedEpoch) {707                if (!store.writeSlotA(next)) {708                    false709                } else try {710                    if (!writeMatchingNoiseSlotB(next)) {711                        runCatching { store.writeSlotA(slotA) }712                        if (priorB != null) runCatching { store.writeSlotB(priorB) }713                        false714                    } else if (!writeCompanionDuressSlot(next)) {715                        runCatching { store.writeSlotA(slotA) }716                        if (priorB != null) runCatching { store.writeSlotB(priorB) }717                        false718                    } else {719                        store.stampLocalMutation()720                        true721                    }722                } catch (_: Exception) {723                    runCatching { store.writeSlotA(slotA) }724                    if (priorB != null) runCatching { store.writeSlotB(priorB) }725                    false726                }727            } ?: false728            if (healed) {729                VaultCrypto.zero(rawKey!!)730                rawKey = null731                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot732                newKey = null733                result734            } else {735                VaultCrypto.zero(newKey!!)736                newKey = null737                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key738                rawKey = null739                result740            }741        } finally {742            // Cover codec, encryption, storage, and stale-epoch failures before743            // either candidate key can be transferred into the live session.744            rawKey?.let(VaultCrypto::zero)745            newKey?.let(VaultCrypto::zero)746        }747    }748749    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */750    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {751        return try {752            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they753            // can throw after the fields above are live. There is no prior session754            // worth restoring — adoption has already replaced it — so a failed755            // adoption discards everything rather than leaving entries and keys756            // resident behind _isUnlocked=false.757            val adopted = mutateIfCurrentEpoch(expectedEpoch) {758                derivedKey = key759                decoyMode = false760                entryList = payload.entries.toMutableList()761                duressConfigured = payload.duressConfigured762                recoveryWordHash = payload.recoveryWordHash763                recoveryWordsSet = payload.recoveryWordsSet764                sessionDataAvailable = true765                _settings.value = store.loadSettings()766                ensureRootKey(payload)767                autoPurgeExpiredTrash()768                consolidatePasskeyEntries()769                publish()770                _isUnlocked.value = true771                // A successful adoption starts a new generation. A sibling772                // SlotAUnlock captured in the prior epoch will fail closed at773                // the next commit instead of replacing this session.774                sessionEpoch = nextSessionEpoch(sessionEpoch)775            }776            if (adopted == null) {777                VaultCrypto.zero(key)778                UnlockResult.Error("stale_unlock")779            } else {780                UnlockResult.Success781            }782        } catch (e: Exception) {783            // Do not call lock() here: a concurrent newer session may already784            // own this repository. Only invalidate the failed epoch's fields.785            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }786            VaultCrypto.zero(key)787            UnlockResult.Error(e.message ?: "unlock_failed")788        }789    }790791    // ---- Constant-time unlock (MF-5) --------------------------------------792793    /**794     * A slot-A unlock that has been derived but not yet adopted. Holds the live795     * Argon2id key and decoded payload so the caller can derive several candidates796     * concurrently and commit only the winner. The key is owned by this token until797     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.798     */799    class SlotAUnlock internal constructor(800        key: ByteArray,801        internal val payload: VaultPayload,802        internal val epoch: Long,803    ) {804        private var ownedKey: ByteArray? = key805        private var consumed = false806807        /** Transfer the key exactly once to the adopter. */808        internal fun takeKey(): ByteArray? = synchronized(this) {809            if (consumed) return@synchronized null810            consumed = true811            ownedKey.also { ownedKey = null }812        }813814        /** Discard an uncommitted token without touching a transferred key. */815        internal fun discard() = synchronized(this) {816            if (consumed) return@synchronized817            consumed = true818            ownedKey?.let(VaultCrypto::zero)819            ownedKey = null820        }821    }822823    /**824     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.825     * Pure computation (no mutex), so it can run concurrently with other candidate826     * derivations before the caller decides which to commit (MF-5). Returns null on827     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).828     */829    fun tryUnlockSlotA(password: String): SlotAUnlock? {830        val epoch = currentEpoch()831        val slotA = store.loadSlotA() ?: return null832        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))833        return try {834            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)835            val payload = try {836                VaultCodec.decode(plaintext)837            } finally {838                VaultCrypto.zero(plaintext)839            }840            SlotAUnlock(key, payload, epoch)841        } catch (_: Exception) {842            VaultCrypto.zero(key)843            null844        }845    }846847    /** Adopt a previously-derived [SlotAUnlock] as the live session. */848    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {849        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")850        adoptSlotA(key, unlock.payload, unlock.epoch)851    }852853    /** Zero a derived [SlotAUnlock] that will not be adopted. */854    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()855856    // ---- Duress slot B (MF-6) ---------------------------------------------857858    /**859     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key860     * and decoded decoy payload so it can be derived concurrently with slot A and861     * only committed if the duress passphrase was the one entered (MF-5/MF-6).862     */863    class DuressUnlock internal constructor(864        key: ByteArray,865        internal val payload: VaultPayload,866        internal val epoch: Long,867    ) {868        private var ownedKey: ByteArray? = key869        private var consumed = false870871        internal fun takeKey(): ByteArray? = synchronized(this) {872            if (consumed) return@synchronized null873            consumed = true874            ownedKey.also { ownedKey = null }875        }876877        internal fun discard() = synchronized(this) {878            if (consumed) return@synchronized879            consumed = true880            ownedKey?.let(VaultCrypto::zero)881            ownedKey = null882        }883    }884885    /**886     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the887     * session. Success means the entered password is the duress passphrase that keys888     * slot B — indistinguishable from the real slot A at the lock screen. Pure889     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].890     * Returns null when no duress slot exists or the password is not the duress one.891     */892    fun tryUnlockDuress(password: String): DuressUnlock? {893        val epoch = currentEpoch()894        val slot = store.loadDecoySlot() ?: return null895        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))896        return try {897            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)898            val payload = try {899                VaultCodec.decode(plaintext)900            } finally {901                VaultCrypto.zero(plaintext)902            }903            DuressUnlock(key, payload, epoch)904        } catch (_: Exception) {905            VaultCrypto.zero(key)906            null907        }908    }909910    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */911    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {912        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")913        if (currentEpoch() != unlock.epoch) {914            VaultCrypto.zero(key)915            return@withLock UnlockResult.Error("stale_unlock")916        }917        return try {918            val adopted = mutateIfCurrentEpoch(unlock.epoch) {919                derivedKey = key920                decoyMode = true921                entryList = unlock.payload.entries.toMutableList()922                duressConfigured = false923                recoveryWordHash = unlock.payload.recoveryWordHash924                recoveryWordsSet = unlock.payload.recoveryWordsSet925                sessionDataAvailable = true926                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)927                          else VaultCrypto.generateRootKey()928                _settings.value = store.loadSettings()929                publish()930                _isUnlocked.value = true931                sessionEpoch = nextSessionEpoch(sessionEpoch)932            }933            if (adopted == null) {934                VaultCrypto.zero(key)935                UnlockResult.Error("stale_unlock")936            } else {937                UnlockResult.Success938            }939        } catch (e: Exception) {940            // A payload/root/settings failure can occur after the candidate key941            // has been assigned to derivedKey. Tear down only this epoch so a942            // newer session is not disturbed, and scrub the token reference on943            // every error path.944            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }945            VaultCrypto.zero(key)946            UnlockResult.Error(e.message ?: "decoy_unlock_failed")947        }948    }949950    /** Zero a derived [DuressUnlock] that will not be adopted. */951    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()952953    /**954     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an955     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The956     * passphrase is never stored — only its ability to decrypt slot B proves it.957     * Requires the real vault to be unlocked and not itself a decoy session.958     */959    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {960        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().961        val expectedEpoch = mutateState {962            if (derivedKey != null && !decoyMode) sessionEpoch else null963        } ?: return false964        // Reject a duress passphrase that also opens the real vault: it would make the965        // lock screen ambiguous (both slots would decrypt for the same input).966        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }967        var key: ByteArray? = null968        var basePlaintext: ByteArray? = null969        return try {970            val salt = VaultCrypto.generateSalt()971            key = VaultCrypto.deriveKey(duressPassphrase, salt)972            // An empty decoy vault with its own fresh sync root key — nothing real leaks.973            val decoyRoot = VaultCrypto.generateRootKey()974            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }975            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)976            basePlaintext = VaultCodec.encode(decoyPayload)977            // H-04: pad the duress plaintext to slot A's ciphertext length so the978            // duress blob matches slot A's blob on disk. VaultCodec.decode strips979            // trailing NULs, so the extra padding survives encrypt but never980            // reaches the JSON parser.981            // This decoy plaintext length is frozen from here on: re-sealing it982            // on later growth would need the duress passphrase, which is never983            // retained. VaultStore compensates by re-padding the two slot984            // RECORDS to the longer of the two on every write, so the frozen985            // blob no longer shows up as a length difference at rest.986            val slotA = store.loadSlotA()987            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0988            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!989            val blob = try {990                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))991            } finally {992                VaultCrypto.zero(plaintext)993                // Padding creates a second array; scrub the original encoded994                // payload too rather than dropping its last reference untouched.995                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)996                basePlaintext = null997                key?.let(VaultCrypto::zero)998                key = null999            }1000            beforeDurableCommit()1001            val ok = withEpochCommit(expectedEpoch) {1002                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))1003            } ?: false1004            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {1005                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1006            } else if (ok) {1007                return false1008            }1009            ok1010        } catch (_: Exception) {1011            false1012        } finally {1013            // Cover failures before the encrypt block is entered (for example a1014            // codec/copy exception) as well as any early return after key derivation.1015            key?.let(VaultCrypto::zero)1016            basePlaintext?.let(VaultCrypto::zero)1017        }1018    }101910201021    /**1022     * Export the decoy slot as browser-shaped JSON for adoption snapshots.1023     *1024     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all1025     * lowercase hex — identical to the browser's `duress_slot` wire format — so1026     * any receiver (browser or Android) can install it via the existing1027     * passthrough. Returns null when duress is not configured, no decoy exists,1028     * or any conversion failure occurs. Never decrypts.1029     */1030    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1031        if (!duressConfigured) return null1032        val slot = store.loadDecoySlot() ?: return null1033        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1034    }10351036    /**1037     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1038     *1039     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1040     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1041     * byte arrays are zeroed in finally.1042     */1043    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1044        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1045        // serialized — kotlinx-serialization's encodeDefaults = false would1046        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1047        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1048        // silently dropping the duress slot on Android→browser adoptions.1049        var salt: ByteArray? = null1050        var blob: ByteArray? = null1051        var nonce: ByteArray? = null1052        var ctAndTag: ByteArray? = null1053        var ct: ByteArray? = null1054        var tag: ByteArray? = null1055        try {1056            salt = unb64(slot.salt)1057            blob = unb64(slot.blob)1058            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1059            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1060            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1061            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1062            val obj = JsonObject(mapOf(1063                "version" to JsonPrimitive(1),1064                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1065                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1066                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1067                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1068            ))1069            return obj.toString()1070        } finally {1071            salt?.let(VaultCrypto::zero)1072            blob?.let(VaultCrypto::zero)1073            nonce?.let(VaultCrypto::zero)1074            ctAndTag?.let(VaultCrypto::zero)1075            ct?.let(VaultCrypto::zero)1076            tag?.let(VaultCrypto::zero)1077        }1078    }10791080    /**1081     * Install a browser-shaped duress slot received through adoption.1082     *1083     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1084     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1085     * writes the result to the decoy slot. Epoch-fenced like the Codex1086     * restore path so a concurrent lock() cannot strand the write.1087     *1088     * Returns false on invalid input, no live session, or write failure.1089     */1090    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1091        val b = BrowserSlot.parse(json) ?: return false1092        var stored: StoredVault? = null1093        var salt: ByteArray? = null1094        var nonce: ByteArray? = null1095        var ct: ByteArray? = null1096        var tag: ByteArray? = null1097        var blob: ByteArray? = null1098        try {1099            salt = unhex(b.salt)1100            nonce = unhex(b.nonce)1101            ct = unhex(b.ciphertext)1102            tag = unhex(b.tag)1103            blob = ByteArray(nonce.size + ct.size + tag.size).also {1104                System.arraycopy(nonce, 0, it, 0, nonce.size)1105                System.arraycopy(ct, 0, it, nonce.size, ct.size)1106                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1107            }1108            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1109            val expectedEpoch = sessionEpoch1110            beforeDurableCommit()1111            withEpochCommit(expectedEpoch) {1112                store.writeDecoySlot(stored)1113            } ?: false1114        } catch (_: Exception) {1115            false1116        } finally {1117            salt?.let(VaultCrypto::zero)1118            nonce?.let(VaultCrypto::zero)1119            ct?.let(VaultCrypto::zero)1120            tag?.let(VaultCrypto::zero)1121            blob?.let(VaultCrypto::zero)1122        }1123    }11241125    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1126    suspend fun clearDuress(): Boolean = mutex.withLock {1127        // H-04: presence-indistinguishable — replace the real decoy with a fake1128        // blob of slot A's size rather than removing the duress record. The1129        // record stays on disk at the same length, so an at-rest observer1130        // cannot tell "duress cleared" from "no duress ever configured".1131        val expectedEpoch = currentEpoch()1132        val replaced = withEpochCommit(expectedEpoch) {1133            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1134                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1135        } ?: false1136        if (!replaced) return@withLock false1137        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1138        // duressConfigured write so a concurrent lock() cannot land between them.1139        if (!isSessionEpochCurrent(expectedEpoch)) return false1140        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1141        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1142            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1143        }1144        true1145    }11461147    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1148    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1149        var current: Throwable? = error1150        while (current != null) {1151            if (current is javax.crypto.AEADBadTagException) return true1152            // Some Android providers expose a GCM tag failure as the parent1153            // BadPaddingException rather than the AEAD-specific subtype.  A1154            // short ciphertext is an IllegalBlockSizeException and therefore1155            // remains an internal/malformed-payload Error below.1156            if (current is javax.crypto.BadPaddingException) return true1157            current = current.cause1158        }1159        return false1160    }11611162    /** Unlock with an already-derived AES-256 key (biometric path). */1163    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1164        val expectedEpoch = currentEpoch()1165        val slotA = try {1166            store.loadSlotA()1167        } catch (_: Exception) {1168            return@withLock UnlockResult.Error("unlock_key_storage_failed")1169        } ?: return@withLock UnlockResult.Error("no_vault")1170        // Keep the caller-owned key separate from the staged session key. The1171        // biometric caller always zeroes its input in a finally block; adoption1172        // must therefore own a copy and use the same all-or-nothing path as a1173        // password unlock rather than publishing a partially decoded session.1174        var stagedKey: ByteArray? = key.copyOf()1175        return try {1176            val encrypted = try {1177                unb64(slotA.blob)1178            } catch (_: Exception) {1179                return@withLock UnlockResult.Error("invalid_vault_blob")1180            }1181            // A GCM record must include nonce + authentication tag.  Providers1182            // differ on whether a shorter record is reported as BadPadding or1183            // AEADBadTag; reject it before decrypt so malformed storage cannot be1184            // misreported as a user-entered wrong key.1185            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1186                return@withLock UnlockResult.Error("invalid_vault_blob")1187            }1188            val plaintext = try {1189                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1190            } catch (e: Exception) {1191                if (isGcmAuthenticationFailure(e)) {1192                    return@withLock UnlockResult.WrongPassword1193                }1194                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1195            }1196            val payload = try {1197                VaultCodec.decode(plaintext)1198            } catch (_: Exception) {1199                return@withLock UnlockResult.Error("invalid_vault_payload")1200            } finally {1201                VaultCrypto.zero(plaintext)1202            }1203            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1204            val result = adoptSlotA(candidate, payload, expectedEpoch)1205            if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1206            result1207        } finally {1208            stagedKey?.let(VaultCrypto::zero)1209        }1210    }12111212    fun lock() {1213        // Never snapshot a mutation that has changed entryList but has not yet1214        // crossed its durable epoch fence. Successful writes refresh the spool1215        // before releasing this mutex; an in-flight writer therefore makes this1216        // lock skip the convenience refresh and fail closed to the prior cache.1217        if (mutex.tryLock()) {1218            try {1219                runCatching { LockedSyncSpoolHooks.refresh(this) }1220            } finally {1221                mutex.unlock()1222            }1223        }1224        // H-03: must observe derivedKey/entryList atomically with any in-flight1225        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1226        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1227        // guards just the brief state reset. Advancing the epoch first invalidates1228        // every operation that captured this session before it reaches a later1229        // durable commit or publication.1230        stateLock.lock()1231        try {1232            sessionEpoch = nextSessionEpoch(sessionEpoch)1233            clearSessionStateLocked()1234            _uiGateLocked.value = false1235        } finally {1236            stateLock.unlock()1237        }1238    }12391240    /**1241     * Enter sync-retained background mode: the UI shows the lock screen but the1242     * vault stays fully unlocked in memory so [PeachSyncService] can complete1243     * sync sessions. Called from [PeachApplication.onStop] when background sync1244     * is enabled. A subsequent full [lock] (auto-lock timer, manual lock)1245     * supersedes this and zeroes everything.1246     */1247    fun lockUiGate() {1248        if (_isUnlocked.value) _uiGateLocked.value = true1249    }12501251    /** Dismiss the UI gate after the user authenticates on resume. */1252    fun clearUiGate() {1253        _uiGateLocked.value = false1254    }12551256    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12571258    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1259    // is a stub off-device).1260    @VisibleForTesting1261    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12621263    /**1264     * Call immediately before launching a system Activity (a SAF file1265     * picker/save dialog, a share sheet) that briefly takes the whole process1266     * out of the foreground as an expected part of an in-app flow — not a1267     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1268     * two apart on its own (it fires for both), and without this, exporting or1269     * importing a file silently locked and wiped the vault mid-flow the moment1270     * the picker took over, before its result ever reached the app.1271     *1272     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1273     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1274     */1275    fun expectTransientBackground() {1276        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1277    }12781279    /**1280     * Consumed once by the next background-lock check; see [expectTransientBackground].1281     * Returns false (and clears the flag) if the suppression has expired or was1282     * never set — a genuine background event must always lock.1283     */1284    fun consumeTransientBackgroundSuppression(): Boolean {1285        val until = suppressNextBackgroundLockUntil1286        val active = until != 0L && nowMs() < until1287        suppressNextBackgroundLockUntil = 0L1288        return active1289    }12901291    // ---- Sync identity ----------------------------------------------------12921293    /**1294     * The vault's stable sync master key (a copy). Caller must zero it. Backs1295     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1296     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1297     */1298    /**1299     * HOLE 5 — the duress vault never syncs.1300     *1301     * [commitDuressUnlock] gives the decoy session a root key so its own codex1302     * and recovery paths work, which means (unlike the browser, where the decoy1303     * path simply has no root key) the sync surface has to refuse explicitly.1304     * Without this the decoy vault would present a second, different vault_id1305     * to a paired device, and a coerced user opening it would make sync visibly1306     * fail on their other devices — a duress indicator, which is the one thing1307     * a duress vault must never be.1308     */1309    // M-04: the "null when locked" half of the contract was documented but never1310    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1311    // [unlockWithRecoveryWords], which installs a root key with no password key1312    // yet) and in a create that failed after staging. Sync must fail closed1313    // there, so the authoritative unlocked flag gates it, not just decoyMode.1314    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)13151316    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1317        if (decoyMode || !_isUnlocked.value) null1318        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1319    }13201321    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1322        mutateState { sessionEpoch == epoch }13231324    internal fun unlockedSessionEpoch(): Long? = mutateState {1325        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1326    }13271328    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13291330    /**1331     * The current session's root key, decoy included. For local-only uses that1332     * must behave identically in a decoy session — Codex backup and recovery1333     * words — because a feature that errors under duress is itself a duress1334     * tell. In a decoy session this is the DECOY's root key (see1335     * [commitDuressUnlock]), never the real vault's, so neither surface can1336     * leak the real vault. Anything that leaves the device must use1337     * [syncMasterKey] instead.1338     */1339    fun sessionRootKey(): ByteArray? = mutateState {1340        if (!sessionDataAvailable) null else rootKey?.copyOf()1341    }13421343    /** Stable per-vault id two paired devices must share to sync. Null when1344     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1345    fun syncVaultId(): String? = mutateState {1346        if (decoyMode || !_isUnlocked.value) null1347        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1348    }13491350    /**1351     * Browser parity (MV-8): rotate the sync identity by replacing the stable1352     * root key. Entries remain under the master-password key, while sync1353     * credentials, vault_id, and recovery words all change.1354     */1355    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1356        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1357            ?: return false1358        installRootKey(generateRecoverableRootKey(), expectedEpoch)1359    }13601361    /**1362     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1363     * keeping every local entry.1364     *1365     * Sync identity (vault id, signing keys) derives from the root key, and1366     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1367     * created vaults can never reconcile. Taking the other side's root key is1368     * what makes them one vault; from the next sync on, both entry sets merge.1369     *1370     * Entries survive because they are sealed under the password-derived key,1371     * not the root key — the same property [rotateSyncIdentity] relies on.1372     *1373     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1374     * this device's previous recovery words stop working for good, and its new1375     * words are the source vault's words, because words are a deterministic1376     * encoding of the root key.1377     */1378    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1379        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1380            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1381        }1382        val expectedEpoch = mutateState {1383            when {1384                derivedKey == null -> null1385                decoyMode -> Long.MIN_VALUE1386                else -> sessionEpoch1387            }1388        }1389        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1390        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1391        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13921393        val incoming = hexToRootKey(rootKeyHex)1394        if (incoming.size != VaultCrypto.KEY_SIZE) {1395            VaultCrypto.zero(incoming)1396            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1397        }1398        // Refuse before committing rather than leave this device holding a vault1399        // whose recovery words cannot be typed back. generateRecoverableRootKey1400        // screens its own keys for this; a key arriving from a peer has not been.1401        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1402        if (!roundTrips) {1403            VaultCrypto.zero(incoming)1404            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1405        }14061407        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1408        return if (installRootKey(incoming, expectedEpoch)) {1409            AdoptVaultResult.Success(vaultId)1410        } else {1411            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1412        }1413    }14141415    /**1416     * The recovery words [rootKeyHex] will produce once joined, or null if it is1417     * not a usable root key. Lets the join flow show the user the words that are1418     * about to replace theirs *before* anything is committed.1419     */1420    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1421        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1422        val bytes = hexToRootKey(rootKeyHex)1423        return try {1424            val words = rootKeyToWords(bytes)1425            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1426        } catch (_: Exception) {1427            null1428        } finally {1429            VaultCrypto.zero(bytes)1430        }1431    }14321433    /**1434     * Swap the vault's root key, keeping every entry, and re-escrow under the1435     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1436     * [mutex].1437     */1438    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1439        // H-03: snapshot the live fields under stateLock so the success and the1440        // rollback paths both see consistent prior/new state. Writing the new1441        // rootKey/recovery fields is inside its own mutateState at the end.1442        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1443        // — holding live references would let a zero-on-failure operation also1444        // wipe the session's still-live key arrays.1445        data class Snapshot(1446            val key: ByteArray,1447            val priorRootKey: ByteArray,1448            val priorRecoveryHash: String?,1449            val priorRecoverySet: Boolean,1450        )1451        val snap = mutateIfCurrentEpoch(expectedEpoch) {1452            val k = derivedKey ?: return@mutateIfCurrentEpoch null1453            if (decoyMode) return@mutateIfCurrentEpoch null1454            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1455            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1456        } ?: run {1457            VaultCrypto.zero(nextRootKey)1458            return false1459        }1460        val priorSlot = store.loadSlotA() ?: run {1461            VaultCrypto.zero(snap.key)1462            VaultCrypto.zero(snap.priorRootKey)1463            VaultCrypto.zero(nextRootKey)1464            return false1465        }1466        val nextWords = rootKeyToWords(nextRootKey)14671468        return try {1469            beforeDurableCommit()1470            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1471            if (mutateIfCurrentEpoch(expectedEpoch) {1472                rootKey = nextRootKey1473                recoveryWordHash = recoveryWordsHash(nextWords)1474                recoveryWordsSet = false1475            } == null) throw IllegalStateException("stale_root_rotation")14761477            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1478                ?: throw IllegalStateException("recovery_escrow_failed")1479            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1480                ?: throw IllegalStateException("root_escrow_failed")1481            val plaintext = VaultCodec.encode(currentPayload())1482            val blob = try {1483                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1484            } finally {1485                VaultCrypto.zero(plaintext)1486            }1487            val nextSlot = priorSlot.copy(1488                blob = blob,1489                recoveryEscrow = nextRecoveryEscrow,1490                rootKeyEscrow = nextRootKeyEscrow,1491            )1492            val committed = withEpochCommit(expectedEpoch) {1493                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1494                try {1495                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1496                    // H-04: a root-key rotation re-seals slot A, so the duress slot1497                    // (real or fake) must be re-padded to the new ciphertext length.1498                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1499                    store.stampLocalMutation()1500                } catch (_: Exception) {1501                    store.writeSlotA(priorSlot)1502                    return@withEpochCommit false1503                }1504                true1505            } ?: false1506            if (!committed) throw IllegalStateException("storage_write_failed")1507            mutateIfCurrentEpoch(expectedEpoch) { publish() }1508            true1509        } catch (_: Exception) {1510            val restored = mutateIfCurrentEpoch(expectedEpoch) {1511                rootKey?.let(VaultCrypto::zero)1512                rootKey = snap.priorRootKey.copyOf()1513                recoveryWordHash = snap.priorRecoveryHash1514                recoveryWordsSet = snap.priorRecoverySet1515            }1516            if (restored != null) {1517                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1518            }1519            false1520        } finally {1521            VaultCrypto.zero(snap.key)1522            VaultCrypto.zero(snap.priorRootKey)1523            // On success nextRootKey is owned by rootKey. On every other path1524            // it is not, including a stale epoch after lock(); scrub it while1525            // the local reference is still available.1526            val stillOwnedBySession = mutateState {1527                sessionEpoch == expectedEpoch && rootKey === nextRootKey1528            }1529            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1530        }1531    }15321533    /**1534     * A copy of the live Argon2id-derived vault key, for biometric escrow only1535     * (wrapped under a hardware KeyStore key on biometric enable). Null when1536     * locked. Caller must [VaultCrypto.zero] it after use.1537     */1538    fun currentVaultKey(): ByteArray? = mutateState {1539        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1540    }15411542    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1543        val expectedEpoch = currentEpoch()1544        val slotA = store.loadSlotA() ?: return false1545        var oldKey: ByteArray? = null1546        var newKey: ByteArray? = null1547        var plaintext: ByteArray? = null1548        return try {1549            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1550            // Keep ownership of the decrypted vault plaintext until decoding has1551            // finished.  Passing decrypt() inline made the byte array unreachable1552            // before the finally block, so a successful or failed password change1553            // could leave the full plaintext resident in the heap.1554            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1555            val payload = try {1556                VaultCodec.decode(decrypted)1557            } finally {1558                VaultCrypto.zero(decrypted)1559            }1560            val newSalt = VaultCrypto.generateSalt()1561            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1562            // Re-seal the existing payload under the new key/salt.1563            plaintext = VaultCodec.encode(payload)1564            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1565            // The escrow seals the slot-A PLAINTEXT, which a password change does not1566            // alter — only the wrapping changes — so carry it forward; rebuilding the1567            // StoredVault from scratch would silently drop it.1568            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1569            // content that a password change does not alter — only the slot-A1570            // wrapping rotates. The root-key escrow staying stable across password1571            // changes matches the browser (`vault.ts:363-364`).1572            val next = StoredVault(1573                version = slotA.version,1574                salt = b64(newSalt),1575                blob = blob,1576                recoveryEscrow = slotA.recoveryEscrow,1577                rootKeyEscrow = slotA.rootKeyEscrow,1578            )1579            // H-01: durably commit slot A + size-matched noise + version stamp1580            // before swapping the session key. A partial commit (new A on disk,1581            // old key in memory) would have the next save encrypt under the old1582            // key while matching disk on the new one.1583            val priorB = store.loadSlotB()1584            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1585            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1586            // inside the same stateLock critical section, so a concurrent lock()1587            // can't observe the in-between (zeroed but not yet reassigned).1588            if (mutateIfCurrentEpoch(expectedEpoch) {1589                oldKey?.let(VaultCrypto::zero); oldKey = null1590                derivedKey?.let(VaultCrypto::zero)1591                derivedKey = newKey; newKey = null1592            } == null) return false1593            true1594        } catch (_: Exception) {1595            false1596        } finally {1597            oldKey?.let(VaultCrypto::zero)1598            newKey?.let(VaultCrypto::zero)1599            plaintext?.let(VaultCrypto::zero)1600        }1601    }16021603    /**1604     * Irreversible wipe. Locks the session and clears every vault key. The Pro1605     * license is in a separate store and is preserved by construction.1606     */1607    suspend fun destroyVault() = mutex.withLock {1608        lock()1609        store.clearVault()1610        store.clearDecoySlot()1611    }16121613    // ---- Entry CRUD -------------------------------------------------------16141615    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1616        val epoch = requireUnlocked()1617        val stamp = VaultCrypto.monotonicEntryTimestamp()1618        val now = System.currentTimeMillis()1619        val entry = withDerivedLast4(draft.copy(1620            id = draft.id.ifBlank { UUID.randomUUID().toString() },1621            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1622            modifiedAt = stamp,1623            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1624        ))1625        // H-03: capture rollback AND write entryList under stateLock so a1626        // concurrent lock() cannot zero the session between the read and the write.1627        val rollback = mutateCurrentSession(epoch) {1628            val r = entryList.toMutableList()1629            entryList.add(entry)1630            r1631        } ?: throw IllegalStateException("vault_locked")1632        persistOrRollback(rollback, epoch)1633        entry1634    }16351636    /**1637     * Insert many entries with a single encrypt-and-write at the end.1638     *1639     * Importing a real vault through [addEntry] was quadratic: every entry took1640     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1641     * restore did 806 full-vault writes and hung the app long enough to ANR.1642     * Rolls back to the pre-import list if the one write fails, matching1643     * [addEntry]'s all-or-nothing behaviour.1644     */1645    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1646        val epoch = requireUnlocked()1647        if (drafts.isEmpty()) return 01648        val now = System.currentTimeMillis()1649        val ready = drafts.map { draft ->1650            val stamp = VaultCrypto.monotonicEntryTimestamp()1651            withDerivedLast4(1652                draft.copy(1653                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1654                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1655                    modifiedAt = stamp,1656                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1657                ),1658            )1659        }1660        // H-03: capture rollback AND append every entry under stateLock.1661        val rollback = mutateCurrentSession(epoch) {1662            val r = entryList.toMutableList()1663            entryList.addAll(ready)1664            r1665        } ?: throw IllegalStateException("vault_locked")1666        persistOrRollback(rollback, epoch)1667        return drafts.size1668    }16691670    /**1671     * M-01: replace the whole active set with [newEntries] in one write.1672     *1673     * Import's REPLACE mode used to soft-delete every current entry one durable1674     * write at a time (swallowing each failure) and then batch-add, so a failed1675     * add left the old entries stranded in Trash with nothing restored. Staging1676     * the entire next list in memory and committing it through1677     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1678     * pre-import list — active entries included — is restored.1679     *1680     * Existing tombstones are preserved so paired devices still see the1681     * deletions they already know about.1682     */1683    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1684        val epoch = requireUnlocked()1685        val now = System.currentTimeMillis()1686        val ready = newEntries.map { draft ->1687            val stamp = VaultCrypto.monotonicEntryTimestamp()1688            withDerivedLast4(1689                draft.copy(1690                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1691                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1692                    modifiedAt = stamp,1693                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1694                ),1695            )1696        }1697        // M-03: an incoming entry that shares an id with an existing live entry1698        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1699        // the old one and then appending the new one would leave the list with1700        // two entries of the same id — the tombstoned one would be returned by1701        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1702        // superseded entries from the tombstone set instead.1703        val incomingIds = ready.map { it.id }.toHashSet()1704        val rollback = mutateCurrentSession(epoch) {1705            val r = entryList.toMutableList()1706            val replaced = entryList.map { entry ->1707                if (entry.deletedAt != null) return@map entry1708                if (entry.id in incomingIds) return@map null1709                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1710                entry.copy(deletedAt = stamp, modifiedAt = stamp)1711            }.filterNotNull()1712            entryList = (replaced + ready).toMutableList()1713            r1714        } ?: throw IllegalStateException("vault_locked")1715        persistOrRollback(rollback, epoch)1716        return ready.size1717    }17181719    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1720        val epoch = requireUnlocked()1721        // H-03: read entryList, compute the patched entry, and write it under a1722        // single stateLock so a concurrent lock() cannot interleave between the1723        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1724        // list, or clobber the entry onto the wrong id after a relock).1725        val prepared = mutateCurrentSession(epoch) {1726            val idx = entryList.indexOfFirst { it.id == patch.id }1727            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1728            val current = entryList[idx]1729            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1730            // Only refresh passwordChangedAt when the password value actually changes.1731            val passwordChanged = patch.password != null && patch.password != current.password1732            // Capture the outgoing password (newest first) so the user can reveal/copy/1733            // restore it later. History is computed authoritatively from `current`, not1734            // the patch — updateEntry is a full-entry replace, so trusting patch's value1735            // would let a stale form clobber the real history. Record the age the old1736            // password carried, skip an empty prior, and skip an immediate exact dupe1737            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1738            val priorPassword = current.password1739            val shouldCapture = passwordChanged &&1740                !priorPassword.isNullOrEmpty() &&1741                current.passwordHistory?.firstOrNull()?.password != priorPassword1742            val nextHistory = if (shouldCapture) {1743                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1744                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1745            } else {1746                current.passwordHistory1747            }1748            val updated = withDerivedLast4(patch.copy(1749                createdAt = current.createdAt,1750                modifiedAt = stamp,1751                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1752                passwordHistory = nextHistory,1753            ))1754            val rollback = entryList.toMutableList()1755            entryList[idx] = updated1756            updated to rollback1757        } ?: throw IllegalStateException("vault_locked")1758        val (updated, rollback) = prepared1759        persistOrRollback(rollback, epoch)1760        updated1761    }17621763    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1764    suspend fun deleteEntry(id: String) = mutex.withLock {1765        val epoch = requireUnlocked()1766        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1767        // the list out from under the indexOfFirst.1768        val rollback = mutateCurrentSession(epoch) {1769            val idx = entryList.indexOfFirst { it.id == id }1770            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1771            val current = entryList[idx]1772            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1773            val prior = entryList.toMutableList()1774            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1775            prior1776        } ?: throw IllegalStateException("vault_locked")1777        persistOrRollback(rollback, epoch)1778    }17791780    suspend fun restoreEntry(id: String) = mutex.withLock {1781        val epoch = requireUnlocked()1782        val rollback = mutateCurrentSession(epoch) {1783            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1784            if (idx == -1) throw IllegalStateException("deleted entry not found")1785            val current = entryList[idx]1786            val prior = entryList.toMutableList()1787            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1788            prior1789        } ?: throw IllegalStateException("vault_locked")1790        persistOrRollback(rollback, epoch)1791    }17921793    /** Permanent delete — no tombstone, no recovery. */1794    suspend fun purgeEntry(id: String) = mutex.withLock {1795        val epoch = requireUnlocked()1796        val rollback = mutateCurrentSession(epoch) {1797            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1798            if (idx == -1) throw IllegalStateException("deleted entry not found")1799            val prior = entryList.toMutableList()1800            entryList.removeAt(idx)1801            prior1802        } ?: throw IllegalStateException("vault_locked")1803        persistOrRollback(rollback, epoch)1804    }18051806    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1807    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1808        val epoch = requireUnlocked()1809        // H-03: read+remove under stateLock so lock() can't reorder the remove.1810        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1811        val r = mutateCurrentSession(epoch) {1812            val rollback = entryList.toMutableList()1813            val before = entryList.size1814            entryList.removeAll { it.deletedAt != null }1815            R(rollback, before - entryList.size)1816        } ?: throw IllegalStateException("vault_locked")1817        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1818        r.removed1819    }18201821    /** Browser parity: number of active entries that dedupe would move to trash. */1822    suspend fun countDuplicateEntries(): Int = mutex.withLock {1823        val epoch = requireUnlocked()1824        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1825        // lock() can't race the read with state writes.1826        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1827            ?: throw IllegalStateException("vault_locked")1828    }18291830    /**1831     * Browser parity: soft-delete duplicate entries, keeping the most recently1832     * modified member of each group. Refuse in decoy mode so the duress vault is1833     * not reshaped into a forensically distinct state.1834     */1835    suspend fun deduplicateEntries(): Int = mutex.withLock {1836        val epoch = requireUnlocked()1837        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1838        val ready = mutateCurrentSession(epoch) {1839            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1840            val active = entryList.filter { it.deletedAt == null }1841            val keepIds = mutableSetOf<String>()1842            active.groupBy { it.duplicateKey() }.values.forEach { group ->1843                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1844            }1845            val rollback = entryList.toMutableList()1846            var removed = 01847            entryList = entryList.map { entry ->1848                if (entry.deletedAt == null && entry.id !in keepIds) {1849                    removed++1850                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1851                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1852                } else {1853                    entry1854                }1855            }.toMutableList()1856            R(rollback, removed)1857        } ?: throw IllegalStateException("vault_locked")1858        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1859        ready.removed1860    }18611862    fun getEntries(): List<VaultEntry> = mutateState {1863        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1864    }1865    fun getEntry(id: String): VaultEntry? = mutateState {1866        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1867    }1868    fun getDeletedEntries(): List<VaultEntry> = mutateState {1869        if (!sessionDataAvailable) emptyList()1870        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1871    }18721873    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1874    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1875        return mutateState {1876            if (!sessionDataAvailable) return@mutateState emptyList()1877            val active = entryList.filter { it.deletedAt == null }1878            if (tagFilter == null) return@mutateState active1879            val needle = tagFilter.trim().lowercase()1880            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1881        }1882    }18831884    /** Sorted, de-duplicated tags across all active entries. */1885    fun getDistinctTags(): List<String> = mutateState {1886        if (!sessionDataAvailable) return@mutateState emptyList()1887        entryList.asSequence()1888            .filter { it.deletedAt == null }1889            .flatMap { it.tags?.asSequence() ?: emptySequence() }1890            .map { it.trim().lowercase() }1891            .filter { it.isNotEmpty() }1892            .distinct()1893            .sorted()1894            .toList()1895    }18961897    /**1898     * Distinct non-blank values for an identity field across active entries,1899     * most-frequently-used first. Supports "username" and "email"; unknown field1900     * names return an empty list. Values keep original casing (deduped1901     * case-insensitively) so a suggestion matches what the user typed.1902     */1903    fun getDistinctFieldValues(field: String): List<String> {1904        val select: (VaultEntry) -> String? = when (field) {1905            "username" -> { e -> e.username }1906            "email" -> { e -> e.email }1907            else -> return emptyList()1908        }1909        return mutateState {1910            if (!sessionDataAvailable) return@mutateState emptyList()1911            entryList.asSequence()1912                .filter { it.deletedAt == null }1913                .mapNotNull { select(it)?.trim() }1914                .filter { it.isNotEmpty() }1915                .groupingBy { it.lowercase() }1916                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1917                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1918                }1919                .values1920                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1921                .map { it.first }1922        }1923    }19241925    fun getTombstones(): List<Tombstone> = mutateState {1926        if (!sessionDataAvailable) emptyList()1927        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1928    }1929    fun getVaultVersion(): Long = store.version()1930    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19311932    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1933        val expectedEpoch = currentEpoch()1934        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1935        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1936        // H-04: snapshots now carry the duress slot too — restore preserves1937        // the at-rest triad (A/B/D), not just the A/B pair.1938        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1939        val priorA = store.loadSlotA()1940        val priorB = store.loadSlotB()1941        val priorD = store.loadDecoySlot()1942        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1943        // slot write — the old code took this snapshot, then stranded it on1944        // every failure, polluting history with a "restore" entry that never1945        // restored anything. The history below is rolled back together with1946        // the slots if the commit fails.1947        val priorHistory = store.loadVaultHistory()1948        // P1-4 (codex): the recovery snapshot must land before we commit the1949        // slot pair, otherwise a failed restore cannot be rolled back to the1950        // pre-restore history. If the history write itself fails, refuse to1951        // proceed — the alternative is a successful restore with no undo path.1952        beforeDurableCommit()1953        if (!takeVaultSnapshot("restore", expectedEpoch)) {1954            return false1955        }1956        // H-04: legacy snapshots without vault_d, or vault_d that fails to1957        // decode, fall back to a fake duress of slot A's size — presence-1958        // indistinguishable from any other freshly-restored vault.1959        val nextD: StoredVault = restoredD ?: run {1960            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161961            val fakeBlob = ByteArray(target).also(rng::nextBytes)1962            val fakeSalt = VaultCrypto.generateSalt()1963            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1964        }1965        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1966            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1967            return false1968        }1969        lock()1970        true1971    }19721973    /**1974     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1975     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1976     * (a restore preserves the duress-shaped blob from history), not a fresh1977     * noise record. Any failure rolls back so the at-rest pair never holds a1978     * restored slot A against a pre-restore slot B. Caller is responsible for1979     * restoring the pre-snapshot history on a false return.1980     *1981     * H-04: duress slot is now part of the atomic triad — restored last so1982     * a duress-write failure rolls the A/B pair back too.1983     */1984    private fun commitRestoreAtomic(1985        nextA: StoredVault,1986        nextB: String,1987        nextD: StoredVault,1988        priorA: StoredVault?,1989        priorB: String?,1990        priorD: StoredVault?,1991        expectedEpoch: Long,1992    ): Boolean {1993        return withEpochCommit(expectedEpoch) {1994            if (!store.writeSlotA(nextA)) return@withEpochCommit false1995            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1996            // must trigger rollback; previously a throw escaped without restoring1997            // the prior slot A and history.1998            try {1999                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")2000                // H-04: duress slot is part of the restore triad. Failures here2001                // roll A+B back so the at-rest state stays consistent.2002                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")2003                store.stampLocalMutation()2004            } catch (_: Exception) {2005                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))2006                return@withEpochCommit false2007            }2008            true2009        } ?: false2010    }20112012    // ---- Sync apply (does NOT advance the version) ------------------------20132014    /**2015     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists2016     * WITHOUT bumping the version counter, so a sync round cannot cascade into2017     * another outbound sync. Returns the number of locally-applied changes.2018     */2019    suspend fun applyMergedRemoteEntries(2020        remoteEntries: List<VaultEntry>,2021        remoteTombstones: List<Tombstone>,2022        expectedEpoch: Long? = null,2023    ): Int = mutex.withLock {2024        val epoch = expectedEpoch ?: requireUnlocked()2025        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02026        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS2027        // BEFORE any comparison against a local one. Without this a peer can2028        // send a far-future modifiedAt and permanently pin its version of an2029        // entry against every honest local edit. The browser clamps in the2030        // matching place (vault.ts applyMergedRemoteEntries).2031        val mergeNow = System.currentTimeMillis()2032        val clamped = remoteEntries.mapNotNull { entry ->2033            // MN-3: drop peer entries that decode cleanly but are semantically2034            // hollow (blank title, credential-less login, husk passkey) and2035            // re-cap passwordHistory before anything merges into the vault.2036            SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2037                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2038                deletedAt = entry.deletedAt?.let {2039                    SyncLimits.clampSyncTimestamp(it, mergeNow)2040                },2041            )2042        }2043        val tombMap = remoteTombstones.associate {2044            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2045        }2046        // H-03: build the new entryList under stateLock so a concurrent lock()2047        // cannot zero derivedKey/entryList between the byId read and the merge.2048        val applied = mutateCurrentSession(epoch) {2049            var n = 02050            val byId = entryList.associateBy { it.id }.toMutableMap()20512052            for (remote in clamped) {2053                val tomb = tombMap[remote.id]2054                if (tomb != null && tomb >= remote.modifiedAt) continue2055                val local = byId[remote.id]2056                if (local == null) {2057                    entryList.add(remote); byId[remote.id] = remote; n++2058                } else if (remote.modifiedAt > local.modifiedAt) {2059                    val i = entryList.indexOfFirst { it.id == local.id }2060                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2061                }2062            }20632064            for ((id, deletedAt) in tombMap) {2065                val local = byId[id] ?: continue2066                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2067                if (deletedAt < local.modifiedAt) continue2068                val i = entryList.indexOfFirst { it.id == id }2069                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2070            }2071            n2072        } ?: return@withLock 020732074        if (applied > 0) {2075            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2076            mutateIfCurrentEpoch(epoch) { publish() }2077            runCatching { LockedSyncSpoolHooks.refresh(this) }2078        }2079        applied2080    }20812082    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2083        // Require a fully live session before exposing a settings mutation.  The2084        // epoch is captured with the read so a lock/new-session boundary cannot2085        // let stale work publish into the replacement session.2086        val epoch = requireUnlocked()2087        val prior = mutateCurrentSession(epoch) {2088            val old = _settings.value2089            _settings.value = settings2090            old2091        } ?: throw IllegalStateException("vault_locked")20922093        val committed = try {2094            beforeDurableCommit()2095            // The durable write is inside the same epoch fence as the in-memory2096            // publication.  If lock() won first, this returns null and no stale2097            // settings reach storage or the public StateFlow.2098            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2099        } catch (_: Exception) {2100            false2101        }2102        if (!committed) {2103            // A live-session storage failure rolls back the public value.  A2104            // stale epoch is already reset by lock()/the new session and must2105            // not be allowed to overwrite that safe state.2106            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2107            throw IllegalStateException("settings_write_failed")2108        }2109    }21102111    // ---- Recovery words (vault metadata) ----------------------------------21122113    /** True once the user has confirmed their recovery words for this vault. */2114    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }21152116    /**2117     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2118     * Persisted without bumping the version — recovery metadata is local-only and2119     * is not an entry mutation paired devices should sync against.2120     */2121    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2122        val epoch = requireUnlocked()2123        // H-03: hash + escrow-related state fields set under stateLock so a2124        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2125        // M-04: the flags have to be live before the escrows are built — both seal2126        // currentPayload() — so capture the prior pair and put it back if the write2127        // never lands. Otherwise the session reports words set against a slot A2128        // that still holds no escrow, and the recovery screen never offers a retry.2129        val prior = mutateCurrentSession(epoch) {2130            val priorPair = recoveryWordHash to recoveryWordsSet2131            recoveryWordHash = recoveryWordsHash(words)2132            recoveryWordsSet = true2133            priorPair2134        } ?: return@withLock false2135        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2136        // (cross-platform recovery of the sync root key) and Android's whole-payload2137        // escrow (offline forgot-password restore of entries). Both derive their key2138        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2139        val persisted = runCatching {2140            persistSlotAWithEscrows(2141                buildRecoveryEscrow(words),2142                buildRootKeyEscrow(words),2143                expectedEpoch = epoch,2144            )2145        }.getOrDefault(false)2146        if (!persisted) {2147            mutateIfCurrentEpoch(epoch) {2148                recoveryWordHash = prior.first2149                recoveryWordsSet = prior.second2150            }2151        }2152        persisted2153    }21542155    /** Verify entered words against the stored recovery hash. */2156    fun verifyRecoveryWords(words: List<String>): Boolean {2157        return mutateState {2158            val stored = recoveryWordHash ?: return@mutateState false2159            stored == recoveryWordsHash(words)2160        }2161    }21622163    // ---- Recovery escrow (forgot-password) --------------------------------21642165    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2166    fun hasRecoveryEscrow(): Boolean =2167        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21682169    /**2170     * One-time self-heal: if this vault has a root key but no recovery escrow2171     * on disk (adopted vaults created before the escrow fix), build and persist2172     * it now. Called on every successful unlock; no-ops once the escrow exists.2173     */2174    private fun ensureRecoveryEscrow() {2175        if (hasRecoveryEscrow()) return2176        val rk = mutateState { rootKey?.copyOf() } ?: return2177        runCatching {2178            val words = rootKeyToWords(rk)2179            val escrow = buildRecoveryEscrow(words)2180            val rkEscrow = buildRootKeyEscrow(words)2181            if (escrow != null || rkEscrow != null) {2182                val prior = store.loadSlotA() ?: return@runCatching2183                store.writeSlotA(prior.copy(2184                    recoveryEscrow = escrow ?: prior.recoveryEscrow,2185                    rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2186                ))2187                mutateState {2188                    recoveryWordHash = recoveryWordsHash(words)2189                    recoveryWordsSet = true2190                }2191            }2192        }.also { VaultCrypto.zero(rk) }2193    }21942195    /**2196     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2197     * the master password. On success the slot-A plaintext sealed in the escrow is2198     * adopted as the live session (entries, rootKey, settings), but no Argon2id2199     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2200     * new password and finish unlocking. Returns false when there is no escrow, the2201     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2202     *2203     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2204     * detached vault key to return here — slot A is wrapped under the rotating2205     * Argon2id key — so this opens the escrowed payload directly.2206     */2207    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2208        val expectedEpoch = currentEpoch()2209        val slotA = store.loadSlotA() ?: return false2210        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false22112212        // Prefer Android's whole-payload escrow: a full offline restore of entries,2213        // root key, and settings. Present for Android-created vaults.2214        val fullEscrow = slotA.recoveryEscrow2215        if (fullEscrow != null) {2216            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2217            if (material != null) {2218                var keyBytes: ByteArray? = null2219                var plaintext: ByteArray? = null2220                var candidateRoot: ByteArray? = null2221                var stateCommitStarted = false2222                try {2223                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2224                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2225                    val payload = VaultCodec.decode(plaintext)2226                    // Stage every fallible operation before touching live session2227                    // fields.  In particular, a settings/provider/decode failure2228                    // cannot leave entries, flags, or a root key resident while the2229                    // public session remains locked.2230                    val candidateSettings = store.loadSettings()2231                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2232                        val decodedRoot = unb64(payload.rootKeyB64)2233                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2234                            // Do not let a malformed authenticated payload abandon2235                            // this decoded root-key candidate before the outer2236                            // finally block owns it.2237                            VaultCrypto.zero(decodedRoot)2238                            throw IllegalArgumentException("invalid recovery root key")2239                        }2240                        decodedRoot2241                    } else {2242                        generateRecoverableRootKey()2243                    }2244                    stateCommitStarted = true2245                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2246                        clearSessionStateLocked()2247                        entryList = payload.entries.toMutableList()2248                        duressConfigured = payload.duressConfigured2249                        recoveryWordHash = payload.recoveryWordHash2250                        recoveryWordsSet = payload.recoveryWordsSet2251                        rootKey = candidateRoot2252                        candidateRoot = null2253                        _settings.value = candidateSettings2254                        publish()2255                        // This is the authenticated recovery half-unlock boundary:2256                        // only set it after every candidate field is installed.2257                        sessionDataAvailable = true2258                        _isUnlocked.value = false2259                        true2260                    }2261                    if (adopted == true) return@withLock true2262                } catch (_: Exception) {2263                    // Wrong words, malformed payloads, provider failures, and2264                    // settings failures all fall through to the browser-compatible2265                    // root-key escrow.  If the state commit had started, invalidate2266                    // that epoch so no partial candidate survives.2267                    if (stateCommitStarted) {2268                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2269                    }2270                } finally {2271                    plaintext?.let(VaultCrypto::zero)2272                    VaultCrypto.zero(material)2273                    keyBytes?.let(VaultCrypto::zero)2274                    candidateRoot?.let(VaultCrypto::zero)2275                }2276            }2277        }22782279        // Browser-format vault (only the root-key escrow): recover the 32-byte root2280        // key (sync identity) and reset the password; entries return via sync. This2281        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2282        val rootEscrow = slotA.rootKeyEscrow2283        if (rootEscrow != null) {2284            var candidateRoot: ByteArray? = null2285            var stateCommitStarted = false2286            try {2287                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2288                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2289                val candidateSettings = store.loadSettings()2290                stateCommitStarted = true2291                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2292                    clearSessionStateLocked()2293                    entryList = mutableListOf()2294                    duressConfigured = false2295                    recoveryWordHash = recoveryWordsHash(words)2296                    recoveryWordsSet = true2297                    rootKey = candidateRoot2298                    candidateRoot = null2299                    _settings.value = candidateSettings2300                    publish()2301                    sessionDataAvailable = true2302                    _isUnlocked.value = false2303                    true2304                }2305                if (adopted == true) return@withLock true2306            } catch (_: Exception) {2307                if (stateCommitStarted) {2308                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2309                }2310            } finally {2311                candidateRoot?.let(VaultCrypto::zero)2312            }2313        }2314        return false2315    }23162317    /**2318     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2319     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2320     * words, mark the session fully unlocked, and publish. Returns false if called2321     * without a prior successful recovery unlock (no in-memory root key).2322     *2323     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2324     * into one step, since there is no detached vault key here.2325     */2326    suspend fun resetPasswordWithRecoveryWords(2327        words: List<String>,2328        newPassword: String,2329    ): Boolean = mutex.withLock {2330        val expectedEpoch = currentEpoch()2331        // H-03: read rootKey + entryList under stateLock before computing the new2332        // plaintext, then re-publish + swap the derived key under stateLock so a2333        // concurrent lock() cannot straddle the two fences.2334        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2335        val priorA = store.loadSlotA() ?: return false2336        var newKey: ByteArray? = null2337        var plaintext: ByteArray? = null2338        return try {2339            val newSalt = VaultCrypto.generateSalt()2340            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2341            plaintext = VaultCodec.encode(currentPayload())2342            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2343            // Rebuild both escrows against the recovered session so their salts rotate2344            // with the password reset; fall back to the prior escrows if rebuild fails2345            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2346            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2347            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2348            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2349            val next = StoredVault(2350                version = priorA.version,2351                salt = b64(newSalt),2352                blob = blob,2353                recoveryEscrow = freshRecovery,2354                rootKeyEscrow = freshRootKey,2355            )2356            // H-01: see changePassword — both rotations share the same2357            // commit-or-keep-old-key contract so a partial write can't strand2358            // the session on a key that no longer matches disk.2359            val priorB = store.loadSlotB()2360            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2361            // H-03 (P1-2 codex): zero the old live key and install the new one2362            // inside the same stateLock critical section so a concurrent lock()2363            // can't observe the in-between.2364            if (mutateIfCurrentEpoch(expectedEpoch) {2365                derivedKey?.let(VaultCrypto::zero)2366                derivedKey = newKey; newKey = null2367                publish()2368                _isUnlocked.value = true2369            } == null) return false2370            true2371        } catch (_: Exception) {2372            false2373        } finally {2374            newKey?.let(VaultCrypto::zero)2375            plaintext?.let(VaultCrypto::zero)2376        }2377    }23782379    /**2380     * Seal the current slot-A plaintext under a key derived from the 12 BIP392381     * recovery [words] (Android offline-restore escrow). IKM is the browser's2382     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2383     * Returns null for a checksum-invalid phrase.2384     */2385    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2386        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2387        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2388        var keyBytes: ByteArray? = null2389        var plaintext: ByteArray? = null2390        return try {2391            val salt = VaultCrypto.generateSalt()2392            val recoveryKey = deriveRecoveryKey(material, salt)2393            keyBytes = recoveryKey.encoded2394            plaintext = VaultCodec.encode(currentPayload())2395            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2396            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2397        } finally {2398            VaultCrypto.zero(material)2399            keyBytes?.let(VaultCrypto::zero)2400            plaintext?.let(VaultCrypto::zero)2401        }2402    }24032404    /**2405     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2406     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2407     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2408     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2409     * the same root key on the browser. Returns null with no in-memory root key or2410     * a checksum-invalid phrase.2411     */2412    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2413        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2414        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2415        val rk = mutateState { rootKey?.copyOf() } ?: run {2416            VaultCrypto.zero(material)2417            return null2418        }2419        var keyBytes: ByteArray? = null2420        var plaintext: ByteArray? = null2421        return try {2422            val salt = VaultCrypto.generateSalt()2423            val recoveryKey = deriveRecoveryKey(material, salt)2424            keyBytes = recoveryKey.encoded2425            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2426            // combined = nonce(12) || ciphertext || tag(16)2427            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2428            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2429            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2430            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2431            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2432            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2433        } finally {2434            VaultCrypto.zero(rk)2435            VaultCrypto.zero(material)2436            keyBytes?.let(VaultCrypto::zero)2437            plaintext?.let(VaultCrypto::zero)2438        }2439    }24402441    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2442    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2443        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2444        var keyBytes: ByteArray? = null2445        var pt: ByteArray? = null2446        return try {2447            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2448            keyBytes = recoveryKey.encoded2449            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2450            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2451            hexToRootKey(String(pt, Charsets.US_ASCII))2452        } catch (_: Exception) {2453            null2454        } finally {2455            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2456            // IKM-equivalent of the vault identity. material+keyBytes were2457            // already zeroed; pt was leaking.2458            VaultCrypto.zero(material)2459            keyBytes?.let(VaultCrypto::zero)2460            pt?.let(VaultCrypto::zero)2461        }2462    }24632464    /**2465     * Re-seal slot A from the current in-memory state under the live key, carrying2466     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2467     * in decoy mode (recovery is never offered for the decoy) and persists without2468     * a version bump, like the recovery-words metadata write it replaces.2469     */2470    private fun persistSlotAWithEscrows(2471        recoveryEscrow: RecoveryEscrow?,2472        rootKeyEscrow: RootKeyEscrow?,2473        expectedEpoch: Long? = null,2474    ): Boolean {2475        val snapshot = snapshotForPersist(expectedEpoch)2476            ?: throw IllegalStateException("vault_locked_or_stale")2477        if (snapshot.decoy) {2478            VaultCrypto.zero(snapshot.key)2479            return false2480        }2481        try {2482            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2483            val plaintext = VaultCodec.encode(snapshot.payload)2484            val blob = try {2485                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2486            } finally {2487                // The escrow path used to drop this reference before zeroing it;2488                // keep it live through the fill on both success and failure.2489                VaultCrypto.zero(plaintext)2490            }2491            val next = prior.copy(2492                blob = blob,2493                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2494                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2495            )2496            beforeDurableCommit()2497            return withEpochCommit(snapshot.epoch) {2498                if (!store.writeSlotA(next)) return@withEpochCommit false2499                try {2500                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2501                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2502                    // the duress slot too. A real decoy is left alone; a fake is2503                    // re-sized to slot A's new ciphertext length.2504                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2505                } catch (e: Exception) {2506                    store.writeSlotA(prior)2507                    throw e2508                }2509                true2510            } ?: false2511        } finally {2512            VaultCrypto.zero(snapshot.key)2513        }2514    }25152516    // ---- Internals --------------------------------------------------------25172518    private fun requireUnlocked(): Long {2519        // H-03: read derivedKey atomically with [lock]'s reset; without it,2520        // [lock] from a background lifecycle event can run between this read2521        // and the caller's first state touch.2522        stateLock.lock()2523        try {2524            if (derivedKey == null) throw IllegalStateException("vault_locked")2525            return sessionEpoch2526        } finally {2527            stateLock.unlock()2528        }2529    }25302531    /** A locked or half-adopted session must not expose secret-bearing fields. */2532    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25332534    private fun countDuplicateEntriesLocked(): Int {2535        val seen = mutableMapOf<String, VaultEntry>()2536        var duplicates = 02537        for (entry in entryList.filter { it.deletedAt == null }) {2538            val key = entry.duplicateKey()2539            val existing = seen[key]2540            if (existing == null) {2541                seen[key] = entry2542            } else {2543                duplicates++2544                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2545                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2546                if (entryStamp > existingStamp) seen[key] = entry2547            }2548        }2549        return duplicates2550    }25512552    private fun VaultEntry.duplicateKey(): String {2553        if (type == "note") return "note|${title.lowercase()}"2554        val passkeyData = passkey2555        if (passkeyData != null) {2556            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2557        }2558        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2559    }25602561    /**2562     * Capture the current slot pair (and metadata) into the history list.2563     * Returns true when the history write landed, false when it didn't — callers2564     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2565     * must check the result and roll back if it does not.2566     */2567    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2568        val epoch = expectedEpoch ?: currentEpoch()2569        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2570        val slotA = store.loadSlotA() ?: return false2571        val vaultA = VaultCodec.encodeSlot(slotA)2572        val vaultB = store.loadSlotB() ?: slotA.blob2573        // H-04: capture the duress slot so a restore preserves the at-rest2574        // triad, not just the A/B pair. The duress slot is always present2575        // (real or fake), so loadDecoySlot() returning null is a logic error2576        // — fall through to a fake duress in that case rather than silently2577        // losing the record from the snapshot.2578        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2579            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162580            val fakeBlob = ByteArray(target).also(rng::nextBytes)2581            val fakeSalt = VaultCrypto.generateSalt()2582            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2583        }2584        val history = store.loadVaultHistory()2585        val latest = history.snapshots.firstOrNull()2586        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2587        val entryCount = mutateIfCurrentEpoch(epoch) {2588            if (derivedKey != null && !decoyMode) {2589                bucketEntryCount(entryList.count { it.deletedAt == null })2590            } else {2591                null2592            }2593        } ?: return false2594        return try {2595            val snapshot = VaultSnapshot(2596                id = UUID.randomUUID().toString(),2597                savedAt = System.currentTimeMillis(),2598                reason = reason,2599                vault_a = vaultA,2600                vault_b = vaultB,2601                vault_d = vaultD,2602                entryCount = entryCount,2603            )2604            // P1-4 (codex): return the history-write boolean to callers so a2605            // false return no longer leaves the snapshot out of history while2606            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2607            withEpochCommit(epoch) {2608                store.writeVaultHistory(2609                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2610                )2611            } ?: false2612        } catch (_: Exception) {2613            false2614        }2615    }26162617    private fun bucketEntryCount(n: Int): Int = when {2618        n <= 0 -> 02619        n <= 10 -> 102620        n <= 25 -> 252621        n <= 50 -> 502622        n <= 100 -> 1002623        n <= 250 -> 2502624        n <= 500 -> 5002625        else -> ((n + 499) / 500) * 5002626    }26272628    /**2629     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2630     * so it is stored explicitly and survives the sync wire format rather than being2631     * re-derived at render time. Non-card entries pass through unchanged.2632     *2633     * M-03: never overwrite a stored last4 with null. A card kept only as last42634     * (e.g. after a PeachScript import that had no PAN) must keep that value;2635     * re-deriving from an absent cardNumber would silently zero the field.2636     */2637    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2638        if (!entry.isCard()) return entry2639        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2640        val derived = if (digits.length >= 4) digits.takeLast(4) else null2641        return entry.copy(last4 = derived ?: entry.last4)2642    }26432644    /**2645     * On unlock, hard-delete trashed entries older than the retention window.2646     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2647     * (the app's "Never" option), so nothing is purged. Persists without a2648     * version bump and without touching the publish state (caller publishes).2649     */2650    private fun autoPurgeExpiredTrash() {2651        val days = _settings.value.trashRetentionDays ?: return2652        if (days <= 0) return2653        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2654        val before = entryList.size2655        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2656        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2657    }26582659    /**2660     * Browser parity (P1): merge orphaned passkey-only entries into matching2661     * login entries that share the same registrable domain. A passkey-only2662     * entry has passkey fields (rpId + credentialId) but no password. Runs on2663     * unlock so consolidation is idempotent and cheap.2664     */2665    private fun consolidatePasskeyEntries() {2666        val passkeyOnly = entryList.filter { entry ->2667            entry.deletedAt == null &&2668                entry.isLogin() &&2669                entry.password.isNullOrBlank() &&2670                !entry.rpId.isNullOrBlank() &&2671                !entry.credentialId.isNullOrBlank()2672        }2673        if (passkeyOnly.isEmpty()) return2674        var changed = false2675        val now = System.currentTimeMillis()2676        for (pkEntry in passkeyOnly) {2677            val rpId = pkEntry.rpId ?: continue2678            val target = entryList.firstOrNull { candidate ->2679                candidate.deletedAt == null &&2680                    candidate.id != pkEntry.id &&2681                    candidate.isLogin() &&2682                    candidate.passkey == null &&2683                    candidate.credentialId.isNullOrBlank() &&2684                    !candidate.password.isNullOrBlank() &&2685                    listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2686                        .any { com.peachpasswords.shared.extractDomain(it) == rpId }2687            } ?: continue2688            val idx = entryList.indexOfFirst { it.id == target.id }2689            if (idx < 0) continue2690            entryList[idx] = target.copy(2691                rpId = pkEntry.rpId,2692                rpName = pkEntry.rpName ?: target.rpName,2693                credentialId = pkEntry.credentialId,2694                publicKeyCose = pkEntry.publicKeyCose,2695                privateKeyJwk = pkEntry.privateKeyJwk,2696                userHandle = pkEntry.userHandle,2697                userName = pkEntry.userName,2698                userDisplayName = pkEntry.userDisplayName,2699                algorithm = pkEntry.algorithm,2700                signCount = pkEntry.signCount,2701                lastUsedAt = pkEntry.lastUsedAt,2702                modifiedAt = now,2703            )2704            // Soft-delete the orphan so it lands in trash (recoverable).2705            val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2706            if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2707            changed = true2708        }2709        if (changed) runCatching { persistSlotA(bumpVersion = false) }2710    }27112712    /** The slot-A plaintext envelope for the current in-memory session. */2713    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }27142715    private fun currentPayloadLocked(): VaultPayload =2716        VaultPayload(2717            entries = entryList.toList(),2718            duressConfigured = duressConfigured,2719            rootKeyB64 = rootKey?.let(::b64) ?: "",2720            recoveryWordHash = recoveryWordHash,2721            recoveryWordsSet = recoveryWordsSet,2722        )27232724    /**2725     * Adopt the payload's root key, lazily minting one for legacy payloads.2726     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2727     * a root key is not a mutation paired devices should sync against.2728     */2729    private fun ensureRootKey(payload: VaultPayload) {2730        if (payload.rootKeyB64.isNotBlank()) {2731            rootKey = unb64(payload.rootKeyB64)2732            return2733        }2734        rootKey = generateRecoverableRootKey()2735        runCatching { persistSlotA(bumpVersion = false) }2736    }27372738    private fun publish() {2739        _entries.value = entryList.filter { it.deletedAt == null }2740    }27412742    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2743        var plaintext = VaultCodec.encode(currentPayload())2744        return try {2745            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2746            StoredVault(version = 1, salt = b64(salt), blob = blob)2747        } finally {2748            // M-05: full vault plaintext is allocated here once per createVault.2749            // Zeroing in finally keeps the transient copy off the heap after the2750            // caller either succeeds or rolls back via lock().2751            VaultCrypto.zero(plaintext)2752        }2753    }27542755    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2756        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2757        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2758        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2759        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2760        val noise = ByteArray(approxBytes).also(rng::nextBytes)2761        return store.writeSlotB(b64(noise))2762    }27632764    /** H-04: the ciphertext length slot A is using right now, so the duress2765     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2766     *  length equals ciphertext length, so this also matches slot A's plaintext. */2767    private fun matchingCiphertextLength(slotA: StoredVault): Int =2768        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27692770    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2771     *  bytes. Replaces a real decoy when the user clears duress, and is the2772     *  initial duress slot when no decoy is configured — so the record's2773     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2774     *  matches slot A's blob length. */2775    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2776        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162777        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2778        val fakeSalt = VaultCrypto.generateSalt()2779        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2780    }27812782    /** H-04: keep the duress record present on every persist. A real decoy is2783     *  left alone (it was padded at setDuressPassphrase time, and we don't have2784     *  the duress key here to re-seal it) — VaultStore re-pads both slot2785     *  records to the longer of the two on every write, which is what keeps the2786     *  pair the same length after slot A crosses a padding bucket2787     *  (PAI-2026-004). No real decoy → a fresh fake of slot A's size. */2788    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2789        val isConfigured = mutateState { duressConfigured }2790        return if (isConfigured) true else writeFakeDuressSlot(slotA)2791    }27922793    /**2794     * H-04: convert a browser-shaped duress slot to Android StoredVault2795     * without decrypting. The browser seals slot "b" under the duress2796     * passphrase; decrypting with the main password silently drops it.2797     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2798     * the salt from hex to base64.2799     */2800    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2801        val salt = unhex(b.salt)2802        val nonce = unhex(b.nonce)2803        val ct = unhex(b.ciphertext)2804        val tag = unhex(b.tag)2805        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2806            System.arraycopy(nonce, 0, it, 0, nonce.size)2807            System.arraycopy(ct, 0, it, nonce.size, ct.size)2808            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2809        }2810        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2811    }28122813    /** Re-seal slot A from the current in-memory state under the live key. */2814    private fun persistSlotA(2815        bumpVersion: Boolean,2816        snapshotReason: String? = "local_save",2817        expectedEpoch: Long? = null,2818    ): Boolean {2819        val snapshot = snapshotForPersist(expectedEpoch)2820            ?: throw IllegalStateException("vault_locked_or_stale")2821        return try {2822            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2823            if (snapshot.decoy) {2824                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2825                val plaintext = VaultCodec.encode(snapshot.payload)2826                val blob = try {2827                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2828                } finally {2829                    // Keep the last reference alive until the zero, including the2830                    // successful encrypt path (M-05).2831                    VaultCrypto.zero(plaintext)2832                }2833                beforeDurableCommit()2834                return withEpochCommit(snapshot.epoch) {2835                    store.writeDecoySlot(prior.copy(blob = blob))2836                } ?: false2837            }2838            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2839            // H-01: captured BEFORE the noise write so a stamp failure can restore2840            // the prior slot B alongside the prior slot A. Slot B is always2841            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2842            // leaves (new A, new B, old version) on disk — partial commit.2843            val priorB = store.loadSlotB()2844            beforeDurableCommit()2845            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2846            val plaintext = VaultCodec.encode(snapshot.payload)2847            val blob = try {2848                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2849            } finally {2850                // Do not drop [plaintext] before fill(0): the serialized vault2851                // contains every entry password and is the primary M-05 target.2852                VaultCrypto.zero(plaintext)2853            }2854            val next = prior.copy(blob = blob)2855            // The epoch check and the entire A/B/D/version commit share one brief2856            // stateLock critical section. lock() either wins before this block or2857            // waits until the already-committed write is complete; it can never2858            // clear the session between the check and the durable write.2859            withEpochCommit(snapshot.epoch) {2860                if (!store.writeSlotA(next)) return@withEpochCommit false2861                try {2862                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2863                    // H-04: duress slot stays present (real one untouched, fake one2864                    // re-padded to slot A's current size on every persist).2865                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2866                    if (bumpVersion) store.stampLocalMutation()2867                } catch (e: Exception) {2868                    store.writeSlotA(prior)2869                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2870                    throw e2871                }2872                true2873            } ?: false2874        } finally {2875            // Snapshot keys are copies, so lock() may zero the live key while this2876            // operation is still deriving/encrypting; always scrub our copy too.2877            VaultCrypto.zero(snapshot.key)2878        }2879    }28802881    /**2882     * H-01: durably commit a slot-A rotation by writing slot A, its2883     * size-matched noise slot B, and the local-mutation stamp in order. Any2884     * failure rolls the previously-committed steps back so the at-rest pair2885     * matches what was there before the call. Returns true only when every2886     * step succeeded — callers must NOT swap their session key on false.2887     */2888    private fun commitVaultRotation(2889        priorA: StoredVault,2890        priorB: String?,2891        nextA: StoredVault,2892        expectedEpoch: Long,2893    ): Boolean {2894        beforeDurableCommit()2895        return withEpochCommit(expectedEpoch) {2896            if (!store.writeSlotA(nextA)) return@withEpochCommit false2897            // P1-1 (codex): the noise write's boolean AND any thrown exception from2898            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2899            // escaped the helper and left slot A committed.2900            try {2901                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2902                // H-04: a password rotation changes slot A's plaintext size (new2903                // salt → re-encrypted blob), so the duress slot must be re-padded2904                // alongside. writeCompanionDuressSlot leaves a real decoy alone2905                // and re-emits a fake of slot A's size otherwise.2906                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2907                store.stampLocalMutation()2908            } catch (_: Exception) {2909                rollbackRotation(priorA, priorB)2910                return@withEpochCommit false2911            }2912            true2913        } ?: false2914    }29152916    /**2917     * Best-effort undo of the writes [commitVaultRotation] may have committed.2918     * Each step runs independently — a failed rollback must not mask the2919     * original commit failure. Log is a throwing stub off-device.2920     */2921    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2922        val failures = mutableListOf<String>()2923        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2924            .onFailure { failures += "writeSlotA: $it" }2925        if (priorB != null) {2926            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2927                .onFailure { failures += "writeSlotB: $it" }2928        }2929        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2930    }29312932    /** Persist a local mutation, rolling the in-memory list back on failure. */2933    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2934        try {2935            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2936                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2937                throw IllegalStateException("storage_write_failed")2938            }2939        } catch (e: Exception) {2940            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2941            throw e2942        }2943        // A lock can win in the small interval after the durable commit and2944        // before publication. The epoch check prevents pre-lock entries from2945        // being republished into a newly locked (or newly unlocked) session.2946        mutateIfCurrentEpoch(expectedEpoch) { publish() }2947        runCatching { LockedSyncSpoolHooks.refresh(this) }2948    }2949}