VaultRepository.kt

141.6 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import 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    /**1616     * A Peach entry id is a UUID. Import and restore carry ids from foreign1617     * files (another Peach install, a vendor export, a hand-written backup) and1618     * no reader bounds them, so an id that is not a well-formed Peach id is1619     * replaced here rather than trusted.1620     *1621     * This is the naturalisation seam, and it is deliberately the only place it1622     * happens. Minting costs at most one duplicate if the same vault is later1623     * restored on a device that still holds the old id — visible and fixable.1624     * The sync path must NOT do this: adopting a peer's id verbatim is the only1625     * thing that keeps two devices agreeing on which entry they are discussing,1626     * so reminting there would fork identity on every device, on every round.1627     */1628    private val entryIdShape = Regex("[A-Za-z0-9_-]{1,64}")16291630    private fun String?.normalizedEntryId(): String =1631        if (this != null && entryIdShape.matches(this)) this else UUID.randomUUID().toString()16321633    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1634        val epoch = requireUnlocked()1635        val stamp = VaultCrypto.monotonicEntryTimestamp()1636        val now = System.currentTimeMillis()1637        val entry = withDerivedLast4(draft.copy(1638            id = draft.id.normalizedEntryId(),1639            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1640            modifiedAt = stamp,1641            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1642        ))1643        // H-03: capture rollback AND write entryList under stateLock so a1644        // concurrent lock() cannot zero the session between the read and the write.1645        val rollback = mutateCurrentSession(epoch) {1646            val r = entryList.toMutableList()1647            entryList.add(entry)1648            r1649        } ?: throw IllegalStateException("vault_locked")1650        persistOrRollback(rollback, epoch)1651        entry1652    }16531654    /**1655     * Insert many entries with a single encrypt-and-write at the end.1656     *1657     * Importing a real vault through [addEntry] was quadratic: every entry took1658     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1659     * restore did 806 full-vault writes and hung the app long enough to ANR.1660     * Rolls back to the pre-import list if the one write fails, matching1661     * [addEntry]'s all-or-nothing behaviour.1662     */1663    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1664        val epoch = requireUnlocked()1665        if (drafts.isEmpty()) return 01666        val now = System.currentTimeMillis()1667        val ready = drafts.map { draft ->1668            val stamp = VaultCrypto.monotonicEntryTimestamp()1669            withDerivedLast4(1670                draft.copy(1671                    id = draft.id.normalizedEntryId(),1672                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1673                    modifiedAt = stamp,1674                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1675                ),1676            )1677        }1678        // H-03: capture rollback AND append every entry under stateLock.1679        val rollback = mutateCurrentSession(epoch) {1680            val r = entryList.toMutableList()1681            entryList.addAll(ready)1682            r1683        } ?: throw IllegalStateException("vault_locked")1684        persistOrRollback(rollback, epoch)1685        return drafts.size1686    }16871688    /**1689     * M-01: replace the whole active set with [newEntries] in one write.1690     *1691     * Import's REPLACE mode used to soft-delete every current entry one durable1692     * write at a time (swallowing each failure) and then batch-add, so a failed1693     * add left the old entries stranded in Trash with nothing restored. Staging1694     * the entire next list in memory and committing it through1695     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1696     * pre-import list — active entries included — is restored.1697     *1698     * Existing tombstones are preserved so paired devices still see the1699     * deletions they already know about.1700     */1701    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1702        val epoch = requireUnlocked()1703        val now = System.currentTimeMillis()1704        val ready = newEntries.map { draft ->1705            val stamp = VaultCrypto.monotonicEntryTimestamp()1706            withDerivedLast4(1707                draft.copy(1708                    id = draft.id.normalizedEntryId(),1709                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1710                    modifiedAt = stamp,1711                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1712                ),1713            )1714        }1715        // M-03: an incoming entry that shares an id with an existing live entry1716        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1717        // the old one and then appending the new one would leave the list with1718        // two entries of the same id — the tombstoned one would be returned by1719        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1720        // superseded entries from the tombstone set instead.1721        val incomingIds = ready.map { it.id }.toHashSet()1722        val rollback = mutateCurrentSession(epoch) {1723            val r = entryList.toMutableList()1724            val replaced = entryList.map { entry ->1725                if (entry.deletedAt != null) return@map entry1726                if (entry.id in incomingIds) return@map null1727                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1728                entry.copy(deletedAt = stamp, modifiedAt = stamp)1729            }.filterNotNull()1730            entryList = (replaced + ready).toMutableList()1731            r1732        } ?: throw IllegalStateException("vault_locked")1733        persistOrRollback(rollback, epoch)1734        return ready.size1735    }17361737    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1738        val epoch = requireUnlocked()1739        // H-03: read entryList, compute the patched entry, and write it under a1740        // single stateLock so a concurrent lock() cannot interleave between the1741        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1742        // list, or clobber the entry onto the wrong id after a relock).1743        val prepared = mutateCurrentSession(epoch) {1744            val idx = entryList.indexOfFirst { it.id == patch.id }1745            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1746            val current = entryList[idx]1747            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1748            // Only refresh passwordChangedAt when the password value actually changes.1749            val passwordChanged = patch.password != null && patch.password != current.password1750            // Capture the outgoing password (newest first) so the user can reveal/copy/1751            // restore it later. History is computed authoritatively from `current`, not1752            // the patch — updateEntry is a full-entry replace, so trusting patch's value1753            // would let a stale form clobber the real history. Record the age the old1754            // password carried, skip an empty prior, and skip an immediate exact dupe1755            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1756            val priorPassword = current.password1757            val shouldCapture = passwordChanged &&1758                !priorPassword.isNullOrEmpty() &&1759                current.passwordHistory?.firstOrNull()?.password != priorPassword1760            val nextHistory = if (shouldCapture) {1761                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1762                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1763            } else {1764                current.passwordHistory1765            }1766            val updated = withDerivedLast4(patch.copy(1767                createdAt = current.createdAt,1768                modifiedAt = stamp,1769                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1770                passwordHistory = nextHistory,1771            ))1772            val rollback = entryList.toMutableList()1773            entryList[idx] = updated1774            updated to rollback1775        } ?: throw IllegalStateException("vault_locked")1776        val (updated, rollback) = prepared1777        persistOrRollback(rollback, epoch)1778        updated1779    }17801781    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1782    suspend fun deleteEntry(id: String) = mutex.withLock {1783        val epoch = requireUnlocked()1784        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1785        // the list out from under the indexOfFirst.1786        val rollback = mutateCurrentSession(epoch) {1787            val idx = entryList.indexOfFirst { it.id == id }1788            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1789            val current = entryList[idx]1790            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1791            val prior = entryList.toMutableList()1792            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1793            prior1794        } ?: throw IllegalStateException("vault_locked")1795        persistOrRollback(rollback, epoch)1796    }17971798    suspend fun restoreEntry(id: String) = mutex.withLock {1799        val epoch = requireUnlocked()1800        val rollback = mutateCurrentSession(epoch) {1801            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1802            if (idx == -1) throw IllegalStateException("deleted entry not found")1803            val current = entryList[idx]1804            val prior = entryList.toMutableList()1805            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1806            prior1807        } ?: throw IllegalStateException("vault_locked")1808        persistOrRollback(rollback, epoch)1809    }18101811    /** Permanent delete — no tombstone, no recovery. */1812    suspend fun purgeEntry(id: String) = mutex.withLock {1813        val epoch = requireUnlocked()1814        val rollback = mutateCurrentSession(epoch) {1815            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1816            if (idx == -1) throw IllegalStateException("deleted entry not found")1817            val prior = entryList.toMutableList()1818            entryList.removeAt(idx)1819            prior1820        } ?: throw IllegalStateException("vault_locked")1821        persistOrRollback(rollback, epoch)1822    }18231824    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1825    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1826        val epoch = requireUnlocked()1827        // H-03: read+remove under stateLock so lock() can't reorder the remove.1828        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1829        val r = mutateCurrentSession(epoch) {1830            val rollback = entryList.toMutableList()1831            val before = entryList.size1832            entryList.removeAll { it.deletedAt != null }1833            R(rollback, before - entryList.size)1834        } ?: throw IllegalStateException("vault_locked")1835        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1836        r.removed1837    }18381839    /** Browser parity: number of active entries that dedupe would move to trash. */1840    suspend fun countDuplicateEntries(): Int = mutex.withLock {1841        val epoch = requireUnlocked()1842        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1843        // lock() can't race the read with state writes.1844        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1845            ?: throw IllegalStateException("vault_locked")1846    }18471848    /**1849     * Browser parity: soft-delete duplicate entries, keeping the most recently1850     * modified member of each group. Refuse in decoy mode so the duress vault is1851     * not reshaped into a forensically distinct state.1852     */1853    suspend fun deduplicateEntries(): Int = mutex.withLock {1854        val epoch = requireUnlocked()1855        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1856        val ready = mutateCurrentSession(epoch) {1857            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1858            val active = entryList.filter { it.deletedAt == null }1859            val keepIds = mutableSetOf<String>()1860            active.groupBy { it.duplicateKey() }.values.forEach { group ->1861                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1862            }1863            val rollback = entryList.toMutableList()1864            var removed = 01865            entryList = entryList.map { entry ->1866                if (entry.deletedAt == null && entry.id !in keepIds) {1867                    removed++1868                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1869                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1870                } else {1871                    entry1872                }1873            }.toMutableList()1874            R(rollback, removed)1875        } ?: throw IllegalStateException("vault_locked")1876        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1877        ready.removed1878    }18791880    fun getEntries(): List<VaultEntry> = mutateState {1881        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1882    }1883    fun getEntry(id: String): VaultEntry? = mutateState {1884        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1885    }1886    fun getDeletedEntries(): List<VaultEntry> = mutateState {1887        if (!sessionDataAvailable) emptyList()1888        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1889    }18901891    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1892    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1893        return mutateState {1894            if (!sessionDataAvailable) return@mutateState emptyList()1895            val active = entryList.filter { it.deletedAt == null }1896            if (tagFilter == null) return@mutateState active1897            val needle = tagFilter.trim().lowercase()1898            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1899        }1900    }19011902    /** Sorted, de-duplicated tags across all active entries. */1903    fun getDistinctTags(): List<String> = mutateState {1904        if (!sessionDataAvailable) return@mutateState emptyList()1905        entryList.asSequence()1906            .filter { it.deletedAt == null }1907            .flatMap { it.tags?.asSequence() ?: emptySequence() }1908            .map { it.trim().lowercase() }1909            .filter { it.isNotEmpty() }1910            .distinct()1911            .sorted()1912            .toList()1913    }19141915    /**1916     * Distinct non-blank values for an identity field across active entries,1917     * most-frequently-used first. Supports "username" and "email"; unknown field1918     * names return an empty list. Values keep original casing (deduped1919     * case-insensitively) so a suggestion matches what the user typed.1920     */1921    fun getDistinctFieldValues(field: String): List<String> {1922        val select: (VaultEntry) -> String? = when (field) {1923            "username" -> { e -> e.username }1924            "email" -> { e -> e.email }1925            else -> return emptyList()1926        }1927        return mutateState {1928            if (!sessionDataAvailable) return@mutateState emptyList()1929            entryList.asSequence()1930                .filter { it.deletedAt == null }1931                .mapNotNull { select(it)?.trim() }1932                .filter { it.isNotEmpty() }1933                .groupingBy { it.lowercase() }1934                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1935                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1936                }1937                .values1938                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1939                .map { it.first }1940        }1941    }19421943    fun getTombstones(): List<Tombstone> = mutateState {1944        if (!sessionDataAvailable) emptyList()1945        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1946    }1947    fun getVaultVersion(): Long = store.version()1948    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19491950    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1951        val expectedEpoch = currentEpoch()1952        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1953        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1954        // H-04: snapshots now carry the duress slot too — restore preserves1955        // the at-rest triad (A/B/D), not just the A/B pair.1956        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1957        val priorA = store.loadSlotA()1958        val priorB = store.loadSlotB()1959        val priorD = store.loadDecoySlot()1960        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1961        // slot write — the old code took this snapshot, then stranded it on1962        // every failure, polluting history with a "restore" entry that never1963        // restored anything. The history below is rolled back together with1964        // the slots if the commit fails.1965        val priorHistory = store.loadVaultHistory()1966        // P1-4 (codex): the recovery snapshot must land before we commit the1967        // slot pair, otherwise a failed restore cannot be rolled back to the1968        // pre-restore history. If the history write itself fails, refuse to1969        // proceed — the alternative is a successful restore with no undo path.1970        beforeDurableCommit()1971        if (!takeVaultSnapshot("restore", expectedEpoch)) {1972            return false1973        }1974        // H-04: legacy snapshots without vault_d, or vault_d that fails to1975        // decode, fall back to a fake duress of slot A's size — presence-1976        // indistinguishable from any other freshly-restored vault.1977        val nextD: StoredVault = restoredD ?: run {1978            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161979            val fakeBlob = ByteArray(target).also(rng::nextBytes)1980            val fakeSalt = VaultCrypto.generateSalt()1981            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1982        }1983        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1984            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1985            return false1986        }1987        lock()1988        true1989    }19901991    /**1992     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1993     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1994     * (a restore preserves the duress-shaped blob from history), not a fresh1995     * noise record. Any failure rolls back so the at-rest pair never holds a1996     * restored slot A against a pre-restore slot B. Caller is responsible for1997     * restoring the pre-snapshot history on a false return.1998     *1999     * H-04: duress slot is now part of the atomic triad — restored last so2000     * a duress-write failure rolls the A/B pair back too.2001     */2002    private fun commitRestoreAtomic(2003        nextA: StoredVault,2004        nextB: String,2005        nextD: StoredVault,2006        priorA: StoredVault?,2007        priorB: String?,2008        priorD: StoredVault?,2009        expectedEpoch: Long,2010    ): Boolean {2011        return withEpochCommit(expectedEpoch) {2012            if (!store.writeSlotA(nextA)) return@withEpochCommit false2013            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it2014            // must trigger rollback; previously a throw escaped without restoring2015            // the prior slot A and history.2016            try {2017                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")2018                // H-04: duress slot is part of the restore triad. Failures here2019                // roll A+B back so the at-rest state stays consistent.2020                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")2021                store.stampLocalMutation()2022            } catch (_: Exception) {2023                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))2024                return@withEpochCommit false2025            }2026            true2027        } ?: false2028    }20292030    // ---- Sync apply (does NOT advance the version) ------------------------20312032    /**2033     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists2034     * WITHOUT bumping the version counter, so a sync round cannot cascade into2035     * another outbound sync. Returns the number of locally-applied changes.2036     */2037    suspend fun applyMergedRemoteEntries(2038        remoteEntries: List<VaultEntry>,2039        remoteTombstones: List<Tombstone>,2040        expectedEpoch: Long? = null,2041    ): Int = mutex.withLock {2042        val epoch = expectedEpoch ?: requireUnlocked()2043        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02044        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS2045        // BEFORE any comparison against a local one. Without this a peer can2046        // send a far-future modifiedAt and permanently pin its version of an2047        // entry against every honest local edit. The browser clamps in the2048        // matching place (vault.ts applyMergedRemoteEntries).2049        val mergeNow = System.currentTimeMillis()2050        val clamped = remoteEntries.mapNotNull { entry ->2051            // MN-3: drop peer entries that decode cleanly but are semantically2052            // hollow (blank title, credential-less login, husk passkey) and2053            // re-cap passwordHistory before anything merges into the vault.2054            SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2055                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2056                deletedAt = entry.deletedAt?.let {2057                    SyncLimits.clampSyncTimestamp(it, mergeNow)2058                },2059            )2060        }2061        val tombMap = remoteTombstones.associate {2062            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2063        }2064        // H-03: build the new entryList under stateLock so a concurrent lock()2065        // cannot zero derivedKey/entryList between the byId read and the merge.2066        val applied = mutateCurrentSession(epoch) {2067            var n = 02068            val byId = entryList.associateBy { it.id }.toMutableMap()20692070            for (remote in clamped) {2071                val tomb = tombMap[remote.id]2072                if (tomb != null && tomb >= remote.modifiedAt) continue2073                val local = byId[remote.id]2074                if (local == null) {2075                    entryList.add(remote); byId[remote.id] = remote; n++2076                } else if (remote.modifiedAt > local.modifiedAt) {2077                    val i = entryList.indexOfFirst { it.id == local.id }2078                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2079                }2080            }20812082            for ((id, deletedAt) in tombMap) {2083                val local = byId[id] ?: continue2084                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2085                if (deletedAt < local.modifiedAt) continue2086                val i = entryList.indexOfFirst { it.id == id }2087                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2088            }2089            n2090        } ?: return@withLock 020912092        if (applied > 0) {2093            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2094            mutateIfCurrentEpoch(epoch) { publish() }2095            runCatching { LockedSyncSpoolHooks.refresh(this) }2096        }2097        applied2098    }20992100    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2101        // Require a fully live session before exposing a settings mutation.  The2102        // epoch is captured with the read so a lock/new-session boundary cannot2103        // let stale work publish into the replacement session.2104        val epoch = requireUnlocked()2105        val prior = mutateCurrentSession(epoch) {2106            val old = _settings.value2107            _settings.value = settings2108            old2109        } ?: throw IllegalStateException("vault_locked")21102111        val committed = try {2112            beforeDurableCommit()2113            // The durable write is inside the same epoch fence as the in-memory2114            // publication.  If lock() won first, this returns null and no stale2115            // settings reach storage or the public StateFlow.2116            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2117        } catch (_: Exception) {2118            false2119        }2120        if (!committed) {2121            // A live-session storage failure rolls back the public value.  A2122            // stale epoch is already reset by lock()/the new session and must2123            // not be allowed to overwrite that safe state.2124            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2125            throw IllegalStateException("settings_write_failed")2126        }2127    }21282129    // ---- Recovery words (vault metadata) ----------------------------------21302131    /** True once the user has confirmed their recovery words for this vault. */2132    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }21332134    /**2135     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2136     * Persisted without bumping the version — recovery metadata is local-only and2137     * is not an entry mutation paired devices should sync against.2138     */2139    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2140        val epoch = requireUnlocked()2141        // H-03: hash + escrow-related state fields set under stateLock so a2142        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2143        // M-04: the flags have to be live before the escrows are built — both seal2144        // currentPayload() — so capture the prior pair and put it back if the write2145        // never lands. Otherwise the session reports words set against a slot A2146        // that still holds no escrow, and the recovery screen never offers a retry.2147        val prior = mutateCurrentSession(epoch) {2148            val priorPair = recoveryWordHash to recoveryWordsSet2149            recoveryWordHash = recoveryWordsHash(words)2150            recoveryWordsSet = true2151            priorPair2152        } ?: return@withLock false2153        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2154        // (cross-platform recovery of the sync root key) and Android's whole-payload2155        // escrow (offline forgot-password restore of entries). Both derive their key2156        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2157        val persisted = runCatching {2158            persistSlotAWithEscrows(2159                buildRecoveryEscrow(words),2160                buildRootKeyEscrow(words),2161                expectedEpoch = epoch,2162            )2163        }.getOrDefault(false)2164        if (!persisted) {2165            mutateIfCurrentEpoch(epoch) {2166                recoveryWordHash = prior.first2167                recoveryWordsSet = prior.second2168            }2169        }2170        persisted2171    }21722173    /** Verify entered words against the stored recovery hash. */2174    fun verifyRecoveryWords(words: List<String>): Boolean {2175        return mutateState {2176            val stored = recoveryWordHash ?: return@mutateState false2177            stored == recoveryWordsHash(words)2178        }2179    }21802181    // ---- Recovery escrow (forgot-password) --------------------------------21822183    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2184    fun hasRecoveryEscrow(): Boolean =2185        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21862187    /**2188     * One-time self-heal: if this vault has a root key but no recovery escrow2189     * on disk (adopted vaults created before the escrow fix), build and persist2190     * it now. Called on every successful unlock; no-ops once the escrow exists.2191     */2192    private fun ensureRecoveryEscrow() {2193        if (hasRecoveryEscrow()) return2194        val rk = mutateState { rootKey?.copyOf() } ?: return2195        runCatching {2196            val words = rootKeyToWords(rk)2197            val escrow = buildRecoveryEscrow(words)2198            val rkEscrow = buildRootKeyEscrow(words)2199            if (escrow != null || rkEscrow != null) {2200                val prior = store.loadSlotA() ?: return@runCatching2201                store.writeSlotA(prior.copy(2202                    recoveryEscrow = escrow ?: prior.recoveryEscrow,2203                    rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2204                ))2205                mutateState {2206                    recoveryWordHash = recoveryWordsHash(words)2207                    recoveryWordsSet = true2208                }2209            }2210        }.also { VaultCrypto.zero(rk) }2211    }22122213    /**2214     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2215     * the master password. On success the slot-A plaintext sealed in the escrow is2216     * adopted as the live session (entries, rootKey, settings), but no Argon2id2217     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2218     * new password and finish unlocking. Returns false when there is no escrow, the2219     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2220     *2221     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2222     * detached vault key to return here — slot A is wrapped under the rotating2223     * Argon2id key — so this opens the escrowed payload directly.2224     */2225    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2226        val expectedEpoch = currentEpoch()2227        val slotA = store.loadSlotA() ?: return false2228        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false22292230        // Prefer Android's whole-payload escrow: a full offline restore of entries,2231        // root key, and settings. Present for Android-created vaults.2232        val fullEscrow = slotA.recoveryEscrow2233        if (fullEscrow != null) {2234            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2235            if (material != null) {2236                var keyBytes: ByteArray? = null2237                var plaintext: ByteArray? = null2238                var candidateRoot: ByteArray? = null2239                var stateCommitStarted = false2240                try {2241                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2242                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2243                    val payload = VaultCodec.decode(plaintext)2244                    // Stage every fallible operation before touching live session2245                    // fields.  In particular, a settings/provider/decode failure2246                    // cannot leave entries, flags, or a root key resident while the2247                    // public session remains locked.2248                    val candidateSettings = store.loadSettings()2249                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2250                        val decodedRoot = unb64(payload.rootKeyB64)2251                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2252                            // Do not let a malformed authenticated payload abandon2253                            // this decoded root-key candidate before the outer2254                            // finally block owns it.2255                            VaultCrypto.zero(decodedRoot)2256                            throw IllegalArgumentException("invalid recovery root key")2257                        }2258                        decodedRoot2259                    } else {2260                        generateRecoverableRootKey()2261                    }2262                    stateCommitStarted = true2263                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2264                        clearSessionStateLocked()2265                        entryList = payload.entries.toMutableList()2266                        duressConfigured = payload.duressConfigured2267                        recoveryWordHash = payload.recoveryWordHash2268                        recoveryWordsSet = payload.recoveryWordsSet2269                        rootKey = candidateRoot2270                        candidateRoot = null2271                        _settings.value = candidateSettings2272                        publish()2273                        // This is the authenticated recovery half-unlock boundary:2274                        // only set it after every candidate field is installed.2275                        sessionDataAvailable = true2276                        _isUnlocked.value = false2277                        true2278                    }2279                    if (adopted == true) return@withLock true2280                } catch (_: Exception) {2281                    // Wrong words, malformed payloads, provider failures, and2282                    // settings failures all fall through to the browser-compatible2283                    // root-key escrow.  If the state commit had started, invalidate2284                    // that epoch so no partial candidate survives.2285                    if (stateCommitStarted) {2286                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2287                    }2288                } finally {2289                    plaintext?.let(VaultCrypto::zero)2290                    VaultCrypto.zero(material)2291                    keyBytes?.let(VaultCrypto::zero)2292                    candidateRoot?.let(VaultCrypto::zero)2293                }2294            }2295        }22962297        // Browser-format vault (only the root-key escrow): recover the 32-byte root2298        // key (sync identity) and reset the password; entries return via sync. This2299        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2300        val rootEscrow = slotA.rootKeyEscrow2301        if (rootEscrow != null) {2302            var candidateRoot: ByteArray? = null2303            var stateCommitStarted = false2304            try {2305                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2306                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2307                val candidateSettings = store.loadSettings()2308                stateCommitStarted = true2309                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2310                    clearSessionStateLocked()2311                    entryList = mutableListOf()2312                    duressConfigured = false2313                    recoveryWordHash = recoveryWordsHash(words)2314                    recoveryWordsSet = true2315                    rootKey = candidateRoot2316                    candidateRoot = null2317                    _settings.value = candidateSettings2318                    publish()2319                    sessionDataAvailable = true2320                    _isUnlocked.value = false2321                    true2322                }2323                if (adopted == true) return@withLock true2324            } catch (_: Exception) {2325                if (stateCommitStarted) {2326                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2327                }2328            } finally {2329                candidateRoot?.let(VaultCrypto::zero)2330            }2331        }2332        return false2333    }23342335    /**2336     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2337     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2338     * words, mark the session fully unlocked, and publish. Returns false if called2339     * without a prior successful recovery unlock (no in-memory root key).2340     *2341     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2342     * into one step, since there is no detached vault key here.2343     */2344    suspend fun resetPasswordWithRecoveryWords(2345        words: List<String>,2346        newPassword: String,2347    ): Boolean = mutex.withLock {2348        val expectedEpoch = currentEpoch()2349        // H-03: read rootKey + entryList under stateLock before computing the new2350        // plaintext, then re-publish + swap the derived key under stateLock so a2351        // concurrent lock() cannot straddle the two fences.2352        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2353        val priorA = store.loadSlotA() ?: return false2354        var newKey: ByteArray? = null2355        var plaintext: ByteArray? = null2356        return try {2357            val newSalt = VaultCrypto.generateSalt()2358            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2359            plaintext = VaultCodec.encode(currentPayload())2360            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2361            // Rebuild both escrows against the recovered session so their salts rotate2362            // with the password reset; fall back to the prior escrows if rebuild fails2363            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2364            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2365            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2366            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2367            val next = StoredVault(2368                version = priorA.version,2369                salt = b64(newSalt),2370                blob = blob,2371                recoveryEscrow = freshRecovery,2372                rootKeyEscrow = freshRootKey,2373            )2374            // H-01: see changePassword — both rotations share the same2375            // commit-or-keep-old-key contract so a partial write can't strand2376            // the session on a key that no longer matches disk.2377            val priorB = store.loadSlotB()2378            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2379            // H-03 (P1-2 codex): zero the old live key and install the new one2380            // inside the same stateLock critical section so a concurrent lock()2381            // can't observe the in-between.2382            if (mutateIfCurrentEpoch(expectedEpoch) {2383                derivedKey?.let(VaultCrypto::zero)2384                derivedKey = newKey; newKey = null2385                publish()2386                _isUnlocked.value = true2387            } == null) return false2388            true2389        } catch (_: Exception) {2390            false2391        } finally {2392            newKey?.let(VaultCrypto::zero)2393            plaintext?.let(VaultCrypto::zero)2394        }2395    }23962397    /**2398     * Seal the current slot-A plaintext under a key derived from the 12 BIP392399     * recovery [words] (Android offline-restore escrow). IKM is the browser's2400     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2401     * Returns null for a checksum-invalid phrase.2402     */2403    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2404        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2405        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2406        var keyBytes: ByteArray? = null2407        var plaintext: ByteArray? = null2408        return try {2409            val salt = VaultCrypto.generateSalt()2410            val recoveryKey = deriveRecoveryKey(material, salt)2411            keyBytes = recoveryKey.encoded2412            plaintext = VaultCodec.encode(currentPayload())2413            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2414            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2415        } finally {2416            VaultCrypto.zero(material)2417            keyBytes?.let(VaultCrypto::zero)2418            plaintext?.let(VaultCrypto::zero)2419        }2420    }24212422    /**2423     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2424     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2425     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2426     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2427     * the same root key on the browser. Returns null with no in-memory root key or2428     * a checksum-invalid phrase.2429     */2430    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2431        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2432        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2433        val rk = mutateState { rootKey?.copyOf() } ?: run {2434            VaultCrypto.zero(material)2435            return null2436        }2437        var keyBytes: ByteArray? = null2438        var plaintext: ByteArray? = null2439        return try {2440            val salt = VaultCrypto.generateSalt()2441            val recoveryKey = deriveRecoveryKey(material, salt)2442            keyBytes = recoveryKey.encoded2443            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2444            // combined = nonce(12) || ciphertext || tag(16)2445            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2446            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2447            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2448            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2449            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2450            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2451        } finally {2452            VaultCrypto.zero(rk)2453            VaultCrypto.zero(material)2454            keyBytes?.let(VaultCrypto::zero)2455            plaintext?.let(VaultCrypto::zero)2456        }2457    }24582459    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2460    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2461        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2462        var keyBytes: ByteArray? = null2463        var pt: ByteArray? = null2464        return try {2465            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2466            keyBytes = recoveryKey.encoded2467            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2468            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2469            hexToRootKey(String(pt, Charsets.US_ASCII))2470        } catch (_: Exception) {2471            null2472        } finally {2473            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2474            // IKM-equivalent of the vault identity. material+keyBytes were2475            // already zeroed; pt was leaking.2476            VaultCrypto.zero(material)2477            keyBytes?.let(VaultCrypto::zero)2478            pt?.let(VaultCrypto::zero)2479        }2480    }24812482    /**2483     * Re-seal slot A from the current in-memory state under the live key, carrying2484     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2485     * in decoy mode (recovery is never offered for the decoy) and persists without2486     * a version bump, like the recovery-words metadata write it replaces.2487     */2488    private fun persistSlotAWithEscrows(2489        recoveryEscrow: RecoveryEscrow?,2490        rootKeyEscrow: RootKeyEscrow?,2491        expectedEpoch: Long? = null,2492    ): Boolean {2493        val snapshot = snapshotForPersist(expectedEpoch)2494            ?: throw IllegalStateException("vault_locked_or_stale")2495        if (snapshot.decoy) {2496            VaultCrypto.zero(snapshot.key)2497            return false2498        }2499        try {2500            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2501            val plaintext = VaultCodec.encode(snapshot.payload)2502            val blob = try {2503                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2504            } finally {2505                // The escrow path used to drop this reference before zeroing it;2506                // keep it live through the fill on both success and failure.2507                VaultCrypto.zero(plaintext)2508            }2509            val next = prior.copy(2510                blob = blob,2511                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2512                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2513            )2514            beforeDurableCommit()2515            return withEpochCommit(snapshot.epoch) {2516                if (!store.writeSlotA(next)) return@withEpochCommit false2517                try {2518                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2519                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2520                    // the duress slot too. A real decoy is left alone; a fake is2521                    // re-sized to slot A's new ciphertext length.2522                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2523                } catch (e: Exception) {2524                    store.writeSlotA(prior)2525                    throw e2526                }2527                true2528            } ?: false2529        } finally {2530            VaultCrypto.zero(snapshot.key)2531        }2532    }25332534    // ---- Internals --------------------------------------------------------25352536    private fun requireUnlocked(): Long {2537        // H-03: read derivedKey atomically with [lock]'s reset; without it,2538        // [lock] from a background lifecycle event can run between this read2539        // and the caller's first state touch.2540        stateLock.lock()2541        try {2542            if (derivedKey == null) throw IllegalStateException("vault_locked")2543            return sessionEpoch2544        } finally {2545            stateLock.unlock()2546        }2547    }25482549    /** A locked or half-adopted session must not expose secret-bearing fields. */2550    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25512552    private fun countDuplicateEntriesLocked(): Int {2553        val seen = mutableMapOf<String, VaultEntry>()2554        var duplicates = 02555        for (entry in entryList.filter { it.deletedAt == null }) {2556            val key = entry.duplicateKey()2557            val existing = seen[key]2558            if (existing == null) {2559                seen[key] = entry2560            } else {2561                duplicates++2562                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2563                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2564                if (entryStamp > existingStamp) seen[key] = entry2565            }2566        }2567        return duplicates2568    }25692570    private fun VaultEntry.duplicateKey(): String {2571        if (type == "note") return "note|${title.lowercase()}"2572        val passkeyData = passkey2573        if (passkeyData != null) {2574            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2575        }2576        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2577    }25782579    /**2580     * Capture the current slot pair (and metadata) into the history list.2581     * Returns true when the history write landed, false when it didn't — callers2582     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2583     * must check the result and roll back if it does not.2584     */2585    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2586        val epoch = expectedEpoch ?: currentEpoch()2587        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2588        val slotA = store.loadSlotA() ?: return false2589        val vaultA = VaultCodec.encodeSlot(slotA)2590        val vaultB = store.loadSlotB() ?: slotA.blob2591        // H-04: capture the duress slot so a restore preserves the at-rest2592        // triad, not just the A/B pair. The duress slot is always present2593        // (real or fake), so loadDecoySlot() returning null is a logic error2594        // — fall through to a fake duress in that case rather than silently2595        // losing the record from the snapshot.2596        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2597            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162598            val fakeBlob = ByteArray(target).also(rng::nextBytes)2599            val fakeSalt = VaultCrypto.generateSalt()2600            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2601        }2602        val history = store.loadVaultHistory()2603        val latest = history.snapshots.firstOrNull()2604        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2605        val entryCount = mutateIfCurrentEpoch(epoch) {2606            if (derivedKey != null && !decoyMode) {2607                bucketEntryCount(entryList.count { it.deletedAt == null })2608            } else {2609                null2610            }2611        } ?: return false2612        return try {2613            val snapshot = VaultSnapshot(2614                id = UUID.randomUUID().toString(),2615                savedAt = System.currentTimeMillis(),2616                reason = reason,2617                vault_a = vaultA,2618                vault_b = vaultB,2619                vault_d = vaultD,2620                entryCount = entryCount,2621            )2622            // P1-4 (codex): return the history-write boolean to callers so a2623            // false return no longer leaves the snapshot out of history while2624            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2625            withEpochCommit(epoch) {2626                store.writeVaultHistory(2627                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2628                )2629            } ?: false2630        } catch (_: Exception) {2631            false2632        }2633    }26342635    private fun bucketEntryCount(n: Int): Int = when {2636        n <= 0 -> 02637        n <= 10 -> 102638        n <= 25 -> 252639        n <= 50 -> 502640        n <= 100 -> 1002641        n <= 250 -> 2502642        n <= 500 -> 5002643        else -> ((n + 499) / 500) * 5002644    }26452646    /**2647     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2648     * so it is stored explicitly and survives the sync wire format rather than being2649     * re-derived at render time. Non-card entries pass through unchanged.2650     *2651     * M-03: never overwrite a stored last4 with null. A card kept only as last42652     * (e.g. after a PeachScript import that had no PAN) must keep that value;2653     * re-deriving from an absent cardNumber would silently zero the field.2654     */2655    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2656        if (!entry.isCard()) return entry2657        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2658        val derived = if (digits.length >= 4) digits.takeLast(4) else null2659        return entry.copy(last4 = derived ?: entry.last4)2660    }26612662    /**2663     * On unlock, hard-delete trashed entries older than the retention window.2664     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2665     * (the app's "Never" option), so nothing is purged. Persists without a2666     * version bump and without touching the publish state (caller publishes).2667     */2668    private fun autoPurgeExpiredTrash() {2669        val days = _settings.value.trashRetentionDays ?: return2670        if (days <= 0) return2671        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2672        val before = entryList.size2673        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2674        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2675    }26762677    /**2678     * Browser parity (P1): merge orphaned passkey-only entries into matching2679     * login entries that share the same registrable domain. A passkey-only2680     * entry has passkey fields (rpId + credentialId) but no password. Runs on2681     * unlock so consolidation is idempotent and cheap.2682     */2683    private fun consolidatePasskeyEntries() {2684        val passkeyOnly = entryList.filter { entry ->2685            entry.deletedAt == null &&2686                entry.isLogin() &&2687                entry.password.isNullOrBlank() &&2688                !entry.rpId.isNullOrBlank() &&2689                !entry.credentialId.isNullOrBlank()2690        }2691        if (passkeyOnly.isEmpty()) return2692        var changed = false2693        val now = System.currentTimeMillis()2694        for (pkEntry in passkeyOnly) {2695            val rpId = pkEntry.rpId ?: continue2696            val target = entryList.firstOrNull { candidate ->2697                candidate.deletedAt == null &&2698                    candidate.id != pkEntry.id &&2699                    candidate.isLogin() &&2700                    candidate.passkey == null &&2701                    candidate.credentialId.isNullOrBlank() &&2702                    !candidate.password.isNullOrBlank() &&2703                    listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2704                        .any { com.peachpasswords.shared.extractDomain(it) == rpId }2705            } ?: continue2706            val idx = entryList.indexOfFirst { it.id == target.id }2707            if (idx < 0) continue2708            entryList[idx] = target.copy(2709                rpId = pkEntry.rpId,2710                rpName = pkEntry.rpName ?: target.rpName,2711                credentialId = pkEntry.credentialId,2712                publicKeyCose = pkEntry.publicKeyCose,2713                privateKeyJwk = pkEntry.privateKeyJwk,2714                userHandle = pkEntry.userHandle,2715                userName = pkEntry.userName,2716                userDisplayName = pkEntry.userDisplayName,2717                algorithm = pkEntry.algorithm,2718                signCount = pkEntry.signCount,2719                lastUsedAt = pkEntry.lastUsedAt,2720                modifiedAt = now,2721            )2722            // Soft-delete the orphan so it lands in trash (recoverable).2723            val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2724            if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2725            changed = true2726        }2727        if (changed) runCatching { persistSlotA(bumpVersion = false) }2728    }27292730    /** The slot-A plaintext envelope for the current in-memory session. */2731    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }27322733    private fun currentPayloadLocked(): VaultPayload =2734        VaultPayload(2735            entries = entryList.toList(),2736            duressConfigured = duressConfigured,2737            rootKeyB64 = rootKey?.let(::b64) ?: "",2738            recoveryWordHash = recoveryWordHash,2739            recoveryWordsSet = recoveryWordsSet,2740        )27412742    /**2743     * Adopt the payload's root key, lazily minting one for legacy payloads.2744     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2745     * a root key is not a mutation paired devices should sync against.2746     */2747    private fun ensureRootKey(payload: VaultPayload) {2748        if (payload.rootKeyB64.isNotBlank()) {2749            rootKey = unb64(payload.rootKeyB64)2750            return2751        }2752        rootKey = generateRecoverableRootKey()2753        runCatching { persistSlotA(bumpVersion = false) }2754    }27552756    private fun publish() {2757        _entries.value = entryList.filter { it.deletedAt == null }2758    }27592760    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2761        var plaintext = VaultCodec.encode(currentPayload())2762        return try {2763            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2764            StoredVault(version = 1, salt = b64(salt), blob = blob)2765        } finally {2766            // M-05: full vault plaintext is allocated here once per createVault.2767            // Zeroing in finally keeps the transient copy off the heap after the2768            // caller either succeeds or rolls back via lock().2769            VaultCrypto.zero(plaintext)2770        }2771    }27722773    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2774        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2775        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2776        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2777        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2778        val noise = ByteArray(approxBytes).also(rng::nextBytes)2779        return store.writeSlotB(b64(noise))2780    }27812782    /** H-04: the ciphertext length slot A is using right now, so the duress2783     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2784     *  length equals ciphertext length, so this also matches slot A's plaintext. */2785    private fun matchingCiphertextLength(slotA: StoredVault): Int =2786        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27872788    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2789     *  bytes. Replaces a real decoy when the user clears duress, and is the2790     *  initial duress slot when no decoy is configured — so the record's2791     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2792     *  matches slot A's blob length. */2793    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2794        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162795        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2796        val fakeSalt = VaultCrypto.generateSalt()2797        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2798    }27992800    /** H-04: keep the duress record present on every persist. A real decoy is2801     *  left alone (it was padded at setDuressPassphrase time, and we don't have2802     *  the duress key here to re-seal it) — VaultStore re-pads both slot2803     *  records to the longer of the two on every write, which is what keeps the2804     *  pair the same length after slot A crosses a padding bucket2805     *  (PAI-2026-004). No real decoy → a fresh fake of slot A's size. */2806    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2807        val isConfigured = mutateState { duressConfigured }2808        return if (isConfigured) true else writeFakeDuressSlot(slotA)2809    }28102811    /**2812     * H-04: convert a browser-shaped duress slot to Android StoredVault2813     * without decrypting. The browser seals slot "b" under the duress2814     * passphrase; decrypting with the main password silently drops it.2815     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2816     * the salt from hex to base64.2817     */2818    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2819        val salt = unhex(b.salt)2820        val nonce = unhex(b.nonce)2821        val ct = unhex(b.ciphertext)2822        val tag = unhex(b.tag)2823        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2824            System.arraycopy(nonce, 0, it, 0, nonce.size)2825            System.arraycopy(ct, 0, it, nonce.size, ct.size)2826            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2827        }2828        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2829    }28302831    /** Re-seal slot A from the current in-memory state under the live key. */2832    private fun persistSlotA(2833        bumpVersion: Boolean,2834        snapshotReason: String? = "local_save",2835        expectedEpoch: Long? = null,2836    ): Boolean {2837        val snapshot = snapshotForPersist(expectedEpoch)2838            ?: throw IllegalStateException("vault_locked_or_stale")2839        return try {2840            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2841            if (snapshot.decoy) {2842                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2843                val plaintext = VaultCodec.encode(snapshot.payload)2844                val blob = try {2845                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2846                } finally {2847                    // Keep the last reference alive until the zero, including the2848                    // successful encrypt path (M-05).2849                    VaultCrypto.zero(plaintext)2850                }2851                beforeDurableCommit()2852                return withEpochCommit(snapshot.epoch) {2853                    store.writeDecoySlot(prior.copy(blob = blob))2854                } ?: false2855            }2856            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2857            // H-01: captured BEFORE the noise write so a stamp failure can restore2858            // the prior slot B alongside the prior slot A. Slot B is always2859            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2860            // leaves (new A, new B, old version) on disk — partial commit.2861            val priorB = store.loadSlotB()2862            beforeDurableCommit()2863            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2864            val plaintext = VaultCodec.encode(snapshot.payload)2865            val blob = try {2866                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2867            } finally {2868                // Do not drop [plaintext] before fill(0): the serialized vault2869                // contains every entry password and is the primary M-05 target.2870                VaultCrypto.zero(plaintext)2871            }2872            val next = prior.copy(blob = blob)2873            // The epoch check and the entire A/B/D/version commit share one brief2874            // stateLock critical section. lock() either wins before this block or2875            // waits until the already-committed write is complete; it can never2876            // clear the session between the check and the durable write.2877            withEpochCommit(snapshot.epoch) {2878                if (!store.writeSlotA(next)) return@withEpochCommit false2879                try {2880                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2881                    // H-04: duress slot stays present (real one untouched, fake one2882                    // re-padded to slot A's current size on every persist).2883                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2884                    if (bumpVersion) store.stampLocalMutation()2885                } catch (e: Exception) {2886                    store.writeSlotA(prior)2887                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2888                    throw e2889                }2890                true2891            } ?: false2892        } finally {2893            // Snapshot keys are copies, so lock() may zero the live key while this2894            // operation is still deriving/encrypting; always scrub our copy too.2895            VaultCrypto.zero(snapshot.key)2896        }2897    }28982899    /**2900     * H-01: durably commit a slot-A rotation by writing slot A, its2901     * size-matched noise slot B, and the local-mutation stamp in order. Any2902     * failure rolls the previously-committed steps back so the at-rest pair2903     * matches what was there before the call. Returns true only when every2904     * step succeeded — callers must NOT swap their session key on false.2905     */2906    private fun commitVaultRotation(2907        priorA: StoredVault,2908        priorB: String?,2909        nextA: StoredVault,2910        expectedEpoch: Long,2911    ): Boolean {2912        beforeDurableCommit()2913        return withEpochCommit(expectedEpoch) {2914            if (!store.writeSlotA(nextA)) return@withEpochCommit false2915            // P1-1 (codex): the noise write's boolean AND any thrown exception from2916            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2917            // escaped the helper and left slot A committed.2918            try {2919                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2920                // H-04: a password rotation changes slot A's plaintext size (new2921                // salt → re-encrypted blob), so the duress slot must be re-padded2922                // alongside. writeCompanionDuressSlot leaves a real decoy alone2923                // and re-emits a fake of slot A's size otherwise.2924                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2925                store.stampLocalMutation()2926            } catch (_: Exception) {2927                rollbackRotation(priorA, priorB)2928                return@withEpochCommit false2929            }2930            true2931        } ?: false2932    }29332934    /**2935     * Best-effort undo of the writes [commitVaultRotation] may have committed.2936     * Each step runs independently — a failed rollback must not mask the2937     * original commit failure. Log is a throwing stub off-device.2938     */2939    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2940        val failures = mutableListOf<String>()2941        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2942            .onFailure { failures += "writeSlotA: $it" }2943        if (priorB != null) {2944            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2945                .onFailure { failures += "writeSlotB: $it" }2946        }2947        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2948    }29492950    /** Persist a local mutation, rolling the in-memory list back on failure. */2951    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2952        try {2953            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2954                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2955                throw IllegalStateException("storage_write_failed")2956            }2957        } catch (e: Exception) {2958            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2959            throw e2960        }2961        // A lock can win in the small interval after the durable commit and2962        // before publication. The epoch check prevents pre-lock entries from2963        // being republished into a newly locked (or newly unlocked) session.2964        mutateIfCurrentEpoch(expectedEpoch) { publish() }2965        runCatching { LockedSyncSpoolHooks.refresh(this) }2966    }2967}