VaultRepository.kt

140.5 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import 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            // ponytail: only re-pads at configuration time — if slot A grows past982            // its initial bucket (entries cross 4096/16384/etc.), the real decoy983            // stays at the smaller bucket. Re-padding on every persist requires984            // keeping the duress passphrase in memory, which we don't.985            val slotA = store.loadSlotA()986            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0987            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!988            val blob = try {989                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))990            } finally {991                VaultCrypto.zero(plaintext)992                // Padding creates a second array; scrub the original encoded993                // payload too rather than dropping its last reference untouched.994                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)995                basePlaintext = null996                key?.let(VaultCrypto::zero)997                key = null998            }999            beforeDurableCommit()1000            val ok = withEpochCommit(expectedEpoch) {1001                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))1002            } ?: false1003            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {1004                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1005            } else if (ok) {1006                return false1007            }1008            ok1009        } catch (_: Exception) {1010            false1011        } finally {1012            // Cover failures before the encrypt block is entered (for example a1013            // codec/copy exception) as well as any early return after key derivation.1014            key?.let(VaultCrypto::zero)1015            basePlaintext?.let(VaultCrypto::zero)1016        }1017    }101810191020    /**1021     * Export the decoy slot as browser-shaped JSON for adoption snapshots.1022     *1023     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all1024     * lowercase hex — identical to the browser's `duress_slot` wire format — so1025     * any receiver (browser or Android) can install it via the existing1026     * passthrough. Returns null when duress is not configured, no decoy exists,1027     * or any conversion failure occurs. Never decrypts.1028     */1029    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1030        if (!duressConfigured) return null1031        val slot = store.loadDecoySlot() ?: return null1032        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1033    }10341035    /**1036     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1037     *1038     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1039     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1040     * byte arrays are zeroed in finally.1041     */1042    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1043        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1044        // serialized — kotlinx-serialization's encodeDefaults = false would1045        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1046        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1047        // silently dropping the duress slot on Android→browser adoptions.1048        var salt: ByteArray? = null1049        var blob: ByteArray? = null1050        var nonce: ByteArray? = null1051        var ctAndTag: ByteArray? = null1052        var ct: ByteArray? = null1053        var tag: ByteArray? = null1054        try {1055            salt = unb64(slot.salt)1056            blob = unb64(slot.blob)1057            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1058            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1059            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1060            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1061            val obj = JsonObject(mapOf(1062                "version" to JsonPrimitive(1),1063                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1064                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1065                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1066                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1067            ))1068            return obj.toString()1069        } finally {1070            salt?.let(VaultCrypto::zero)1071            blob?.let(VaultCrypto::zero)1072            nonce?.let(VaultCrypto::zero)1073            ctAndTag?.let(VaultCrypto::zero)1074            ct?.let(VaultCrypto::zero)1075            tag?.let(VaultCrypto::zero)1076        }1077    }10781079    /**1080     * Install a browser-shaped duress slot received through adoption.1081     *1082     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1083     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1084     * writes the result to the decoy slot. Epoch-fenced like the Codex1085     * restore path so a concurrent lock() cannot strand the write.1086     *1087     * Returns false on invalid input, no live session, or write failure.1088     */1089    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1090        val b = BrowserSlot.parse(json) ?: return false1091        var stored: StoredVault? = null1092        var salt: ByteArray? = null1093        var nonce: ByteArray? = null1094        var ct: ByteArray? = null1095        var tag: ByteArray? = null1096        var blob: ByteArray? = null1097        try {1098            salt = unhex(b.salt)1099            nonce = unhex(b.nonce)1100            ct = unhex(b.ciphertext)1101            tag = unhex(b.tag)1102            blob = ByteArray(nonce.size + ct.size + tag.size).also {1103                System.arraycopy(nonce, 0, it, 0, nonce.size)1104                System.arraycopy(ct, 0, it, nonce.size, ct.size)1105                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1106            }1107            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1108            val expectedEpoch = sessionEpoch1109            beforeDurableCommit()1110            withEpochCommit(expectedEpoch) {1111                store.writeDecoySlot(stored)1112            } ?: false1113        } catch (_: Exception) {1114            false1115        } finally {1116            salt?.let(VaultCrypto::zero)1117            nonce?.let(VaultCrypto::zero)1118            ct?.let(VaultCrypto::zero)1119            tag?.let(VaultCrypto::zero)1120            blob?.let(VaultCrypto::zero)1121        }1122    }11231124    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1125    suspend fun clearDuress(): Boolean = mutex.withLock {1126        // H-04: presence-indistinguishable — replace the real decoy with a fake1127        // blob of slot A's size rather than removing the duress record. The1128        // record stays on disk at the same length, so an at-rest observer1129        // cannot tell "duress cleared" from "no duress ever configured".1130        val expectedEpoch = currentEpoch()1131        val replaced = withEpochCommit(expectedEpoch) {1132            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1133                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1134        } ?: false1135        if (!replaced) return@withLock false1136        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1137        // duressConfigured write so a concurrent lock() cannot land between them.1138        if (!isSessionEpochCurrent(expectedEpoch)) return false1139        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1140        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1141            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1142        }1143        true1144    }11451146    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1147    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1148        var current: Throwable? = error1149        while (current != null) {1150            if (current is javax.crypto.AEADBadTagException) return true1151            // Some Android providers expose a GCM tag failure as the parent1152            // BadPaddingException rather than the AEAD-specific subtype.  A1153            // short ciphertext is an IllegalBlockSizeException and therefore1154            // remains an internal/malformed-payload Error below.1155            if (current is javax.crypto.BadPaddingException) return true1156            current = current.cause1157        }1158        return false1159    }11601161    /** Unlock with an already-derived AES-256 key (biometric path). */1162    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1163        val expectedEpoch = currentEpoch()1164        val slotA = try {1165            store.loadSlotA()1166        } catch (_: Exception) {1167            return@withLock UnlockResult.Error("unlock_key_storage_failed")1168        } ?: return@withLock UnlockResult.Error("no_vault")1169        // Keep the caller-owned key separate from the staged session key. The1170        // biometric caller always zeroes its input in a finally block; adoption1171        // must therefore own a copy and use the same all-or-nothing path as a1172        // password unlock rather than publishing a partially decoded session.1173        var stagedKey: ByteArray? = key.copyOf()1174        return try {1175            val encrypted = try {1176                unb64(slotA.blob)1177            } catch (_: Exception) {1178                return@withLock UnlockResult.Error("invalid_vault_blob")1179            }1180            // A GCM record must include nonce + authentication tag.  Providers1181            // differ on whether a shorter record is reported as BadPadding or1182            // AEADBadTag; reject it before decrypt so malformed storage cannot be1183            // misreported as a user-entered wrong key.1184            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1185                return@withLock UnlockResult.Error("invalid_vault_blob")1186            }1187            val plaintext = try {1188                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1189            } catch (e: Exception) {1190                if (isGcmAuthenticationFailure(e)) {1191                    return@withLock UnlockResult.WrongPassword1192                }1193                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1194            }1195            val payload = try {1196                VaultCodec.decode(plaintext)1197            } catch (_: Exception) {1198                return@withLock UnlockResult.Error("invalid_vault_payload")1199            } finally {1200                VaultCrypto.zero(plaintext)1201            }1202            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1203            val result = adoptSlotA(candidate, payload, expectedEpoch)1204            if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1205            result1206        } finally {1207            stagedKey?.let(VaultCrypto::zero)1208        }1209    }12101211    fun lock() {1212        // Never snapshot a mutation that has changed entryList but has not yet1213        // crossed its durable epoch fence. Successful writes refresh the spool1214        // before releasing this mutex; an in-flight writer therefore makes this1215        // lock skip the convenience refresh and fail closed to the prior cache.1216        if (mutex.tryLock()) {1217            try {1218                runCatching { LockedSyncSpoolHooks.refresh(this) }1219            } finally {1220                mutex.unlock()1221            }1222        }1223        // H-03: must observe derivedKey/entryList atomically with any in-flight1224        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1225        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1226        // guards just the brief state reset. Advancing the epoch first invalidates1227        // every operation that captured this session before it reaches a later1228        // durable commit or publication.1229        stateLock.lock()1230        try {1231            sessionEpoch = nextSessionEpoch(sessionEpoch)1232            clearSessionStateLocked()1233            _uiGateLocked.value = false1234        } finally {1235            stateLock.unlock()1236        }1237    }12381239    /**1240     * Enter sync-retained background mode: the UI shows the lock screen but the1241     * vault stays fully unlocked in memory so [PeachSyncService] can complete1242     * sync sessions. Called from [PeachApplication.onStop] when background sync1243     * is enabled. A subsequent full [lock] (auto-lock timer, manual lock)1244     * supersedes this and zeroes everything.1245     */1246    fun lockUiGate() {1247        if (_isUnlocked.value) _uiGateLocked.value = true1248    }12491250    /** Dismiss the UI gate after the user authenticates on resume. */1251    fun clearUiGate() {1252        _uiGateLocked.value = false1253    }12541255    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12561257    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1258    // is a stub off-device).1259    @VisibleForTesting1260    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12611262    /**1263     * Call immediately before launching a system Activity (a SAF file1264     * picker/save dialog, a share sheet) that briefly takes the whole process1265     * out of the foreground as an expected part of an in-app flow — not a1266     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1267     * two apart on its own (it fires for both), and without this, exporting or1268     * importing a file silently locked and wiped the vault mid-flow the moment1269     * the picker took over, before its result ever reached the app.1270     *1271     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1272     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1273     */1274    fun expectTransientBackground() {1275        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1276    }12771278    /**1279     * Consumed once by the next background-lock check; see [expectTransientBackground].1280     * Returns false (and clears the flag) if the suppression has expired or was1281     * never set — a genuine background event must always lock.1282     */1283    fun consumeTransientBackgroundSuppression(): Boolean {1284        val until = suppressNextBackgroundLockUntil1285        val active = until != 0L && nowMs() < until1286        suppressNextBackgroundLockUntil = 0L1287        return active1288    }12891290    // ---- Sync identity ----------------------------------------------------12911292    /**1293     * The vault's stable sync master key (a copy). Caller must zero it. Backs1294     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1295     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1296     */1297    /**1298     * HOLE 5 — the duress vault never syncs.1299     *1300     * [commitDuressUnlock] gives the decoy session a root key so its own codex1301     * and recovery paths work, which means (unlike the browser, where the decoy1302     * path simply has no root key) the sync surface has to refuse explicitly.1303     * Without this the decoy vault would present a second, different vault_id1304     * to a paired device, and a coerced user opening it would make sync visibly1305     * fail on their other devices — a duress indicator, which is the one thing1306     * a duress vault must never be.1307     */1308    // M-04: the "null when locked" half of the contract was documented but never1309    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1310    // [unlockWithRecoveryWords], which installs a root key with no password key1311    // yet) and in a create that failed after staging. Sync must fail closed1312    // there, so the authoritative unlocked flag gates it, not just decoyMode.1313    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)13141315    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1316        if (decoyMode || !_isUnlocked.value) null1317        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1318    }13191320    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1321        mutateState { sessionEpoch == epoch }13221323    internal fun unlockedSessionEpoch(): Long? = mutateState {1324        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1325    }13261327    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13281329    /**1330     * The current session's root key, decoy included. For local-only uses that1331     * must behave identically in a decoy session — Codex backup and recovery1332     * words — because a feature that errors under duress is itself a duress1333     * tell. In a decoy session this is the DECOY's root key (see1334     * [commitDuressUnlock]), never the real vault's, so neither surface can1335     * leak the real vault. Anything that leaves the device must use1336     * [syncMasterKey] instead.1337     */1338    fun sessionRootKey(): ByteArray? = mutateState {1339        if (!sessionDataAvailable) null else rootKey?.copyOf()1340    }13411342    /** Stable per-vault id two paired devices must share to sync. Null when1343     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1344    fun syncVaultId(): String? = mutateState {1345        if (decoyMode || !_isUnlocked.value) null1346        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1347    }13481349    /**1350     * Browser parity (MV-8): rotate the sync identity by replacing the stable1351     * root key. Entries remain under the master-password key, while sync1352     * credentials, vault_id, and recovery words all change.1353     */1354    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1355        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1356            ?: return false1357        installRootKey(generateRecoverableRootKey(), expectedEpoch)1358    }13591360    /**1361     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1362     * keeping every local entry.1363     *1364     * Sync identity (vault id, signing keys) derives from the root key, and1365     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1366     * created vaults can never reconcile. Taking the other side's root key is1367     * what makes them one vault; from the next sync on, both entry sets merge.1368     *1369     * Entries survive because they are sealed under the password-derived key,1370     * not the root key — the same property [rotateSyncIdentity] relies on.1371     *1372     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1373     * this device's previous recovery words stop working for good, and its new1374     * words are the source vault's words, because words are a deterministic1375     * encoding of the root key.1376     */1377    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1378        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1379            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1380        }1381        val expectedEpoch = mutateState {1382            when {1383                derivedKey == null -> null1384                decoyMode -> Long.MIN_VALUE1385                else -> sessionEpoch1386            }1387        }1388        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1389        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1390        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13911392        val incoming = hexToRootKey(rootKeyHex)1393        if (incoming.size != VaultCrypto.KEY_SIZE) {1394            VaultCrypto.zero(incoming)1395            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1396        }1397        // Refuse before committing rather than leave this device holding a vault1398        // whose recovery words cannot be typed back. generateRecoverableRootKey1399        // screens its own keys for this; a key arriving from a peer has not been.1400        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1401        if (!roundTrips) {1402            VaultCrypto.zero(incoming)1403            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1404        }14051406        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1407        return if (installRootKey(incoming, expectedEpoch)) {1408            AdoptVaultResult.Success(vaultId)1409        } else {1410            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1411        }1412    }14131414    /**1415     * The recovery words [rootKeyHex] will produce once joined, or null if it is1416     * not a usable root key. Lets the join flow show the user the words that are1417     * about to replace theirs *before* anything is committed.1418     */1419    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1420        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1421        val bytes = hexToRootKey(rootKeyHex)1422        return try {1423            val words = rootKeyToWords(bytes)1424            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1425        } catch (_: Exception) {1426            null1427        } finally {1428            VaultCrypto.zero(bytes)1429        }1430    }14311432    /**1433     * Swap the vault's root key, keeping every entry, and re-escrow under the1434     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1435     * [mutex].1436     */1437    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1438        // H-03: snapshot the live fields under stateLock so the success and the1439        // rollback paths both see consistent prior/new state. Writing the new1440        // rootKey/recovery fields is inside its own mutateState at the end.1441        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1442        // — holding live references would let a zero-on-failure operation also1443        // wipe the session's still-live key arrays.1444        data class Snapshot(1445            val key: ByteArray,1446            val priorRootKey: ByteArray,1447            val priorRecoveryHash: String?,1448            val priorRecoverySet: Boolean,1449        )1450        val snap = mutateIfCurrentEpoch(expectedEpoch) {1451            val k = derivedKey ?: return@mutateIfCurrentEpoch null1452            if (decoyMode) return@mutateIfCurrentEpoch null1453            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1454            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1455        } ?: run {1456            VaultCrypto.zero(nextRootKey)1457            return false1458        }1459        val priorSlot = store.loadSlotA() ?: run {1460            VaultCrypto.zero(snap.key)1461            VaultCrypto.zero(snap.priorRootKey)1462            VaultCrypto.zero(nextRootKey)1463            return false1464        }1465        val nextWords = rootKeyToWords(nextRootKey)14661467        return try {1468            beforeDurableCommit()1469            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1470            if (mutateIfCurrentEpoch(expectedEpoch) {1471                rootKey = nextRootKey1472                recoveryWordHash = recoveryWordsHash(nextWords)1473                recoveryWordsSet = false1474            } == null) throw IllegalStateException("stale_root_rotation")14751476            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1477                ?: throw IllegalStateException("recovery_escrow_failed")1478            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1479                ?: throw IllegalStateException("root_escrow_failed")1480            val plaintext = VaultCodec.encode(currentPayload())1481            val blob = try {1482                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1483            } finally {1484                VaultCrypto.zero(plaintext)1485            }1486            val nextSlot = priorSlot.copy(1487                blob = blob,1488                recoveryEscrow = nextRecoveryEscrow,1489                rootKeyEscrow = nextRootKeyEscrow,1490            )1491            val committed = withEpochCommit(expectedEpoch) {1492                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1493                try {1494                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1495                    // H-04: a root-key rotation re-seals slot A, so the duress slot1496                    // (real or fake) must be re-padded to the new ciphertext length.1497                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1498                    store.stampLocalMutation()1499                } catch (_: Exception) {1500                    store.writeSlotA(priorSlot)1501                    return@withEpochCommit false1502                }1503                true1504            } ?: false1505            if (!committed) throw IllegalStateException("storage_write_failed")1506            mutateIfCurrentEpoch(expectedEpoch) { publish() }1507            true1508        } catch (_: Exception) {1509            val restored = mutateIfCurrentEpoch(expectedEpoch) {1510                rootKey?.let(VaultCrypto::zero)1511                rootKey = snap.priorRootKey.copyOf()1512                recoveryWordHash = snap.priorRecoveryHash1513                recoveryWordsSet = snap.priorRecoverySet1514            }1515            if (restored != null) {1516                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1517            }1518            false1519        } finally {1520            VaultCrypto.zero(snap.key)1521            VaultCrypto.zero(snap.priorRootKey)1522            // On success nextRootKey is owned by rootKey. On every other path1523            // it is not, including a stale epoch after lock(); scrub it while1524            // the local reference is still available.1525            val stillOwnedBySession = mutateState {1526                sessionEpoch == expectedEpoch && rootKey === nextRootKey1527            }1528            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1529        }1530    }15311532    /**1533     * A copy of the live Argon2id-derived vault key, for biometric escrow only1534     * (wrapped under a hardware KeyStore key on biometric enable). Null when1535     * locked. Caller must [VaultCrypto.zero] it after use.1536     */1537    fun currentVaultKey(): ByteArray? = mutateState {1538        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1539    }15401541    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1542        val expectedEpoch = currentEpoch()1543        val slotA = store.loadSlotA() ?: return false1544        var oldKey: ByteArray? = null1545        var newKey: ByteArray? = null1546        var plaintext: ByteArray? = null1547        return try {1548            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1549            // Keep ownership of the decrypted vault plaintext until decoding has1550            // finished.  Passing decrypt() inline made the byte array unreachable1551            // before the finally block, so a successful or failed password change1552            // could leave the full plaintext resident in the heap.1553            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1554            val payload = try {1555                VaultCodec.decode(decrypted)1556            } finally {1557                VaultCrypto.zero(decrypted)1558            }1559            val newSalt = VaultCrypto.generateSalt()1560            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1561            // Re-seal the existing payload under the new key/salt.1562            plaintext = VaultCodec.encode(payload)1563            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1564            // The escrow seals the slot-A PLAINTEXT, which a password change does not1565            // alter — only the wrapping changes — so carry it forward; rebuilding the1566            // StoredVault from scratch would silently drop it.1567            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1568            // content that a password change does not alter — only the slot-A1569            // wrapping rotates. The root-key escrow staying stable across password1570            // changes matches the browser (`vault.ts:363-364`).1571            val next = StoredVault(1572                version = slotA.version,1573                salt = b64(newSalt),1574                blob = blob,1575                recoveryEscrow = slotA.recoveryEscrow,1576                rootKeyEscrow = slotA.rootKeyEscrow,1577            )1578            // H-01: durably commit slot A + size-matched noise + version stamp1579            // before swapping the session key. A partial commit (new A on disk,1580            // old key in memory) would have the next save encrypt under the old1581            // key while matching disk on the new one.1582            val priorB = store.loadSlotB()1583            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1584            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1585            // inside the same stateLock critical section, so a concurrent lock()1586            // can't observe the in-between (zeroed but not yet reassigned).1587            if (mutateIfCurrentEpoch(expectedEpoch) {1588                oldKey?.let(VaultCrypto::zero); oldKey = null1589                derivedKey?.let(VaultCrypto::zero)1590                derivedKey = newKey; newKey = null1591            } == null) return false1592            true1593        } catch (_: Exception) {1594            false1595        } finally {1596            oldKey?.let(VaultCrypto::zero)1597            newKey?.let(VaultCrypto::zero)1598            plaintext?.let(VaultCrypto::zero)1599        }1600    }16011602    /**1603     * Irreversible wipe. Locks the session and clears every vault key. The Pro1604     * license is in a separate store and is preserved by construction.1605     */1606    suspend fun destroyVault() = mutex.withLock {1607        lock()1608        store.clearVault()1609        store.clearDecoySlot()1610    }16111612    // ---- Entry CRUD -------------------------------------------------------16131614    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1615        val epoch = requireUnlocked()1616        val stamp = VaultCrypto.monotonicEntryTimestamp()1617        val now = System.currentTimeMillis()1618        val entry = withDerivedLast4(draft.copy(1619            id = draft.id.ifBlank { UUID.randomUUID().toString() },1620            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1621            modifiedAt = stamp,1622            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1623        ))1624        // H-03: capture rollback AND write entryList under stateLock so a1625        // concurrent lock() cannot zero the session between the read and the write.1626        val rollback = mutateCurrentSession(epoch) {1627            val r = entryList.toMutableList()1628            entryList.add(entry)1629            r1630        } ?: throw IllegalStateException("vault_locked")1631        persistOrRollback(rollback, epoch)1632        entry1633    }16341635    /**1636     * Insert many entries with a single encrypt-and-write at the end.1637     *1638     * Importing a real vault through [addEntry] was quadratic: every entry took1639     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1640     * restore did 806 full-vault writes and hung the app long enough to ANR.1641     * Rolls back to the pre-import list if the one write fails, matching1642     * [addEntry]'s all-or-nothing behaviour.1643     */1644    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1645        val epoch = requireUnlocked()1646        if (drafts.isEmpty()) return 01647        val now = System.currentTimeMillis()1648        val ready = drafts.map { draft ->1649            val stamp = VaultCrypto.monotonicEntryTimestamp()1650            withDerivedLast4(1651                draft.copy(1652                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1653                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1654                    modifiedAt = stamp,1655                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1656                ),1657            )1658        }1659        // H-03: capture rollback AND append every entry under stateLock.1660        val rollback = mutateCurrentSession(epoch) {1661            val r = entryList.toMutableList()1662            entryList.addAll(ready)1663            r1664        } ?: throw IllegalStateException("vault_locked")1665        persistOrRollback(rollback, epoch)1666        return drafts.size1667    }16681669    /**1670     * M-01: replace the whole active set with [newEntries] in one write.1671     *1672     * Import's REPLACE mode used to soft-delete every current entry one durable1673     * write at a time (swallowing each failure) and then batch-add, so a failed1674     * add left the old entries stranded in Trash with nothing restored. Staging1675     * the entire next list in memory and committing it through1676     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1677     * pre-import list — active entries included — is restored.1678     *1679     * Existing tombstones are preserved so paired devices still see the1680     * deletions they already know about.1681     */1682    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1683        val epoch = requireUnlocked()1684        val now = System.currentTimeMillis()1685        val ready = newEntries.map { draft ->1686            val stamp = VaultCrypto.monotonicEntryTimestamp()1687            withDerivedLast4(1688                draft.copy(1689                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1690                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1691                    modifiedAt = stamp,1692                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1693                ),1694            )1695        }1696        // M-03: an incoming entry that shares an id with an existing live entry1697        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1698        // the old one and then appending the new one would leave the list with1699        // two entries of the same id — the tombstoned one would be returned by1700        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1701        // superseded entries from the tombstone set instead.1702        val incomingIds = ready.map { it.id }.toHashSet()1703        val rollback = mutateCurrentSession(epoch) {1704            val r = entryList.toMutableList()1705            val replaced = entryList.map { entry ->1706                if (entry.deletedAt != null) return@map entry1707                if (entry.id in incomingIds) return@map null1708                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1709                entry.copy(deletedAt = stamp, modifiedAt = stamp)1710            }.filterNotNull()1711            entryList = (replaced + ready).toMutableList()1712            r1713        } ?: throw IllegalStateException("vault_locked")1714        persistOrRollback(rollback, epoch)1715        return ready.size1716    }17171718    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1719        val epoch = requireUnlocked()1720        // H-03: read entryList, compute the patched entry, and write it under a1721        // single stateLock so a concurrent lock() cannot interleave between the1722        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1723        // list, or clobber the entry onto the wrong id after a relock).1724        val prepared = mutateCurrentSession(epoch) {1725            val idx = entryList.indexOfFirst { it.id == patch.id }1726            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1727            val current = entryList[idx]1728            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1729            // Only refresh passwordChangedAt when the password value actually changes.1730            val passwordChanged = patch.password != null && patch.password != current.password1731            // Capture the outgoing password (newest first) so the user can reveal/copy/1732            // restore it later. History is computed authoritatively from `current`, not1733            // the patch — updateEntry is a full-entry replace, so trusting patch's value1734            // would let a stale form clobber the real history. Record the age the old1735            // password carried, skip an empty prior, and skip an immediate exact dupe1736            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1737            val priorPassword = current.password1738            val shouldCapture = passwordChanged &&1739                !priorPassword.isNullOrEmpty() &&1740                current.passwordHistory?.firstOrNull()?.password != priorPassword1741            val nextHistory = if (shouldCapture) {1742                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1743                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1744            } else {1745                current.passwordHistory1746            }1747            val updated = withDerivedLast4(patch.copy(1748                createdAt = current.createdAt,1749                modifiedAt = stamp,1750                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1751                passwordHistory = nextHistory,1752            ))1753            val rollback = entryList.toMutableList()1754            entryList[idx] = updated1755            updated to rollback1756        } ?: throw IllegalStateException("vault_locked")1757        val (updated, rollback) = prepared1758        persistOrRollback(rollback, epoch)1759        updated1760    }17611762    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1763    suspend fun deleteEntry(id: String) = mutex.withLock {1764        val epoch = requireUnlocked()1765        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1766        // the list out from under the indexOfFirst.1767        val rollback = mutateCurrentSession(epoch) {1768            val idx = entryList.indexOfFirst { it.id == id }1769            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1770            val current = entryList[idx]1771            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1772            val prior = entryList.toMutableList()1773            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1774            prior1775        } ?: throw IllegalStateException("vault_locked")1776        persistOrRollback(rollback, epoch)1777    }17781779    suspend fun restoreEntry(id: String) = mutex.withLock {1780        val epoch = requireUnlocked()1781        val rollback = mutateCurrentSession(epoch) {1782            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1783            if (idx == -1) throw IllegalStateException("deleted entry not found")1784            val current = entryList[idx]1785            val prior = entryList.toMutableList()1786            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1787            prior1788        } ?: throw IllegalStateException("vault_locked")1789        persistOrRollback(rollback, epoch)1790    }17911792    /** Permanent delete — no tombstone, no recovery. */1793    suspend fun purgeEntry(id: String) = mutex.withLock {1794        val epoch = requireUnlocked()1795        val rollback = mutateCurrentSession(epoch) {1796            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1797            if (idx == -1) throw IllegalStateException("deleted entry not found")1798            val prior = entryList.toMutableList()1799            entryList.removeAt(idx)1800            prior1801        } ?: throw IllegalStateException("vault_locked")1802        persistOrRollback(rollback, epoch)1803    }18041805    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1806    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1807        val epoch = requireUnlocked()1808        // H-03: read+remove under stateLock so lock() can't reorder the remove.1809        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1810        val r = mutateCurrentSession(epoch) {1811            val rollback = entryList.toMutableList()1812            val before = entryList.size1813            entryList.removeAll { it.deletedAt != null }1814            R(rollback, before - entryList.size)1815        } ?: throw IllegalStateException("vault_locked")1816        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1817        r.removed1818    }18191820    /** Browser parity: number of active entries that dedupe would move to trash. */1821    suspend fun countDuplicateEntries(): Int = mutex.withLock {1822        val epoch = requireUnlocked()1823        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1824        // lock() can't race the read with state writes.1825        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1826            ?: throw IllegalStateException("vault_locked")1827    }18281829    /**1830     * Browser parity: soft-delete duplicate entries, keeping the most recently1831     * modified member of each group. Refuse in decoy mode so the duress vault is1832     * not reshaped into a forensically distinct state.1833     */1834    suspend fun deduplicateEntries(): Int = mutex.withLock {1835        val epoch = requireUnlocked()1836        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1837        val ready = mutateCurrentSession(epoch) {1838            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1839            val active = entryList.filter { it.deletedAt == null }1840            val keepIds = mutableSetOf<String>()1841            active.groupBy { it.duplicateKey() }.values.forEach { group ->1842                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1843            }1844            val rollback = entryList.toMutableList()1845            var removed = 01846            entryList = entryList.map { entry ->1847                if (entry.deletedAt == null && entry.id !in keepIds) {1848                    removed++1849                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1850                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1851                } else {1852                    entry1853                }1854            }.toMutableList()1855            R(rollback, removed)1856        } ?: throw IllegalStateException("vault_locked")1857        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1858        ready.removed1859    }18601861    fun getEntries(): List<VaultEntry> = mutateState {1862        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1863    }1864    fun getEntry(id: String): VaultEntry? = mutateState {1865        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1866    }1867    fun getDeletedEntries(): List<VaultEntry> = mutateState {1868        if (!sessionDataAvailable) emptyList()1869        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1870    }18711872    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1873    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1874        return mutateState {1875            if (!sessionDataAvailable) return@mutateState emptyList()1876            val active = entryList.filter { it.deletedAt == null }1877            if (tagFilter == null) return@mutateState active1878            val needle = tagFilter.trim().lowercase()1879            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1880        }1881    }18821883    /** Sorted, de-duplicated tags across all active entries. */1884    fun getDistinctTags(): List<String> = mutateState {1885        if (!sessionDataAvailable) return@mutateState emptyList()1886        entryList.asSequence()1887            .filter { it.deletedAt == null }1888            .flatMap { it.tags?.asSequence() ?: emptySequence() }1889            .map { it.trim().lowercase() }1890            .filter { it.isNotEmpty() }1891            .distinct()1892            .sorted()1893            .toList()1894    }18951896    /**1897     * Distinct non-blank values for an identity field across active entries,1898     * most-frequently-used first. Supports "username" and "email"; unknown field1899     * names return an empty list. Values keep original casing (deduped1900     * case-insensitively) so a suggestion matches what the user typed.1901     */1902    fun getDistinctFieldValues(field: String): List<String> {1903        val select: (VaultEntry) -> String? = when (field) {1904            "username" -> { e -> e.username }1905            "email" -> { e -> e.email }1906            else -> return emptyList()1907        }1908        return mutateState {1909            if (!sessionDataAvailable) return@mutateState emptyList()1910            entryList.asSequence()1911                .filter { it.deletedAt == null }1912                .mapNotNull { select(it)?.trim() }1913                .filter { it.isNotEmpty() }1914                .groupingBy { it.lowercase() }1915                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1916                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1917                }1918                .values1919                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1920                .map { it.first }1921        }1922    }19231924    fun getTombstones(): List<Tombstone> = mutateState {1925        if (!sessionDataAvailable) emptyList()1926        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1927    }1928    fun getVaultVersion(): Long = store.version()1929    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19301931    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1932        val expectedEpoch = currentEpoch()1933        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1934        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1935        // H-04: snapshots now carry the duress slot too — restore preserves1936        // the at-rest triad (A/B/D), not just the A/B pair.1937        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1938        val priorA = store.loadSlotA()1939        val priorB = store.loadSlotB()1940        val priorD = store.loadDecoySlot()1941        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1942        // slot write — the old code took this snapshot, then stranded it on1943        // every failure, polluting history with a "restore" entry that never1944        // restored anything. The history below is rolled back together with1945        // the slots if the commit fails.1946        val priorHistory = store.loadVaultHistory()1947        // P1-4 (codex): the recovery snapshot must land before we commit the1948        // slot pair, otherwise a failed restore cannot be rolled back to the1949        // pre-restore history. If the history write itself fails, refuse to1950        // proceed — the alternative is a successful restore with no undo path.1951        beforeDurableCommit()1952        if (!takeVaultSnapshot("restore", expectedEpoch)) {1953            return false1954        }1955        // H-04: legacy snapshots without vault_d, or vault_d that fails to1956        // decode, fall back to a fake duress of slot A's size — presence-1957        // indistinguishable from any other freshly-restored vault.1958        val nextD: StoredVault = restoredD ?: run {1959            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161960            val fakeBlob = ByteArray(target).also(rng::nextBytes)1961            val fakeSalt = VaultCrypto.generateSalt()1962            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1963        }1964        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1965            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1966            return false1967        }1968        lock()1969        true1970    }19711972    /**1973     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1974     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1975     * (a restore preserves the duress-shaped blob from history), not a fresh1976     * noise record. Any failure rolls back so the at-rest pair never holds a1977     * restored slot A against a pre-restore slot B. Caller is responsible for1978     * restoring the pre-snapshot history on a false return.1979     *1980     * H-04: duress slot is now part of the atomic triad — restored last so1981     * a duress-write failure rolls the A/B pair back too.1982     */1983    private fun commitRestoreAtomic(1984        nextA: StoredVault,1985        nextB: String,1986        nextD: StoredVault,1987        priorA: StoredVault?,1988        priorB: String?,1989        priorD: StoredVault?,1990        expectedEpoch: Long,1991    ): Boolean {1992        return withEpochCommit(expectedEpoch) {1993            if (!store.writeSlotA(nextA)) return@withEpochCommit false1994            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1995            // must trigger rollback; previously a throw escaped without restoring1996            // the prior slot A and history.1997            try {1998                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1999                // H-04: duress slot is part of the restore triad. Failures here2000                // roll A+B back so the at-rest state stays consistent.2001                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")2002                store.stampLocalMutation()2003            } catch (_: Exception) {2004                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))2005                return@withEpochCommit false2006            }2007            true2008        } ?: false2009    }20102011    // ---- Sync apply (does NOT advance the version) ------------------------20122013    /**2014     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists2015     * WITHOUT bumping the version counter, so a sync round cannot cascade into2016     * another outbound sync. Returns the number of locally-applied changes.2017     */2018    suspend fun applyMergedRemoteEntries(2019        remoteEntries: List<VaultEntry>,2020        remoteTombstones: List<Tombstone>,2021        expectedEpoch: Long? = null,2022    ): Int = mutex.withLock {2023        val epoch = expectedEpoch ?: requireUnlocked()2024        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02025        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS2026        // BEFORE any comparison against a local one. Without this a peer can2027        // send a far-future modifiedAt and permanently pin its version of an2028        // entry against every honest local edit. The browser clamps in the2029        // matching place (vault.ts applyMergedRemoteEntries).2030        val mergeNow = System.currentTimeMillis()2031        val clamped = remoteEntries.mapNotNull { entry ->2032            // MN-3: drop peer entries that decode cleanly but are semantically2033            // hollow (blank title, credential-less login, husk passkey) and2034            // re-cap passwordHistory before anything merges into the vault.2035            SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2036                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2037                deletedAt = entry.deletedAt?.let {2038                    SyncLimits.clampSyncTimestamp(it, mergeNow)2039                },2040            )2041        }2042        val tombMap = remoteTombstones.associate {2043            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2044        }2045        // H-03: build the new entryList under stateLock so a concurrent lock()2046        // cannot zero derivedKey/entryList between the byId read and the merge.2047        val applied = mutateCurrentSession(epoch) {2048            var n = 02049            val byId = entryList.associateBy { it.id }.toMutableMap()20502051            for (remote in clamped) {2052                val tomb = tombMap[remote.id]2053                if (tomb != null && tomb >= remote.modifiedAt) continue2054                val local = byId[remote.id]2055                if (local == null) {2056                    entryList.add(remote); byId[remote.id] = remote; n++2057                } else if (remote.modifiedAt > local.modifiedAt) {2058                    val i = entryList.indexOfFirst { it.id == local.id }2059                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2060                }2061            }20622063            for ((id, deletedAt) in tombMap) {2064                val local = byId[id] ?: continue2065                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2066                if (deletedAt < local.modifiedAt) continue2067                val i = entryList.indexOfFirst { it.id == id }2068                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2069            }2070            n2071        } ?: return@withLock 020722073        if (applied > 0) {2074            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2075            mutateIfCurrentEpoch(epoch) { publish() }2076            runCatching { LockedSyncSpoolHooks.refresh(this) }2077        }2078        applied2079    }20802081    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2082        // Require a fully live session before exposing a settings mutation.  The2083        // epoch is captured with the read so a lock/new-session boundary cannot2084        // let stale work publish into the replacement session.2085        val epoch = requireUnlocked()2086        val prior = mutateCurrentSession(epoch) {2087            val old = _settings.value2088            _settings.value = settings2089            old2090        } ?: throw IllegalStateException("vault_locked")20912092        val committed = try {2093            beforeDurableCommit()2094            // The durable write is inside the same epoch fence as the in-memory2095            // publication.  If lock() won first, this returns null and no stale2096            // settings reach storage or the public StateFlow.2097            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2098        } catch (_: Exception) {2099            false2100        }2101        if (!committed) {2102            // A live-session storage failure rolls back the public value.  A2103            // stale epoch is already reset by lock()/the new session and must2104            // not be allowed to overwrite that safe state.2105            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2106            throw IllegalStateException("settings_write_failed")2107        }2108    }21092110    // ---- Recovery words (vault metadata) ----------------------------------21112112    /** True once the user has confirmed their recovery words for this vault. */2113    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }21142115    /**2116     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2117     * Persisted without bumping the version — recovery metadata is local-only and2118     * is not an entry mutation paired devices should sync against.2119     */2120    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2121        val epoch = requireUnlocked()2122        // H-03: hash + escrow-related state fields set under stateLock so a2123        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2124        // M-04: the flags have to be live before the escrows are built — both seal2125        // currentPayload() — so capture the prior pair and put it back if the write2126        // never lands. Otherwise the session reports words set against a slot A2127        // that still holds no escrow, and the recovery screen never offers a retry.2128        val prior = mutateCurrentSession(epoch) {2129            val priorPair = recoveryWordHash to recoveryWordsSet2130            recoveryWordHash = recoveryWordsHash(words)2131            recoveryWordsSet = true2132            priorPair2133        } ?: return@withLock false2134        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2135        // (cross-platform recovery of the sync root key) and Android's whole-payload2136        // escrow (offline forgot-password restore of entries). Both derive their key2137        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2138        val persisted = runCatching {2139            persistSlotAWithEscrows(2140                buildRecoveryEscrow(words),2141                buildRootKeyEscrow(words),2142                expectedEpoch = epoch,2143            )2144        }.getOrDefault(false)2145        if (!persisted) {2146            mutateIfCurrentEpoch(epoch) {2147                recoveryWordHash = prior.first2148                recoveryWordsSet = prior.second2149            }2150        }2151        persisted2152    }21532154    /** Verify entered words against the stored recovery hash. */2155    fun verifyRecoveryWords(words: List<String>): Boolean {2156        return mutateState {2157            val stored = recoveryWordHash ?: return@mutateState false2158            stored == recoveryWordsHash(words)2159        }2160    }21612162    // ---- Recovery escrow (forgot-password) --------------------------------21632164    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2165    fun hasRecoveryEscrow(): Boolean =2166        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21672168    /**2169     * One-time self-heal: if this vault has a root key but no recovery escrow2170     * on disk (adopted vaults created before the escrow fix), build and persist2171     * it now. Called on every successful unlock; no-ops once the escrow exists.2172     */2173    private fun ensureRecoveryEscrow() {2174        if (hasRecoveryEscrow()) return2175        val rk = mutateState { rootKey?.copyOf() } ?: return2176        runCatching {2177            val words = rootKeyToWords(rk)2178            val escrow = buildRecoveryEscrow(words)2179            val rkEscrow = buildRootKeyEscrow(words)2180            if (escrow != null || rkEscrow != null) {2181                val prior = store.loadSlotA() ?: return@runCatching2182                store.writeSlotA(prior.copy(2183                    recoveryEscrow = escrow ?: prior.recoveryEscrow,2184                    rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2185                ))2186                mutateState {2187                    recoveryWordHash = recoveryWordsHash(words)2188                    recoveryWordsSet = true2189                }2190            }2191        }.also { VaultCrypto.zero(rk) }2192    }21932194    /**2195     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2196     * the master password. On success the slot-A plaintext sealed in the escrow is2197     * adopted as the live session (entries, rootKey, settings), but no Argon2id2198     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2199     * new password and finish unlocking. Returns false when there is no escrow, the2200     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2201     *2202     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2203     * detached vault key to return here — slot A is wrapped under the rotating2204     * Argon2id key — so this opens the escrowed payload directly.2205     */2206    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2207        val expectedEpoch = currentEpoch()2208        val slotA = store.loadSlotA() ?: return false2209        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false22102211        // Prefer Android's whole-payload escrow: a full offline restore of entries,2212        // root key, and settings. Present for Android-created vaults.2213        val fullEscrow = slotA.recoveryEscrow2214        if (fullEscrow != null) {2215            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2216            if (material != null) {2217                var keyBytes: ByteArray? = null2218                var plaintext: ByteArray? = null2219                var candidateRoot: ByteArray? = null2220                var stateCommitStarted = false2221                try {2222                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2223                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2224                    val payload = VaultCodec.decode(plaintext)2225                    // Stage every fallible operation before touching live session2226                    // fields.  In particular, a settings/provider/decode failure2227                    // cannot leave entries, flags, or a root key resident while the2228                    // public session remains locked.2229                    val candidateSettings = store.loadSettings()2230                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2231                        val decodedRoot = unb64(payload.rootKeyB64)2232                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2233                            // Do not let a malformed authenticated payload abandon2234                            // this decoded root-key candidate before the outer2235                            // finally block owns it.2236                            VaultCrypto.zero(decodedRoot)2237                            throw IllegalArgumentException("invalid recovery root key")2238                        }2239                        decodedRoot2240                    } else {2241                        generateRecoverableRootKey()2242                    }2243                    stateCommitStarted = true2244                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2245                        clearSessionStateLocked()2246                        entryList = payload.entries.toMutableList()2247                        duressConfigured = payload.duressConfigured2248                        recoveryWordHash = payload.recoveryWordHash2249                        recoveryWordsSet = payload.recoveryWordsSet2250                        rootKey = candidateRoot2251                        candidateRoot = null2252                        _settings.value = candidateSettings2253                        publish()2254                        // This is the authenticated recovery half-unlock boundary:2255                        // only set it after every candidate field is installed.2256                        sessionDataAvailable = true2257                        _isUnlocked.value = false2258                        true2259                    }2260                    if (adopted == true) return@withLock true2261                } catch (_: Exception) {2262                    // Wrong words, malformed payloads, provider failures, and2263                    // settings failures all fall through to the browser-compatible2264                    // root-key escrow.  If the state commit had started, invalidate2265                    // that epoch so no partial candidate survives.2266                    if (stateCommitStarted) {2267                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2268                    }2269                } finally {2270                    plaintext?.let(VaultCrypto::zero)2271                    VaultCrypto.zero(material)2272                    keyBytes?.let(VaultCrypto::zero)2273                    candidateRoot?.let(VaultCrypto::zero)2274                }2275            }2276        }22772278        // Browser-format vault (only the root-key escrow): recover the 32-byte root2279        // key (sync identity) and reset the password; entries return via sync. This2280        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2281        val rootEscrow = slotA.rootKeyEscrow2282        if (rootEscrow != null) {2283            var candidateRoot: ByteArray? = null2284            var stateCommitStarted = false2285            try {2286                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2287                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2288                val candidateSettings = store.loadSettings()2289                stateCommitStarted = true2290                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2291                    clearSessionStateLocked()2292                    entryList = mutableListOf()2293                    duressConfigured = false2294                    recoveryWordHash = recoveryWordsHash(words)2295                    recoveryWordsSet = true2296                    rootKey = candidateRoot2297                    candidateRoot = null2298                    _settings.value = candidateSettings2299                    publish()2300                    sessionDataAvailable = true2301                    _isUnlocked.value = false2302                    true2303                }2304                if (adopted == true) return@withLock true2305            } catch (_: Exception) {2306                if (stateCommitStarted) {2307                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2308                }2309            } finally {2310                candidateRoot?.let(VaultCrypto::zero)2311            }2312        }2313        return false2314    }23152316    /**2317     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2318     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2319     * words, mark the session fully unlocked, and publish. Returns false if called2320     * without a prior successful recovery unlock (no in-memory root key).2321     *2322     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2323     * into one step, since there is no detached vault key here.2324     */2325    suspend fun resetPasswordWithRecoveryWords(2326        words: List<String>,2327        newPassword: String,2328    ): Boolean = mutex.withLock {2329        val expectedEpoch = currentEpoch()2330        // H-03: read rootKey + entryList under stateLock before computing the new2331        // plaintext, then re-publish + swap the derived key under stateLock so a2332        // concurrent lock() cannot straddle the two fences.2333        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2334        val priorA = store.loadSlotA() ?: return false2335        var newKey: ByteArray? = null2336        var plaintext: ByteArray? = null2337        return try {2338            val newSalt = VaultCrypto.generateSalt()2339            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2340            plaintext = VaultCodec.encode(currentPayload())2341            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2342            // Rebuild both escrows against the recovered session so their salts rotate2343            // with the password reset; fall back to the prior escrows if rebuild fails2344            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2345            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2346            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2347            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2348            val next = StoredVault(2349                version = priorA.version,2350                salt = b64(newSalt),2351                blob = blob,2352                recoveryEscrow = freshRecovery,2353                rootKeyEscrow = freshRootKey,2354            )2355            // H-01: see changePassword — both rotations share the same2356            // commit-or-keep-old-key contract so a partial write can't strand2357            // the session on a key that no longer matches disk.2358            val priorB = store.loadSlotB()2359            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2360            // H-03 (P1-2 codex): zero the old live key and install the new one2361            // inside the same stateLock critical section so a concurrent lock()2362            // can't observe the in-between.2363            if (mutateIfCurrentEpoch(expectedEpoch) {2364                derivedKey?.let(VaultCrypto::zero)2365                derivedKey = newKey; newKey = null2366                publish()2367                _isUnlocked.value = true2368            } == null) return false2369            true2370        } catch (_: Exception) {2371            false2372        } finally {2373            newKey?.let(VaultCrypto::zero)2374            plaintext?.let(VaultCrypto::zero)2375        }2376    }23772378    /**2379     * Seal the current slot-A plaintext under a key derived from the 12 BIP392380     * recovery [words] (Android offline-restore escrow). IKM is the browser's2381     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2382     * Returns null for a checksum-invalid phrase.2383     */2384    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2385        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2386        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2387        var keyBytes: ByteArray? = null2388        var plaintext: ByteArray? = null2389        return try {2390            val salt = VaultCrypto.generateSalt()2391            val recoveryKey = deriveRecoveryKey(material, salt)2392            keyBytes = recoveryKey.encoded2393            plaintext = VaultCodec.encode(currentPayload())2394            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2395            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2396        } finally {2397            VaultCrypto.zero(material)2398            keyBytes?.let(VaultCrypto::zero)2399            plaintext?.let(VaultCrypto::zero)2400        }2401    }24022403    /**2404     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2405     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2406     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2407     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2408     * the same root key on the browser. Returns null with no in-memory root key or2409     * a checksum-invalid phrase.2410     */2411    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2412        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2413        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2414        val rk = mutateState { rootKey?.copyOf() } ?: run {2415            VaultCrypto.zero(material)2416            return null2417        }2418        var keyBytes: ByteArray? = null2419        var plaintext: ByteArray? = null2420        return try {2421            val salt = VaultCrypto.generateSalt()2422            val recoveryKey = deriveRecoveryKey(material, salt)2423            keyBytes = recoveryKey.encoded2424            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2425            // combined = nonce(12) || ciphertext || tag(16)2426            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2427            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2428            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2429            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2430            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2431            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2432        } finally {2433            VaultCrypto.zero(rk)2434            VaultCrypto.zero(material)2435            keyBytes?.let(VaultCrypto::zero)2436            plaintext?.let(VaultCrypto::zero)2437        }2438    }24392440    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2441    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2442        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2443        var keyBytes: ByteArray? = null2444        var pt: ByteArray? = null2445        return try {2446            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2447            keyBytes = recoveryKey.encoded2448            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2449            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2450            hexToRootKey(String(pt, Charsets.US_ASCII))2451        } catch (_: Exception) {2452            null2453        } finally {2454            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2455            // IKM-equivalent of the vault identity. material+keyBytes were2456            // already zeroed; pt was leaking.2457            VaultCrypto.zero(material)2458            keyBytes?.let(VaultCrypto::zero)2459            pt?.let(VaultCrypto::zero)2460        }2461    }24622463    /**2464     * Re-seal slot A from the current in-memory state under the live key, carrying2465     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2466     * in decoy mode (recovery is never offered for the decoy) and persists without2467     * a version bump, like the recovery-words metadata write it replaces.2468     */2469    private fun persistSlotAWithEscrows(2470        recoveryEscrow: RecoveryEscrow?,2471        rootKeyEscrow: RootKeyEscrow?,2472        expectedEpoch: Long? = null,2473    ): Boolean {2474        val snapshot = snapshotForPersist(expectedEpoch)2475            ?: throw IllegalStateException("vault_locked_or_stale")2476        if (snapshot.decoy) {2477            VaultCrypto.zero(snapshot.key)2478            return false2479        }2480        try {2481            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2482            val plaintext = VaultCodec.encode(snapshot.payload)2483            val blob = try {2484                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2485            } finally {2486                // The escrow path used to drop this reference before zeroing it;2487                // keep it live through the fill on both success and failure.2488                VaultCrypto.zero(plaintext)2489            }2490            val next = prior.copy(2491                blob = blob,2492                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2493                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2494            )2495            beforeDurableCommit()2496            return withEpochCommit(snapshot.epoch) {2497                if (!store.writeSlotA(next)) return@withEpochCommit false2498                try {2499                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2500                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2501                    // the duress slot too. A real decoy is left alone; a fake is2502                    // re-sized to slot A's new ciphertext length.2503                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2504                } catch (e: Exception) {2505                    store.writeSlotA(prior)2506                    throw e2507                }2508                true2509            } ?: false2510        } finally {2511            VaultCrypto.zero(snapshot.key)2512        }2513    }25142515    // ---- Internals --------------------------------------------------------25162517    private fun requireUnlocked(): Long {2518        // H-03: read derivedKey atomically with [lock]'s reset; without it,2519        // [lock] from a background lifecycle event can run between this read2520        // and the caller's first state touch.2521        stateLock.lock()2522        try {2523            if (derivedKey == null) throw IllegalStateException("vault_locked")2524            return sessionEpoch2525        } finally {2526            stateLock.unlock()2527        }2528    }25292530    /** A locked or half-adopted session must not expose secret-bearing fields. */2531    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25322533    private fun countDuplicateEntriesLocked(): Int {2534        val seen = mutableMapOf<String, VaultEntry>()2535        var duplicates = 02536        for (entry in entryList.filter { it.deletedAt == null }) {2537            val key = entry.duplicateKey()2538            val existing = seen[key]2539            if (existing == null) {2540                seen[key] = entry2541            } else {2542                duplicates++2543                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2544                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2545                if (entryStamp > existingStamp) seen[key] = entry2546            }2547        }2548        return duplicates2549    }25502551    private fun VaultEntry.duplicateKey(): String {2552        if (type == "note") return "note|${title.lowercase()}"2553        val passkeyData = passkey2554        if (passkeyData != null) {2555            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2556        }2557        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2558    }25592560    /**2561     * Capture the current slot pair (and metadata) into the history list.2562     * Returns true when the history write landed, false when it didn't — callers2563     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2564     * must check the result and roll back if it does not.2565     */2566    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2567        val epoch = expectedEpoch ?: currentEpoch()2568        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2569        val slotA = store.loadSlotA() ?: return false2570        val vaultA = VaultCodec.encodeSlot(slotA)2571        val vaultB = store.loadSlotB() ?: slotA.blob2572        // H-04: capture the duress slot so a restore preserves the at-rest2573        // triad, not just the A/B pair. The duress slot is always present2574        // (real or fake), so loadDecoySlot() returning null is a logic error2575        // — fall through to a fake duress in that case rather than silently2576        // losing the record from the snapshot.2577        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2578            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162579            val fakeBlob = ByteArray(target).also(rng::nextBytes)2580            val fakeSalt = VaultCrypto.generateSalt()2581            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2582        }2583        val history = store.loadVaultHistory()2584        val latest = history.snapshots.firstOrNull()2585        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2586        val entryCount = mutateIfCurrentEpoch(epoch) {2587            if (derivedKey != null && !decoyMode) {2588                bucketEntryCount(entryList.count { it.deletedAt == null })2589            } else {2590                null2591            }2592        } ?: return false2593        return try {2594            val snapshot = VaultSnapshot(2595                id = UUID.randomUUID().toString(),2596                savedAt = System.currentTimeMillis(),2597                reason = reason,2598                vault_a = vaultA,2599                vault_b = vaultB,2600                vault_d = vaultD,2601                entryCount = entryCount,2602            )2603            // P1-4 (codex): return the history-write boolean to callers so a2604            // false return no longer leaves the snapshot out of history while2605            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2606            withEpochCommit(epoch) {2607                store.writeVaultHistory(2608                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2609                )2610            } ?: false2611        } catch (_: Exception) {2612            false2613        }2614    }26152616    private fun bucketEntryCount(n: Int): Int = when {2617        n <= 0 -> 02618        n <= 10 -> 102619        n <= 25 -> 252620        n <= 50 -> 502621        n <= 100 -> 1002622        n <= 250 -> 2502623        n <= 500 -> 5002624        else -> ((n + 499) / 500) * 5002625    }26262627    /**2628     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2629     * so it is stored explicitly and survives the sync wire format rather than being2630     * re-derived at render time. Non-card entries pass through unchanged.2631     *2632     * M-03: never overwrite a stored last4 with null. A card kept only as last42633     * (e.g. after a PeachScript import that had no PAN) must keep that value;2634     * re-deriving from an absent cardNumber would silently zero the field.2635     */2636    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2637        if (!entry.isCard()) return entry2638        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2639        val derived = if (digits.length >= 4) digits.takeLast(4) else null2640        return entry.copy(last4 = derived ?: entry.last4)2641    }26422643    /**2644     * On unlock, hard-delete trashed entries older than the retention window.2645     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2646     * (the app's "Never" option), so nothing is purged. Persists without a2647     * version bump and without touching the publish state (caller publishes).2648     */2649    private fun autoPurgeExpiredTrash() {2650        val days = _settings.value.trashRetentionDays ?: return2651        if (days <= 0) return2652        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2653        val before = entryList.size2654        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2655        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2656    }26572658    /**2659     * Browser parity (P1): merge orphaned passkey-only entries into matching2660     * login entries that share the same registrable domain. A passkey-only2661     * entry has passkey fields (rpId + credentialId) but no password. Runs on2662     * unlock so consolidation is idempotent and cheap.2663     */2664    private fun consolidatePasskeyEntries() {2665        val passkeyOnly = entryList.filter { entry ->2666            entry.deletedAt == null &&2667                entry.isLogin() &&2668                entry.password.isNullOrBlank() &&2669                !entry.rpId.isNullOrBlank() &&2670                !entry.credentialId.isNullOrBlank()2671        }2672        if (passkeyOnly.isEmpty()) return2673        var changed = false2674        val now = System.currentTimeMillis()2675        for (pkEntry in passkeyOnly) {2676            val rpId = pkEntry.rpId ?: continue2677            val target = entryList.firstOrNull { candidate ->2678                candidate.deletedAt == null &&2679                    candidate.id != pkEntry.id &&2680                    candidate.isLogin() &&2681                    candidate.passkey == null &&2682                    candidate.credentialId.isNullOrBlank() &&2683                    !candidate.password.isNullOrBlank() &&2684                    listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2685                        .any { com.peachpasswords.shared.extractDomain(it) == rpId }2686            } ?: continue2687            val idx = entryList.indexOfFirst { it.id == target.id }2688            if (idx < 0) continue2689            entryList[idx] = target.copy(2690                rpId = pkEntry.rpId,2691                rpName = pkEntry.rpName ?: target.rpName,2692                credentialId = pkEntry.credentialId,2693                publicKeyCose = pkEntry.publicKeyCose,2694                privateKeyJwk = pkEntry.privateKeyJwk,2695                userHandle = pkEntry.userHandle,2696                userName = pkEntry.userName,2697                userDisplayName = pkEntry.userDisplayName,2698                algorithm = pkEntry.algorithm,2699                signCount = pkEntry.signCount,2700                lastUsedAt = pkEntry.lastUsedAt,2701                modifiedAt = now,2702            )2703            // Soft-delete the orphan so it lands in trash (recoverable).2704            val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2705            if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2706            changed = true2707        }2708        if (changed) runCatching { persistSlotA(bumpVersion = false) }2709    }27102711    /** The slot-A plaintext envelope for the current in-memory session. */2712    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }27132714    private fun currentPayloadLocked(): VaultPayload =2715        VaultPayload(2716            entries = entryList.toList(),2717            duressConfigured = duressConfigured,2718            rootKeyB64 = rootKey?.let(::b64) ?: "",2719            recoveryWordHash = recoveryWordHash,2720            recoveryWordsSet = recoveryWordsSet,2721        )27222723    /**2724     * Adopt the payload's root key, lazily minting one for legacy payloads.2725     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2726     * a root key is not a mutation paired devices should sync against.2727     */2728    private fun ensureRootKey(payload: VaultPayload) {2729        if (payload.rootKeyB64.isNotBlank()) {2730            rootKey = unb64(payload.rootKeyB64)2731            return2732        }2733        rootKey = generateRecoverableRootKey()2734        runCatching { persistSlotA(bumpVersion = false) }2735    }27362737    private fun publish() {2738        _entries.value = entryList.filter { it.deletedAt == null }2739    }27402741    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2742        var plaintext = VaultCodec.encode(currentPayload())2743        return try {2744            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2745            StoredVault(version = 1, salt = b64(salt), blob = blob)2746        } finally {2747            // M-05: full vault plaintext is allocated here once per createVault.2748            // Zeroing in finally keeps the transient copy off the heap after the2749            // caller either succeeds or rolls back via lock().2750            VaultCrypto.zero(plaintext)2751        }2752    }27532754    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2755        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2756        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2757        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2758        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2759        val noise = ByteArray(approxBytes).also(rng::nextBytes)2760        return store.writeSlotB(b64(noise))2761    }27622763    /** H-04: the ciphertext length slot A is using right now, so the duress2764     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2765     *  length equals ciphertext length, so this also matches slot A's plaintext. */2766    private fun matchingCiphertextLength(slotA: StoredVault): Int =2767        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27682769    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2770     *  bytes. Replaces a real decoy when the user clears duress, and is the2771     *  initial duress slot when no decoy is configured — so the record's2772     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2773     *  matches slot A's blob length. */2774    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2775        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162776        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2777        val fakeSalt = VaultCrypto.generateSalt()2778        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2779    }27802781    /** H-04: keep the duress record presence- and size-indistinguishable from2782     *  slot A on every persist. A real decoy is left alone (it was padded at2783     *  setDuressPassphrase time, and we don't have the duress key here to2784     *  re-pad on entry growth — that's an accepted residual tell, see the2785     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2786     *  A's size. */2787    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2788        val isConfigured = mutateState { duressConfigured }2789        return if (isConfigured) true else writeFakeDuressSlot(slotA)2790    }27912792    /**2793     * H-04: convert a browser-shaped duress slot to Android StoredVault2794     * without decrypting. The browser seals slot "b" under the duress2795     * passphrase; decrypting with the main password silently drops it.2796     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2797     * the salt from hex to base64.2798     */2799    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2800        val salt = unhex(b.salt)2801        val nonce = unhex(b.nonce)2802        val ct = unhex(b.ciphertext)2803        val tag = unhex(b.tag)2804        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2805            System.arraycopy(nonce, 0, it, 0, nonce.size)2806            System.arraycopy(ct, 0, it, nonce.size, ct.size)2807            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2808        }2809        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2810    }28112812    /** Re-seal slot A from the current in-memory state under the live key. */2813    private fun persistSlotA(2814        bumpVersion: Boolean,2815        snapshotReason: String? = "local_save",2816        expectedEpoch: Long? = null,2817    ): Boolean {2818        val snapshot = snapshotForPersist(expectedEpoch)2819            ?: throw IllegalStateException("vault_locked_or_stale")2820        return try {2821            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2822            if (snapshot.decoy) {2823                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2824                val plaintext = VaultCodec.encode(snapshot.payload)2825                val blob = try {2826                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2827                } finally {2828                    // Keep the last reference alive until the zero, including the2829                    // successful encrypt path (M-05).2830                    VaultCrypto.zero(plaintext)2831                }2832                beforeDurableCommit()2833                return withEpochCommit(snapshot.epoch) {2834                    store.writeDecoySlot(prior.copy(blob = blob))2835                } ?: false2836            }2837            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2838            // H-01: captured BEFORE the noise write so a stamp failure can restore2839            // the prior slot B alongside the prior slot A. Slot B is always2840            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2841            // leaves (new A, new B, old version) on disk — partial commit.2842            val priorB = store.loadSlotB()2843            beforeDurableCommit()2844            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2845            val plaintext = VaultCodec.encode(snapshot.payload)2846            val blob = try {2847                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2848            } finally {2849                // Do not drop [plaintext] before fill(0): the serialized vault2850                // contains every entry password and is the primary M-05 target.2851                VaultCrypto.zero(plaintext)2852            }2853            val next = prior.copy(blob = blob)2854            // The epoch check and the entire A/B/D/version commit share one brief2855            // stateLock critical section. lock() either wins before this block or2856            // waits until the already-committed write is complete; it can never2857            // clear the session between the check and the durable write.2858            withEpochCommit(snapshot.epoch) {2859                if (!store.writeSlotA(next)) return@withEpochCommit false2860                try {2861                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2862                    // H-04: duress slot stays present (real one untouched, fake one2863                    // re-padded to slot A's current size on every persist).2864                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2865                    if (bumpVersion) store.stampLocalMutation()2866                } catch (e: Exception) {2867                    store.writeSlotA(prior)2868                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2869                    throw e2870                }2871                true2872            } ?: false2873        } finally {2874            // Snapshot keys are copies, so lock() may zero the live key while this2875            // operation is still deriving/encrypting; always scrub our copy too.2876            VaultCrypto.zero(snapshot.key)2877        }2878    }28792880    /**2881     * H-01: durably commit a slot-A rotation by writing slot A, its2882     * size-matched noise slot B, and the local-mutation stamp in order. Any2883     * failure rolls the previously-committed steps back so the at-rest pair2884     * matches what was there before the call. Returns true only when every2885     * step succeeded — callers must NOT swap their session key on false.2886     */2887    private fun commitVaultRotation(2888        priorA: StoredVault,2889        priorB: String?,2890        nextA: StoredVault,2891        expectedEpoch: Long,2892    ): Boolean {2893        beforeDurableCommit()2894        return withEpochCommit(expectedEpoch) {2895            if (!store.writeSlotA(nextA)) return@withEpochCommit false2896            // P1-1 (codex): the noise write's boolean AND any thrown exception from2897            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2898            // escaped the helper and left slot A committed.2899            try {2900                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2901                // H-04: a password rotation changes slot A's plaintext size (new2902                // salt → re-encrypted blob), so the duress slot must be re-padded2903                // alongside. writeCompanionDuressSlot leaves a real decoy alone2904                // and re-emits a fake of slot A's size otherwise.2905                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2906                store.stampLocalMutation()2907            } catch (_: Exception) {2908                rollbackRotation(priorA, priorB)2909                return@withEpochCommit false2910            }2911            true2912        } ?: false2913    }29142915    /**2916     * Best-effort undo of the writes [commitVaultRotation] may have committed.2917     * Each step runs independently — a failed rollback must not mask the2918     * original commit failure. Log is a throwing stub off-device.2919     */2920    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2921        val failures = mutableListOf<String>()2922        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2923            .onFailure { failures += "writeSlotA: $it" }2924        if (priorB != null) {2925            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2926                .onFailure { failures += "writeSlotB: $it" }2927        }2928        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2929    }29302931    /** Persist a local mutation, rolling the in-memory list back on failure. */2932    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2933        try {2934            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2935                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2936                throw IllegalStateException("storage_write_failed")2937            }2938        } catch (e: Exception) {2939            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2940            throw e2941        }2942        // A lock can win in the small interval after the durable commit and2943        // before publication. The epoch check prevents pre-lock entries from2944        // being republished into a newly locked (or newly unlocked) session.2945        mutateIfCurrentEpoch(expectedEpoch) { publish() }2946        runCatching { LockedSyncSpoolHooks.refresh(this) }2947    }2948}