VaultRepository.kt

140.2 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    private val _settings = MutableStateFlow(VaultSettings())208    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()209210    /** Clear every in-memory session field while [stateLock] is held. */211    private fun clearSessionStateLocked() {212        derivedKey?.let(VaultCrypto::zero)213        rootKey?.let(VaultCrypto::zero)214        derivedKey = null215        rootKey = null216        entryList = mutableListOf()217        duressConfigured = false218        recoveryWordHash = null219        recoveryWordsSet = false220        sessionDataAvailable = false221        decoyMode = false222        _settings.value = VaultSettings()223        _entries.value = emptyList()224        _isUnlocked.value = false225    }226227    fun hasVault(): Boolean = store.hasVault()228229    // ---- Codex (printed paper backup) -------------------------------------230231    /**232     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the233     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's234     * A/B naming where slot B is the duress decoy); the noise slot is local-only235     * and never crosses devices. Restorable on any device that has the master236     * password. Null when no vault exists.237     */238    fun codexVaultBlobBase64(): String? {239        val slotA = store.loadSlotA() ?: return null240        // H-04: duress slot is always present after createVault (real or fake),241        // so the wrapper can rely on it being loadable.242        val slotD = store.loadDecoySlot() ?: return null243        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""244        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))245    }246247    /**248     * Replace the local vault from a Codex blob, then unlock with [password].249     * Writes slot A (and a size-matched noise slot B) before unlocking so the250     * restored vault is at rest exactly as a freshly-created one would be.251     */252    /** A slot is stored either as a JSON string (browser) or inline object (Android). */253    private fun JsonElement?.asSlotJson(): String? = when {254        this == null || this is JsonNull -> null255        this is JsonPrimitive && isString -> content256        else -> toString()257    }258259    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {260        val expectedEpoch = currentEpoch()261        var candidateKey: ByteArray? = null262        var plaintext: ByteArray? = null263        return try {264            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)265            // Parsed as JSON, not matched with a regex. The wrapper is266            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which267            // generates most real Codexes — writes each slot as a JSON268            // *string*, with "b" present. A regex looking for an object269            // literal under "a" at the very end of the wrapper matches270            // neither of those, so every browser-generated Codex failed here271            // with "invalid_codex_blob" after decoding perfectly.272            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject273                ?: return UnlockResult.Error("invalid_codex_blob")274            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")275            // A Codex printed from the browser carries a browser-shaped slot,276            // which is the common case — most Codexes are printed there. It is277            // translated to Android's shape (see BrowserSlot) rather than278            // rejected. A translation failure is a wrong password, not a bad279            // blob: the codes decoded fine, the password just didn't open them.280            val slotA = VaultCodec.decodeSlot(slotJson)281                ?: BrowserSlot.parse(slotJson)?.let {282                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword283                }284                ?: return UnlockResult.Error("invalid_codex_blob")285            // C-01: authenticate the candidate ENTIRELY in memory before touching286            // storage. VaultCodec.decodeSlot is structural — an Android-shaped287            // Codex parses under ANY password — so writing slot A first and288            // unlocking afterwards destroyed the device's existing vault every289            // time the user mistyped the password.290            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))291            plaintext = decryptSlotABlob(slotA, candidateKey)292            // LV-1: a Codex printed from a vault sealed before NFC normalization293            // opens only under the raw password bytes. unlockLocked used to supply294            // this fallback; authenticating up front here has to keep it, or those295            // Codexes become unrestorable.296            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {297                VaultCrypto.zero(candidateKey)298                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))299                plaintext = decryptSlotABlob(slotA, candidateKey)300            }301            if (plaintext == null) return UnlockResult.WrongPassword302            val payload = try {303                VaultCodec.decode(plaintext)304            } catch (_: Exception) {305                return UnlockResult.Error("invalid_codex_blob")306            }307308            val priorA = store.loadSlotA()309            val priorB = store.loadSlotB()310            val priorD = store.loadDecoySlot()311            // Carry the real duress slot across when the Codex has one — the312            // browser writes "b" as its duress-shaped slot, and dropping it313            // here would silently destroy a configured decoy.314            // H-04: write browser B into the duress slot (not the noise slot),315            // translating browser-shaped slots to Android's StoredVault shape.316            val codexDuressJson = wrapper["b"].asSlotJson()317            val codexDuress: StoredVault? = codexDuressJson?.let {318                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->319                    // H-04: pass-through — the browser duress slot is sealed320                    // under the duress passphrase, not the main password. Trying321                    // to decrypt with the slot-A password fails the GCM tag and322                    // silently drops a real decoy. Re-encode the browser hex323                    // fields as Android base64 salt/blob so tryUnlockDuress can324                    // still open it with the duress passphrase.325                    browserDuressPassthrough(b)326                }327            }328329            beforeDurableCommit()330            val committed = runCatching {331                withEpochCommit(expectedEpoch) {332                    try {333                        // Keep every slot write, including the inverse, under the334                        // same epoch fence. Releasing stateLock after a partial335                        // write and reacquiring it for rollback lets lock() advance336                        // the epoch in between and strand the incoming slot A.337                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")338                        // Noise slot is always size-matched to slot A — local-only,339                        // never written from a Codex.340                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")341                        // H-04: duress slot always present; real one from the Codex or342                        // a fake the size of slot A.343                        val duressOk = if (codexDuress != null) {344                            store.writeDecoySlot(codexDuress)345                        } else {346                            writeFakeDuressSlot(slotA)347                        }348                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")349                        store.stampLocalMutation()350                        true351                    } catch (_: Exception) {352                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))353                        // Keep the fence held through the final rollback354                        // observation. A concurrent lock() may queue here, but355                        // cannot invalidate the restored A/B/D triad.356                        afterCodexRollback()357                        false358                    }359                } ?: false360            }.getOrDefault(false)361            if (!committed) {362                return UnlockResult.Error("storage_write_failed")363            }364365            val ownedKey = candidateKey!!366            candidateKey = null // the session owns the key from here on367            adoptSlotA(ownedKey, payload, expectedEpoch)368        } catch (e: Exception) {369            // A raw kotlinx parse error ("Unexpected JSON token at offset N")370            // reaching the user says nothing about WHICH json failed — the371            // wrapper, or the payload decrypted out of it. Codex screens are372            // FLAG_SECURE so this can't be screenshotted either. Debug builds373            // log the structure around the failure; alphanumerics are masked so374            // no ciphertext or vault content lands in a log.375            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))376            UnlockResult.Error(e.message ?: "restore_failed")377        } finally {378            plaintext?.let(VaultCrypto::zero)379            candidateKey?.let(VaultCrypto::zero)380        }381    }382383    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */384    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =385        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()386387    /**388     * Undo a partially-committed Codex restore while [stateLock] is already held.389     * Every step is attempted independently so one failure cannot strand the390     * others. Callers that need epoch validation must enter through391     * [withEpochCommit] first; this helper deliberately does not reacquire the392     * lock, avoiding a reentrant gap between a failed write and its inverse.393     * With no prior vault the accurate inverse is having none at all, rather394     * than a half-written slot A that [hasVault] would report as a real vault.395     */396    private fun rollbackRestoreLocked(397        priorA: StoredVault?,398        priorB: String?,399        priorD: StoredVault? = null,400    ): List<String> {401        val failures = mutableListOf<String>()402        if (priorA == null) {403            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }404        } else {405            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }406                .onFailure { failures += "writeSlotA: $it" }407            if (priorB != null) {408                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }409                    .onFailure { failures += "writeSlotB: $it" }410            }411            // H-04: restore the prior duress slot — a half-committed restore that412            // left the duress record on the Codex side and slot A on the prior413            // side would put the at-rest pair in a state that never existed.414            if (priorD != null) {415                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }416                    .onFailure { failures += "writeDecoySlot: $it" }417            } else {418                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }419            }420        }421        return failures422    }423424    private fun reportRollbackFailures(failures: List<String>) {425        // Log is a throwing stub off-device, and a failed log must never mask426        // the rollback result the caller is about to report.427        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }428    }429430    /** Debug-only: structure of a failed Codex restore, with content masked. */431    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {432        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)433        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()434        val window = offset?.let {435            val from = (it - 40).coerceAtLeast(0)436            val to = (it + 40).coerceAtMost(wrapperJson.length)437            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")438        }439        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()440        val slot = wrapper?.get("a")441        buildString {442            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)443            append(" | blobBase64=").append(vaultBlobBase64.length)444            append(" wrapperChars=").append(wrapperJson.length)445            append(" wrapperParses=").append(wrapper != null)446            append(" keys=").append(wrapper?.keys)447            append(" slotAKind=").append(448                when {449                    slot == null -> "absent"450                    slot is JsonPrimitive && slot.isString -> "string"451                    slot is JsonObject -> "object:" + slot.keys452                    else -> slot::class.simpleName453                },454            )455            if (window != null) append(" masked@").append(offset).append("=").append(window)456        }457    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }458459    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {460        val expectedEpoch = currentEpoch()461        var key: ByteArray? = null462        return try {463            val salt = VaultCrypto.generateSalt()464            key = VaultCrypto.deriveKey(password, salt)465            if (mutateIfCurrentEpoch(expectedEpoch) {466                entryList = mutableListOf()467                duressConfigured = false468                recoveryWordHash = null469                recoveryWordsSet = false470                decoyMode = false471                sessionDataAvailable = false472                rootKey = generateRecoverableRootKey()473            } == null) return CreateVaultResult.Error("stale_create")474            // M-04: the fields above have to be live before sealSlotA, since it475            // seals currentPayload(). That stages a root key the session would476            // otherwise keep after a failed create — [lock] discards the whole477            // staged session on every non-success exit below.478            val slotA = sealSlotA(key!!, salt)479            // P1-3 (codex): every later step must roll the slot pair back if it480            // fails — leaving a half-built vault is worse than refusing to make it.481            beforeDurableCommit()482            val created = withEpochCommit(expectedEpoch) {483                if (!store.writeSlotA(slotA)) return@withEpochCommit false484                try {485                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")486                    // H-04: duress slot must be present from the start so its487                    // existence on disk is independent of duressConfigured.488                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")489                    store.stampLocalMutation()490                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")491                    true492                } catch (_: Exception) {493                    // Half-built vault on disk is worse than no vault — refuse to494                    // create if any step past slot A fails.495                    store.clearVault()496                    false497                }498            } ?: false499            if (!created) {500                lock()501                return CreateVaultResult.Error("storage_write_failed")502            }503            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")504            if (mutateIfCurrentEpoch(expectedEpoch) {505                derivedKey = sessionKey506                key = null507                publish()508                _isUnlocked.value = true509                sessionDataAvailable = true510                sessionEpoch = nextSessionEpoch(sessionEpoch)511            } == null) return CreateVaultResult.Error("stale_create")512            CreateVaultResult.Success513        } catch (e: Exception) {514            lock()515            CreateVaultResult.Error(e.message ?: "create_failed")516        } finally {517            key?.let(VaultCrypto::zero)518        }519    }520521    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {522        val expectedEpoch = currentEpoch()523        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")524        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")525        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {526            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")527        }528        var adoptedRootKey: ByteArray? = null529        var nextDerivedKey: ByteArray? = null530        var sessionRootKey: ByteArray? = null531        return try {532            adoptedRootKey = hexToRootKey(rootKeyHex)533            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {534                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")535            }536            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)537            val salt = VaultCrypto.generateSalt()538            nextDerivedKey = VaultCrypto.deriveKey(password, salt)539            sessionRootKey = adoptedRootKey.copyOf()540            val payload = VaultPayload(541                entries = emptyList(),542                duressConfigured = false,543                rootKeyB64 = b64(sessionRootKey),544                recoveryWordHash = null,545                recoveryWordsSet = false,546            )547            val plaintext = VaultCodec.encode(payload)548            val blob = try {549                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))550            } finally {551                VaultCrypto.zero(plaintext)552            }553            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)554            // P1-3 (codex): same partial-commit guard as createVault — any failure555            // past slot A wipes the half-built vault and refuses adoption.556            beforeDurableCommit()557            val created = withEpochCommit(expectedEpoch) {558                if (!store.writeSlotA(slotA)) return@withEpochCommit false559                try {560                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")561                    // H-04: duress slot must be present from the start so its562                    // existence on disk is independent of duressConfigured.563                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")564                    store.stampLocalMutation()565                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")566                    true567                } catch (_: Exception) {568                    store.clearVault()569                    false570                }571            } ?: false572            if (!created) return AdoptVaultResult.Error("storage_write_failed")573574            val adopted = mutateIfCurrentEpoch(expectedEpoch) {575                val previousDerivedKey = derivedKey576                val previousRootKey = rootKey577                derivedKey = nextDerivedKey578                rootKey = sessionRootKey579                nextDerivedKey = null580                sessionRootKey = null581                entryList = mutableListOf()582                duressConfigured = false583                recoveryWordHash = null584                recoveryWordsSet = false585                decoyMode = false586                sessionDataAvailable = true587                publish()588                _isUnlocked.value = true589                sessionEpoch = nextSessionEpoch(sessionEpoch)590                previousDerivedKey to previousRootKey591            }592            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")593            val (previousDerivedKey, previousRootKey) = adopted594            previousDerivedKey?.let(VaultCrypto::zero)595            previousRootKey?.let(VaultCrypto::zero)596            // Write the recovery escrow so the forgot-password flow works on597            // this device. The words are deterministic from the root key and598            // were already confirmed on the source device.599            runCatching {600                val words = rootKeyToWords(rootKey!!)601                val escrow = buildRecoveryEscrow(words)602                val rkEscrow = buildRootKeyEscrow(words)603                if (escrow != null || rkEscrow != null) {604                    val prior = store.loadSlotA()605                    if (prior != null) {606                        store.writeSlotA(prior.copy(607                            recoveryEscrow = escrow ?: prior.recoveryEscrow,608                            rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,609                        ))610                    }611                    mutateState {612                        recoveryWordHash = recoveryWordsHash(words)613                        recoveryWordsSet = true614                    }615                }616            }617            AdoptVaultResult.Success(vaultId)618        } catch (e: Exception) {619            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")620        } finally {621            adoptedRootKey?.let(VaultCrypto::zero)622            nextDerivedKey?.let(VaultCrypto::zero)623            sessionRootKey?.let(VaultCrypto::zero)624        }625    }626627    suspend fun unlock(password: String): UnlockResult = mutex.withLock {628        val result = unlockLocked(password, currentEpoch())629        if (result is UnlockResult.Success) ensureRecoveryEscrow()630        result631    }632633    /** Unlock body without acquiring the mutex — callers must already hold it. */634    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {635        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")636        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))637        val payload = try {638            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)639            try {640                VaultCodec.decode(plaintext)641            } finally {642                VaultCrypto.zero(plaintext)643            }644        } catch (_: Exception) {645            VaultCrypto.zero(key)646            // LV-1: a vault sealed before NFC normalization with a non-NFC647            // password derives a different key now. If the supplied password is648            // not already in NFC form, retry with the raw (un-normalized) bytes.649            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {650                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)651                if (legacy != null) return legacy652            }653            return UnlockResult.WrongPassword654        }655        return adoptSlotA(key, payload, expectedEpoch)656    }657658    /**659     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw660     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed661     * under the NFC-normalized key — so every future unlock takes the normal662     * path. Returns null when the raw key also fails (genuinely wrong password)663     * OR when any step of the heal commits fails (the original slot A is restored664     * so disk and session stay aligned).665     */666    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {667        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))668        var newKey: ByteArray? = null669        return try {670            val payload = try {671                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)672                try {673                    VaultCodec.decode(plaintext)674                } finally {675                    VaultCrypto.zero(plaintext)676                }677            } catch (_: Exception) {678                return null679            }680            // Heal: re-seal slot A under the NFC key (mirrors changePassword's681            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.682            val newSalt = VaultCrypto.generateSalt()683            newKey = VaultCrypto.deriveKey(password, newSalt)684            val plaintext = VaultCodec.encode(payload)685            val blob = try {686                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))687            } finally {688                VaultCrypto.zero(plaintext)689            }690            val next = StoredVault(691                version = slotA.version,692                salt = b64(newSalt),693                blob = blob,694                recoveryEscrow = slotA.recoveryEscrow,695                rootKeyEscrow = slotA.rootKeyEscrow,696            )697            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,698            // or stampLocalMutation must restore the prior slot A AND slot B so disk699            // and the eventual session key never disagree on the wrapping.700            val priorB = store.loadSlotB()701            beforeDurableCommit()702            val healed = withEpochCommit(expectedEpoch) {703                if (!store.writeSlotA(next)) {704                    false705                } else try {706                    if (!writeMatchingNoiseSlotB(next)) {707                        runCatching { store.writeSlotA(slotA) }708                        if (priorB != null) runCatching { store.writeSlotB(priorB) }709                        false710                    } else if (!writeCompanionDuressSlot(next)) {711                        runCatching { store.writeSlotA(slotA) }712                        if (priorB != null) runCatching { store.writeSlotB(priorB) }713                        false714                    } else {715                        store.stampLocalMutation()716                        true717                    }718                } catch (_: Exception) {719                    runCatching { store.writeSlotA(slotA) }720                    if (priorB != null) runCatching { store.writeSlotB(priorB) }721                    false722                }723            } ?: false724            if (healed) {725                VaultCrypto.zero(rawKey!!)726                rawKey = null727                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot728                newKey = null729                result730            } else {731                VaultCrypto.zero(newKey!!)732                newKey = null733                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key734                rawKey = null735                result736            }737        } finally {738            // Cover codec, encryption, storage, and stale-epoch failures before739            // either candidate key can be transferred into the live session.740            rawKey?.let(VaultCrypto::zero)741            newKey?.let(VaultCrypto::zero)742        }743    }744745    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */746    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {747        return try {748            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they749            // can throw after the fields above are live. There is no prior session750            // worth restoring — adoption has already replaced it — so a failed751            // adoption discards everything rather than leaving entries and keys752            // resident behind _isUnlocked=false.753            val adopted = mutateIfCurrentEpoch(expectedEpoch) {754                derivedKey = key755                decoyMode = false756                entryList = payload.entries.toMutableList()757                duressConfigured = payload.duressConfigured758                recoveryWordHash = payload.recoveryWordHash759                recoveryWordsSet = payload.recoveryWordsSet760                sessionDataAvailable = true761                _settings.value = store.loadSettings()762                ensureRootKey(payload)763                autoPurgeExpiredTrash()764                consolidatePasskeyEntries()765                publish()766                _isUnlocked.value = true767                // A successful adoption starts a new generation. A sibling768                // SlotAUnlock captured in the prior epoch will fail closed at769                // the next commit instead of replacing this session.770                sessionEpoch = nextSessionEpoch(sessionEpoch)771            }772            if (adopted == null) {773                VaultCrypto.zero(key)774                UnlockResult.Error("stale_unlock")775            } else {776                UnlockResult.Success777            }778        } catch (e: Exception) {779            // Do not call lock() here: a concurrent newer session may already780            // own this repository. Only invalidate the failed epoch's fields.781            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }782            VaultCrypto.zero(key)783            UnlockResult.Error(e.message ?: "unlock_failed")784        }785    }786787    // ---- Constant-time unlock (MF-5) --------------------------------------788789    /**790     * A slot-A unlock that has been derived but not yet adopted. Holds the live791     * Argon2id key and decoded payload so the caller can derive several candidates792     * concurrently and commit only the winner. The key is owned by this token until793     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.794     */795    class SlotAUnlock internal constructor(796        key: ByteArray,797        internal val payload: VaultPayload,798        internal val epoch: Long,799    ) {800        private var ownedKey: ByteArray? = key801        private var consumed = false802803        /** Transfer the key exactly once to the adopter. */804        internal fun takeKey(): ByteArray? = synchronized(this) {805            if (consumed) return@synchronized null806            consumed = true807            ownedKey.also { ownedKey = null }808        }809810        /** Discard an uncommitted token without touching a transferred key. */811        internal fun discard() = synchronized(this) {812            if (consumed) return@synchronized813            consumed = true814            ownedKey?.let(VaultCrypto::zero)815            ownedKey = null816        }817    }818819    /**820     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.821     * Pure computation (no mutex), so it can run concurrently with other candidate822     * derivations before the caller decides which to commit (MF-5). Returns null on823     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).824     */825    fun tryUnlockSlotA(password: String): SlotAUnlock? {826        val epoch = currentEpoch()827        val slotA = store.loadSlotA() ?: return null828        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))829        return try {830            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)831            val payload = try {832                VaultCodec.decode(plaintext)833            } finally {834                VaultCrypto.zero(plaintext)835            }836            SlotAUnlock(key, payload, epoch)837        } catch (_: Exception) {838            VaultCrypto.zero(key)839            null840        }841    }842843    /** Adopt a previously-derived [SlotAUnlock] as the live session. */844    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {845        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")846        adoptSlotA(key, unlock.payload, unlock.epoch)847    }848849    /** Zero a derived [SlotAUnlock] that will not be adopted. */850    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()851852    // ---- Duress slot B (MF-6) ---------------------------------------------853854    /**855     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key856     * and decoded decoy payload so it can be derived concurrently with slot A and857     * only committed if the duress passphrase was the one entered (MF-5/MF-6).858     */859    class DuressUnlock internal constructor(860        key: ByteArray,861        internal val payload: VaultPayload,862        internal val epoch: Long,863    ) {864        private var ownedKey: ByteArray? = key865        private var consumed = false866867        internal fun takeKey(): ByteArray? = synchronized(this) {868            if (consumed) return@synchronized null869            consumed = true870            ownedKey.also { ownedKey = null }871        }872873        internal fun discard() = synchronized(this) {874            if (consumed) return@synchronized875            consumed = true876            ownedKey?.let(VaultCrypto::zero)877            ownedKey = null878        }879    }880881    /**882     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the883     * session. Success means the entered password is the duress passphrase that keys884     * slot B — indistinguishable from the real slot A at the lock screen. Pure885     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].886     * Returns null when no duress slot exists or the password is not the duress one.887     */888    fun tryUnlockDuress(password: String): DuressUnlock? {889        val epoch = currentEpoch()890        val slot = store.loadDecoySlot() ?: return null891        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))892        return try {893            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)894            val payload = try {895                VaultCodec.decode(plaintext)896            } finally {897                VaultCrypto.zero(plaintext)898            }899            DuressUnlock(key, payload, epoch)900        } catch (_: Exception) {901            VaultCrypto.zero(key)902            null903        }904    }905906    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */907    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {908        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")909        if (currentEpoch() != unlock.epoch) {910            VaultCrypto.zero(key)911            return@withLock UnlockResult.Error("stale_unlock")912        }913        return try {914            val adopted = mutateIfCurrentEpoch(unlock.epoch) {915                derivedKey = key916                decoyMode = true917                entryList = unlock.payload.entries.toMutableList()918                duressConfigured = false919                recoveryWordHash = unlock.payload.recoveryWordHash920                recoveryWordsSet = unlock.payload.recoveryWordsSet921                sessionDataAvailable = true922                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)923                          else VaultCrypto.generateRootKey()924                _settings.value = store.loadSettings()925                publish()926                _isUnlocked.value = true927                sessionEpoch = nextSessionEpoch(sessionEpoch)928            }929            if (adopted == null) {930                VaultCrypto.zero(key)931                UnlockResult.Error("stale_unlock")932            } else {933                UnlockResult.Success934            }935        } catch (e: Exception) {936            // A payload/root/settings failure can occur after the candidate key937            // has been assigned to derivedKey. Tear down only this epoch so a938            // newer session is not disturbed, and scrub the token reference on939            // every error path.940            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }941            VaultCrypto.zero(key)942            UnlockResult.Error(e.message ?: "decoy_unlock_failed")943        }944    }945946    /** Zero a derived [DuressUnlock] that will not be adopted. */947    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()948949    /**950     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an951     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The952     * passphrase is never stored — only its ability to decrypt slot B proves it.953     * Requires the real vault to be unlocked and not itself a decoy session.954     */955    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {956        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().957        val expectedEpoch = mutateState {958            if (derivedKey != null && !decoyMode) sessionEpoch else null959        } ?: return false960        // Reject a duress passphrase that also opens the real vault: it would make the961        // lock screen ambiguous (both slots would decrypt for the same input).962        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }963        var key: ByteArray? = null964        var basePlaintext: ByteArray? = null965        return try {966            val salt = VaultCrypto.generateSalt()967            key = VaultCrypto.deriveKey(duressPassphrase, salt)968            // An empty decoy vault with its own fresh sync root key — nothing real leaks.969            val decoyRoot = VaultCrypto.generateRootKey()970            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }971            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)972            basePlaintext = VaultCodec.encode(decoyPayload)973            // H-04: pad the duress plaintext to slot A's ciphertext length so the974            // duress blob matches slot A's blob on disk. VaultCodec.decode strips975            // trailing NULs, so the extra padding survives encrypt but never976            // reaches the JSON parser.977            // ponytail: only re-pads at configuration time — if slot A grows past978            // its initial bucket (entries cross 4096/16384/etc.), the real decoy979            // stays at the smaller bucket. Re-padding on every persist requires980            // keeping the duress passphrase in memory, which we don't.981            val slotA = store.loadSlotA()982            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0983            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!984            val blob = try {985                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))986            } finally {987                VaultCrypto.zero(plaintext)988                // Padding creates a second array; scrub the original encoded989                // payload too rather than dropping its last reference untouched.990                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)991                basePlaintext = null992                key?.let(VaultCrypto::zero)993                key = null994            }995            beforeDurableCommit()996            val ok = withEpochCommit(expectedEpoch) {997                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))998            } ?: false999            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {1000                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1001            } else if (ok) {1002                return false1003            }1004            ok1005        } catch (_: Exception) {1006            false1007        } finally {1008            // Cover failures before the encrypt block is entered (for example a1009            // codec/copy exception) as well as any early return after key derivation.1010            key?.let(VaultCrypto::zero)1011            basePlaintext?.let(VaultCrypto::zero)1012        }1013    }101410151016    /**1017     * Export the decoy slot as browser-shaped JSON for adoption snapshots.1018     *1019     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all1020     * lowercase hex — identical to the browser's `duress_slot` wire format — so1021     * any receiver (browser or Android) can install it via the existing1022     * passthrough. Returns null when duress is not configured, no decoy exists,1023     * or any conversion failure occurs. Never decrypts.1024     */1025    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1026        if (!duressConfigured) return null1027        val slot = store.loadDecoySlot() ?: return null1028        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1029    }10301031    /**1032     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1033     *1034     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1035     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1036     * byte arrays are zeroed in finally.1037     */1038    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1039        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1040        // serialized — kotlinx-serialization's encodeDefaults = false would1041        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1042        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1043        // silently dropping the duress slot on Android→browser adoptions.1044        var salt: ByteArray? = null1045        var blob: ByteArray? = null1046        var nonce: ByteArray? = null1047        var ctAndTag: ByteArray? = null1048        var ct: ByteArray? = null1049        var tag: ByteArray? = null1050        try {1051            salt = unb64(slot.salt)1052            blob = unb64(slot.blob)1053            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1054            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1055            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1056            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1057            val obj = JsonObject(mapOf(1058                "version" to JsonPrimitive(1),1059                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1060                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1061                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1062                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1063            ))1064            return obj.toString()1065        } finally {1066            salt?.let(VaultCrypto::zero)1067            blob?.let(VaultCrypto::zero)1068            nonce?.let(VaultCrypto::zero)1069            ctAndTag?.let(VaultCrypto::zero)1070            ct?.let(VaultCrypto::zero)1071            tag?.let(VaultCrypto::zero)1072        }1073    }10741075    /**1076     * Install a browser-shaped duress slot received through adoption.1077     *1078     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1079     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1080     * writes the result to the decoy slot. Epoch-fenced like the Codex1081     * restore path so a concurrent lock() cannot strand the write.1082     *1083     * Returns false on invalid input, no live session, or write failure.1084     */1085    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1086        val b = BrowserSlot.parse(json) ?: return false1087        var stored: StoredVault? = null1088        var salt: ByteArray? = null1089        var nonce: ByteArray? = null1090        var ct: ByteArray? = null1091        var tag: ByteArray? = null1092        var blob: ByteArray? = null1093        try {1094            salt = unhex(b.salt)1095            nonce = unhex(b.nonce)1096            ct = unhex(b.ciphertext)1097            tag = unhex(b.tag)1098            blob = ByteArray(nonce.size + ct.size + tag.size).also {1099                System.arraycopy(nonce, 0, it, 0, nonce.size)1100                System.arraycopy(ct, 0, it, nonce.size, ct.size)1101                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1102            }1103            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1104            val expectedEpoch = sessionEpoch1105            beforeDurableCommit()1106            withEpochCommit(expectedEpoch) {1107                store.writeDecoySlot(stored)1108            } ?: false1109        } catch (_: Exception) {1110            false1111        } finally {1112            salt?.let(VaultCrypto::zero)1113            nonce?.let(VaultCrypto::zero)1114            ct?.let(VaultCrypto::zero)1115            tag?.let(VaultCrypto::zero)1116            blob?.let(VaultCrypto::zero)1117        }1118    }11191120    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1121    suspend fun clearDuress(): Boolean = mutex.withLock {1122        // H-04: presence-indistinguishable — replace the real decoy with a fake1123        // blob of slot A's size rather than removing the duress record. The1124        // record stays on disk at the same length, so an at-rest observer1125        // cannot tell "duress cleared" from "no duress ever configured".1126        val expectedEpoch = currentEpoch()1127        val replaced = withEpochCommit(expectedEpoch) {1128            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1129                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1130        } ?: false1131        if (!replaced) return@withLock false1132        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1133        // duressConfigured write so a concurrent lock() cannot land between them.1134        if (!isSessionEpochCurrent(expectedEpoch)) return false1135        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1136        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1137            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1138        }1139        true1140    }11411142    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1143    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1144        var current: Throwable? = error1145        while (current != null) {1146            if (current is javax.crypto.AEADBadTagException) return true1147            // Some Android providers expose a GCM tag failure as the parent1148            // BadPaddingException rather than the AEAD-specific subtype.  A1149            // short ciphertext is an IllegalBlockSizeException and therefore1150            // remains an internal/malformed-payload Error below.1151            if (current is javax.crypto.BadPaddingException) return true1152            current = current.cause1153        }1154        return false1155    }11561157    /** Unlock with an already-derived AES-256 key (biometric path). */1158    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1159        val expectedEpoch = currentEpoch()1160        val slotA = try {1161            store.loadSlotA()1162        } catch (_: Exception) {1163            return@withLock UnlockResult.Error("unlock_key_storage_failed")1164        } ?: return@withLock UnlockResult.Error("no_vault")1165        // Keep the caller-owned key separate from the staged session key. The1166        // biometric caller always zeroes its input in a finally block; adoption1167        // must therefore own a copy and use the same all-or-nothing path as a1168        // password unlock rather than publishing a partially decoded session.1169        var stagedKey: ByteArray? = key.copyOf()1170        return try {1171            val encrypted = try {1172                unb64(slotA.blob)1173            } catch (_: Exception) {1174                return@withLock UnlockResult.Error("invalid_vault_blob")1175            }1176            // A GCM record must include nonce + authentication tag.  Providers1177            // differ on whether a shorter record is reported as BadPadding or1178            // AEADBadTag; reject it before decrypt so malformed storage cannot be1179            // misreported as a user-entered wrong key.1180            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1181                return@withLock UnlockResult.Error("invalid_vault_blob")1182            }1183            val plaintext = try {1184                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1185            } catch (e: Exception) {1186                if (isGcmAuthenticationFailure(e)) {1187                    return@withLock UnlockResult.WrongPassword1188                }1189                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1190            }1191            val payload = try {1192                VaultCodec.decode(plaintext)1193            } catch (_: Exception) {1194                return@withLock UnlockResult.Error("invalid_vault_payload")1195            } finally {1196                VaultCrypto.zero(plaintext)1197            }1198            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1199            val result = adoptSlotA(candidate, payload, expectedEpoch)1200            if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1201            result1202        } finally {1203            stagedKey?.let(VaultCrypto::zero)1204        }1205    }12061207    fun lock() {1208        // Never snapshot a mutation that has changed entryList but has not yet1209        // crossed its durable epoch fence. Successful writes refresh the spool1210        // before releasing this mutex; an in-flight writer therefore makes this1211        // lock skip the convenience refresh and fail closed to the prior cache.1212        if (mutex.tryLock()) {1213            try {1214                runCatching { LockedSyncSpoolHooks.refresh(this) }1215            } finally {1216                mutex.unlock()1217            }1218        }1219        // H-03: must observe derivedKey/entryList atomically with any in-flight1220        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1221        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1222        // guards just the brief state reset. Advancing the epoch first invalidates1223        // every operation that captured this session before it reaches a later1224        // durable commit or publication.1225        stateLock.lock()1226        try {1227            sessionEpoch = nextSessionEpoch(sessionEpoch)1228            clearSessionStateLocked()1229            _uiGateLocked.value = false1230        } finally {1231            stateLock.unlock()1232        }1233    }12341235    /**1236     * Enter sync-retained background mode: the UI shows the lock screen but the1237     * vault stays fully unlocked in memory so [PeachSyncService] can complete1238     * sync sessions. Called from [PeachApplication.onStop] when background sync1239     * is enabled. A subsequent full [lock] (auto-lock timer, manual lock)1240     * supersedes this and zeroes everything.1241     */1242    fun lockUiGate() {1243        if (_isUnlocked.value) _uiGateLocked.value = true1244    }12451246    /** Dismiss the UI gate after the user authenticates on resume. */1247    fun clearUiGate() {1248        _uiGateLocked.value = false1249    }12501251    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12521253    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1254    // is a stub off-device).1255    @VisibleForTesting1256    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12571258    /**1259     * Call immediately before launching a system Activity (a SAF file1260     * picker/save dialog, a share sheet) that briefly takes the whole process1261     * out of the foreground as an expected part of an in-app flow — not a1262     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1263     * two apart on its own (it fires for both), and without this, exporting or1264     * importing a file silently locked and wiped the vault mid-flow the moment1265     * the picker took over, before its result ever reached the app.1266     *1267     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1268     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1269     */1270    fun expectTransientBackground() {1271        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1272    }12731274    /**1275     * Consumed once by the next background-lock check; see [expectTransientBackground].1276     * Returns false (and clears the flag) if the suppression has expired or was1277     * never set — a genuine background event must always lock.1278     */1279    fun consumeTransientBackgroundSuppression(): Boolean {1280        val until = suppressNextBackgroundLockUntil1281        val active = until != 0L && nowMs() < until1282        suppressNextBackgroundLockUntil = 0L1283        return active1284    }12851286    // ---- Sync identity ----------------------------------------------------12871288    /**1289     * The vault's stable sync master key (a copy). Caller must zero it. Backs1290     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1291     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1292     */1293    /**1294     * HOLE 5 — the duress vault never syncs.1295     *1296     * [commitDuressUnlock] gives the decoy session a root key so its own codex1297     * and recovery paths work, which means (unlike the browser, where the decoy1298     * path simply has no root key) the sync surface has to refuse explicitly.1299     * Without this the decoy vault would present a second, different vault_id1300     * to a paired device, and a coerced user opening it would make sync visibly1301     * fail on their other devices — a duress indicator, which is the one thing1302     * a duress vault must never be.1303     */1304    // M-04: the "null when locked" half of the contract was documented but never1305    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1306    // [unlockWithRecoveryWords], which installs a root key with no password key1307    // yet) and in a create that failed after staging. Sync must fail closed1308    // there, so the authoritative unlocked flag gates it, not just decoyMode.1309    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)13101311    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1312        if (decoyMode || !_isUnlocked.value) null1313        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1314    }13151316    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1317        mutateState { sessionEpoch == epoch }13181319    internal fun unlockedSessionEpoch(): Long? = mutateState {1320        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1321    }13221323    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13241325    /**1326     * The current session's root key, decoy included. For local-only uses that1327     * must behave identically in a decoy session — Codex backup and recovery1328     * words — because a feature that errors under duress is itself a duress1329     * tell. In a decoy session this is the DECOY's root key (see1330     * [commitDuressUnlock]), never the real vault's, so neither surface can1331     * leak the real vault. Anything that leaves the device must use1332     * [syncMasterKey] instead.1333     */1334    fun sessionRootKey(): ByteArray? = mutateState {1335        if (!sessionDataAvailable) null else rootKey?.copyOf()1336    }13371338    /** Stable per-vault id two paired devices must share to sync. Null when1339     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1340    fun syncVaultId(): String? = mutateState {1341        if (decoyMode || !_isUnlocked.value) null1342        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1343    }13441345    /**1346     * Browser parity (MV-8): rotate the sync identity by replacing the stable1347     * root key. Entries remain under the master-password key, while sync1348     * credentials, vault_id, and recovery words all change.1349     */1350    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1351        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1352            ?: return false1353        installRootKey(generateRecoverableRootKey(), expectedEpoch)1354    }13551356    /**1357     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1358     * keeping every local entry.1359     *1360     * Sync identity (vault id, signing keys) derives from the root key, and1361     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1362     * created vaults can never reconcile. Taking the other side's root key is1363     * what makes them one vault; from the next sync on, both entry sets merge.1364     *1365     * Entries survive because they are sealed under the password-derived key,1366     * not the root key — the same property [rotateSyncIdentity] relies on.1367     *1368     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1369     * this device's previous recovery words stop working for good, and its new1370     * words are the source vault's words, because words are a deterministic1371     * encoding of the root key.1372     */1373    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1374        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1375            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1376        }1377        val expectedEpoch = mutateState {1378            when {1379                derivedKey == null -> null1380                decoyMode -> Long.MIN_VALUE1381                else -> sessionEpoch1382            }1383        }1384        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1385        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1386        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13871388        val incoming = hexToRootKey(rootKeyHex)1389        if (incoming.size != VaultCrypto.KEY_SIZE) {1390            VaultCrypto.zero(incoming)1391            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1392        }1393        // Refuse before committing rather than leave this device holding a vault1394        // whose recovery words cannot be typed back. generateRecoverableRootKey1395        // screens its own keys for this; a key arriving from a peer has not been.1396        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1397        if (!roundTrips) {1398            VaultCrypto.zero(incoming)1399            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1400        }14011402        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1403        return if (installRootKey(incoming, expectedEpoch)) {1404            AdoptVaultResult.Success(vaultId)1405        } else {1406            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1407        }1408    }14091410    /**1411     * The recovery words [rootKeyHex] will produce once joined, or null if it is1412     * not a usable root key. Lets the join flow show the user the words that are1413     * about to replace theirs *before* anything is committed.1414     */1415    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1416        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1417        val bytes = hexToRootKey(rootKeyHex)1418        return try {1419            val words = rootKeyToWords(bytes)1420            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1421        } catch (_: Exception) {1422            null1423        } finally {1424            VaultCrypto.zero(bytes)1425        }1426    }14271428    /**1429     * Swap the vault's root key, keeping every entry, and re-escrow under the1430     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1431     * [mutex].1432     */1433    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1434        // H-03: snapshot the live fields under stateLock so the success and the1435        // rollback paths both see consistent prior/new state. Writing the new1436        // rootKey/recovery fields is inside its own mutateState at the end.1437        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1438        // — holding live references would let a zero-on-failure operation also1439        // wipe the session's still-live key arrays.1440        data class Snapshot(1441            val key: ByteArray,1442            val priorRootKey: ByteArray,1443            val priorRecoveryHash: String?,1444            val priorRecoverySet: Boolean,1445        )1446        val snap = mutateIfCurrentEpoch(expectedEpoch) {1447            val k = derivedKey ?: return@mutateIfCurrentEpoch null1448            if (decoyMode) return@mutateIfCurrentEpoch null1449            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1450            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1451        } ?: run {1452            VaultCrypto.zero(nextRootKey)1453            return false1454        }1455        val priorSlot = store.loadSlotA() ?: run {1456            VaultCrypto.zero(snap.key)1457            VaultCrypto.zero(snap.priorRootKey)1458            VaultCrypto.zero(nextRootKey)1459            return false1460        }1461        val nextWords = rootKeyToWords(nextRootKey)14621463        return try {1464            beforeDurableCommit()1465            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1466            if (mutateIfCurrentEpoch(expectedEpoch) {1467                rootKey = nextRootKey1468                recoveryWordHash = recoveryWordsHash(nextWords)1469                recoveryWordsSet = false1470            } == null) throw IllegalStateException("stale_root_rotation")14711472            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1473                ?: throw IllegalStateException("recovery_escrow_failed")1474            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1475                ?: throw IllegalStateException("root_escrow_failed")1476            val plaintext = VaultCodec.encode(currentPayload())1477            val blob = try {1478                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1479            } finally {1480                VaultCrypto.zero(plaintext)1481            }1482            val nextSlot = priorSlot.copy(1483                blob = blob,1484                recoveryEscrow = nextRecoveryEscrow,1485                rootKeyEscrow = nextRootKeyEscrow,1486            )1487            val committed = withEpochCommit(expectedEpoch) {1488                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1489                try {1490                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1491                    // H-04: a root-key rotation re-seals slot A, so the duress slot1492                    // (real or fake) must be re-padded to the new ciphertext length.1493                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1494                    store.stampLocalMutation()1495                } catch (_: Exception) {1496                    store.writeSlotA(priorSlot)1497                    return@withEpochCommit false1498                }1499                true1500            } ?: false1501            if (!committed) throw IllegalStateException("storage_write_failed")1502            mutateIfCurrentEpoch(expectedEpoch) { publish() }1503            true1504        } catch (_: Exception) {1505            val restored = mutateIfCurrentEpoch(expectedEpoch) {1506                rootKey?.let(VaultCrypto::zero)1507                rootKey = snap.priorRootKey.copyOf()1508                recoveryWordHash = snap.priorRecoveryHash1509                recoveryWordsSet = snap.priorRecoverySet1510            }1511            if (restored != null) {1512                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1513            }1514            false1515        } finally {1516            VaultCrypto.zero(snap.key)1517            VaultCrypto.zero(snap.priorRootKey)1518            // On success nextRootKey is owned by rootKey. On every other path1519            // it is not, including a stale epoch after lock(); scrub it while1520            // the local reference is still available.1521            val stillOwnedBySession = mutateState {1522                sessionEpoch == expectedEpoch && rootKey === nextRootKey1523            }1524            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1525        }1526    }15271528    /**1529     * A copy of the live Argon2id-derived vault key, for biometric escrow only1530     * (wrapped under a hardware KeyStore key on biometric enable). Null when1531     * locked. Caller must [VaultCrypto.zero] it after use.1532     */1533    fun currentVaultKey(): ByteArray? = mutateState {1534        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1535    }15361537    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1538        val expectedEpoch = currentEpoch()1539        val slotA = store.loadSlotA() ?: return false1540        var oldKey: ByteArray? = null1541        var newKey: ByteArray? = null1542        var plaintext: ByteArray? = null1543        return try {1544            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1545            // Keep ownership of the decrypted vault plaintext until decoding has1546            // finished.  Passing decrypt() inline made the byte array unreachable1547            // before the finally block, so a successful or failed password change1548            // could leave the full plaintext resident in the heap.1549            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1550            val payload = try {1551                VaultCodec.decode(decrypted)1552            } finally {1553                VaultCrypto.zero(decrypted)1554            }1555            val newSalt = VaultCrypto.generateSalt()1556            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1557            // Re-seal the existing payload under the new key/salt.1558            plaintext = VaultCodec.encode(payload)1559            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1560            // The escrow seals the slot-A PLAINTEXT, which a password change does not1561            // alter — only the wrapping changes — so carry it forward; rebuilding the1562            // StoredVault from scratch would silently drop it.1563            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1564            // content that a password change does not alter — only the slot-A1565            // wrapping rotates. The root-key escrow staying stable across password1566            // changes matches the browser (`vault.ts:363-364`).1567            val next = StoredVault(1568                version = slotA.version,1569                salt = b64(newSalt),1570                blob = blob,1571                recoveryEscrow = slotA.recoveryEscrow,1572                rootKeyEscrow = slotA.rootKeyEscrow,1573            )1574            // H-01: durably commit slot A + size-matched noise + version stamp1575            // before swapping the session key. A partial commit (new A on disk,1576            // old key in memory) would have the next save encrypt under the old1577            // key while matching disk on the new one.1578            val priorB = store.loadSlotB()1579            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1580            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1581            // inside the same stateLock critical section, so a concurrent lock()1582            // can't observe the in-between (zeroed but not yet reassigned).1583            if (mutateIfCurrentEpoch(expectedEpoch) {1584                oldKey?.let(VaultCrypto::zero); oldKey = null1585                derivedKey?.let(VaultCrypto::zero)1586                derivedKey = newKey; newKey = null1587            } == null) return false1588            true1589        } catch (_: Exception) {1590            false1591        } finally {1592            oldKey?.let(VaultCrypto::zero)1593            newKey?.let(VaultCrypto::zero)1594            plaintext?.let(VaultCrypto::zero)1595        }1596    }15971598    /**1599     * Irreversible wipe. Locks the session and clears every vault key. The Pro1600     * license is in a separate store and is preserved by construction.1601     */1602    suspend fun destroyVault() = mutex.withLock {1603        lock()1604        store.clearVault()1605        store.clearDecoySlot()1606    }16071608    // ---- Entry CRUD -------------------------------------------------------16091610    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1611        val epoch = requireUnlocked()1612        val stamp = VaultCrypto.monotonicEntryTimestamp()1613        val now = System.currentTimeMillis()1614        val entry = withDerivedLast4(draft.copy(1615            id = draft.id.ifBlank { UUID.randomUUID().toString() },1616            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1617            modifiedAt = stamp,1618            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1619        ))1620        // H-03: capture rollback AND write entryList under stateLock so a1621        // concurrent lock() cannot zero the session between the read and the write.1622        val rollback = mutateCurrentSession(epoch) {1623            val r = entryList.toMutableList()1624            entryList.add(entry)1625            r1626        } ?: throw IllegalStateException("vault_locked")1627        persistOrRollback(rollback, epoch)1628        entry1629    }16301631    /**1632     * Insert many entries with a single encrypt-and-write at the end.1633     *1634     * Importing a real vault through [addEntry] was quadratic: every entry took1635     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1636     * restore did 806 full-vault writes and hung the app long enough to ANR.1637     * Rolls back to the pre-import list if the one write fails, matching1638     * [addEntry]'s all-or-nothing behaviour.1639     */1640    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1641        val epoch = requireUnlocked()1642        if (drafts.isEmpty()) return 01643        val now = System.currentTimeMillis()1644        val ready = drafts.map { draft ->1645            val stamp = VaultCrypto.monotonicEntryTimestamp()1646            withDerivedLast4(1647                draft.copy(1648                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1649                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1650                    modifiedAt = stamp,1651                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1652                ),1653            )1654        }1655        // H-03: capture rollback AND append every entry under stateLock.1656        val rollback = mutateCurrentSession(epoch) {1657            val r = entryList.toMutableList()1658            entryList.addAll(ready)1659            r1660        } ?: throw IllegalStateException("vault_locked")1661        persistOrRollback(rollback, epoch)1662        return drafts.size1663    }16641665    /**1666     * M-01: replace the whole active set with [newEntries] in one write.1667     *1668     * Import's REPLACE mode used to soft-delete every current entry one durable1669     * write at a time (swallowing each failure) and then batch-add, so a failed1670     * add left the old entries stranded in Trash with nothing restored. Staging1671     * the entire next list in memory and committing it through1672     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1673     * pre-import list — active entries included — is restored.1674     *1675     * Existing tombstones are preserved so paired devices still see the1676     * deletions they already know about.1677     */1678    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1679        val epoch = requireUnlocked()1680        val now = System.currentTimeMillis()1681        val ready = newEntries.map { draft ->1682            val stamp = VaultCrypto.monotonicEntryTimestamp()1683            withDerivedLast4(1684                draft.copy(1685                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1686                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1687                    modifiedAt = stamp,1688                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1689                ),1690            )1691        }1692        // M-03: an incoming entry that shares an id with an existing live entry1693        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1694        // the old one and then appending the new one would leave the list with1695        // two entries of the same id — the tombstoned one would be returned by1696        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1697        // superseded entries from the tombstone set instead.1698        val incomingIds = ready.map { it.id }.toHashSet()1699        val rollback = mutateCurrentSession(epoch) {1700            val r = entryList.toMutableList()1701            val replaced = entryList.map { entry ->1702                if (entry.deletedAt != null) return@map entry1703                if (entry.id in incomingIds) return@map null1704                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1705                entry.copy(deletedAt = stamp, modifiedAt = stamp)1706            }.filterNotNull()1707            entryList = (replaced + ready).toMutableList()1708            r1709        } ?: throw IllegalStateException("vault_locked")1710        persistOrRollback(rollback, epoch)1711        return ready.size1712    }17131714    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1715        val epoch = requireUnlocked()1716        // H-03: read entryList, compute the patched entry, and write it under a1717        // single stateLock so a concurrent lock() cannot interleave between the1718        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1719        // list, or clobber the entry onto the wrong id after a relock).1720        val prepared = mutateCurrentSession(epoch) {1721            val idx = entryList.indexOfFirst { it.id == patch.id }1722            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1723            val current = entryList[idx]1724            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1725            // Only refresh passwordChangedAt when the password value actually changes.1726            val passwordChanged = patch.password != null && patch.password != current.password1727            // Capture the outgoing password (newest first) so the user can reveal/copy/1728            // restore it later. History is computed authoritatively from `current`, not1729            // the patch — updateEntry is a full-entry replace, so trusting patch's value1730            // would let a stale form clobber the real history. Record the age the old1731            // password carried, skip an empty prior, and skip an immediate exact dupe1732            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1733            val priorPassword = current.password1734            val shouldCapture = passwordChanged &&1735                !priorPassword.isNullOrEmpty() &&1736                current.passwordHistory?.firstOrNull()?.password != priorPassword1737            val nextHistory = if (shouldCapture) {1738                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1739                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1740            } else {1741                current.passwordHistory1742            }1743            val updated = withDerivedLast4(patch.copy(1744                createdAt = current.createdAt,1745                modifiedAt = stamp,1746                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1747                passwordHistory = nextHistory,1748            ))1749            val rollback = entryList.toMutableList()1750            entryList[idx] = updated1751            updated to rollback1752        } ?: throw IllegalStateException("vault_locked")1753        val (updated, rollback) = prepared1754        persistOrRollback(rollback, epoch)1755        updated1756    }17571758    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1759    suspend fun deleteEntry(id: String) = mutex.withLock {1760        val epoch = requireUnlocked()1761        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1762        // the list out from under the indexOfFirst.1763        val rollback = mutateCurrentSession(epoch) {1764            val idx = entryList.indexOfFirst { it.id == id }1765            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1766            val current = entryList[idx]1767            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1768            val prior = entryList.toMutableList()1769            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1770            prior1771        } ?: throw IllegalStateException("vault_locked")1772        persistOrRollback(rollback, epoch)1773    }17741775    suspend fun restoreEntry(id: String) = mutex.withLock {1776        val epoch = requireUnlocked()1777        val rollback = mutateCurrentSession(epoch) {1778            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1779            if (idx == -1) throw IllegalStateException("deleted entry not found")1780            val current = entryList[idx]1781            val prior = entryList.toMutableList()1782            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1783            prior1784        } ?: throw IllegalStateException("vault_locked")1785        persistOrRollback(rollback, epoch)1786    }17871788    /** Permanent delete — no tombstone, no recovery. */1789    suspend fun purgeEntry(id: String) = mutex.withLock {1790        val epoch = requireUnlocked()1791        val rollback = mutateCurrentSession(epoch) {1792            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1793            if (idx == -1) throw IllegalStateException("deleted entry not found")1794            val prior = entryList.toMutableList()1795            entryList.removeAt(idx)1796            prior1797        } ?: throw IllegalStateException("vault_locked")1798        persistOrRollback(rollback, epoch)1799    }18001801    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1802    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1803        val epoch = requireUnlocked()1804        // H-03: read+remove under stateLock so lock() can't reorder the remove.1805        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1806        val r = mutateCurrentSession(epoch) {1807            val rollback = entryList.toMutableList()1808            val before = entryList.size1809            entryList.removeAll { it.deletedAt != null }1810            R(rollback, before - entryList.size)1811        } ?: throw IllegalStateException("vault_locked")1812        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1813        r.removed1814    }18151816    /** Browser parity: number of active entries that dedupe would move to trash. */1817    suspend fun countDuplicateEntries(): Int = mutex.withLock {1818        val epoch = requireUnlocked()1819        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1820        // lock() can't race the read with state writes.1821        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1822            ?: throw IllegalStateException("vault_locked")1823    }18241825    /**1826     * Browser parity: soft-delete duplicate entries, keeping the most recently1827     * modified member of each group. Refuse in decoy mode so the duress vault is1828     * not reshaped into a forensically distinct state.1829     */1830    suspend fun deduplicateEntries(): Int = mutex.withLock {1831        val epoch = requireUnlocked()1832        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1833        val ready = mutateCurrentSession(epoch) {1834            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1835            val active = entryList.filter { it.deletedAt == null }1836            val keepIds = mutableSetOf<String>()1837            active.groupBy { it.duplicateKey() }.values.forEach { group ->1838                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1839            }1840            val rollback = entryList.toMutableList()1841            var removed = 01842            entryList = entryList.map { entry ->1843                if (entry.deletedAt == null && entry.id !in keepIds) {1844                    removed++1845                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1846                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1847                } else {1848                    entry1849                }1850            }.toMutableList()1851            R(rollback, removed)1852        } ?: throw IllegalStateException("vault_locked")1853        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1854        ready.removed1855    }18561857    fun getEntries(): List<VaultEntry> = mutateState {1858        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1859    }1860    fun getEntry(id: String): VaultEntry? = mutateState {1861        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1862    }1863    fun getDeletedEntries(): List<VaultEntry> = mutateState {1864        if (!sessionDataAvailable) emptyList()1865        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1866    }18671868    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1869    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1870        return mutateState {1871            if (!sessionDataAvailable) return@mutateState emptyList()1872            val active = entryList.filter { it.deletedAt == null }1873            if (tagFilter == null) return@mutateState active1874            val needle = tagFilter.trim().lowercase()1875            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1876        }1877    }18781879    /** Sorted, de-duplicated tags across all active entries. */1880    fun getDistinctTags(): List<String> = mutateState {1881        if (!sessionDataAvailable) return@mutateState emptyList()1882        entryList.asSequence()1883            .filter { it.deletedAt == null }1884            .flatMap { it.tags?.asSequence() ?: emptySequence() }1885            .map { it.trim().lowercase() }1886            .filter { it.isNotEmpty() }1887            .distinct()1888            .sorted()1889            .toList()1890    }18911892    /**1893     * Distinct non-blank values for an identity field across active entries,1894     * most-frequently-used first. Supports "username" and "email"; unknown field1895     * names return an empty list. Values keep original casing (deduped1896     * case-insensitively) so a suggestion matches what the user typed.1897     */1898    fun getDistinctFieldValues(field: String): List<String> {1899        val select: (VaultEntry) -> String? = when (field) {1900            "username" -> { e -> e.username }1901            "email" -> { e -> e.email }1902            else -> return emptyList()1903        }1904        return mutateState {1905            if (!sessionDataAvailable) return@mutateState emptyList()1906            entryList.asSequence()1907                .filter { it.deletedAt == null }1908                .mapNotNull { select(it)?.trim() }1909                .filter { it.isNotEmpty() }1910                .groupingBy { it.lowercase() }1911                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1912                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1913                }1914                .values1915                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1916                .map { it.first }1917        }1918    }19191920    fun getTombstones(): List<Tombstone> = mutateState {1921        if (!sessionDataAvailable) emptyList()1922        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1923    }1924    fun getVaultVersion(): Long = store.version()1925    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19261927    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1928        val expectedEpoch = currentEpoch()1929        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1930        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1931        // H-04: snapshots now carry the duress slot too — restore preserves1932        // the at-rest triad (A/B/D), not just the A/B pair.1933        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1934        val priorA = store.loadSlotA()1935        val priorB = store.loadSlotB()1936        val priorD = store.loadDecoySlot()1937        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1938        // slot write — the old code took this snapshot, then stranded it on1939        // every failure, polluting history with a "restore" entry that never1940        // restored anything. The history below is rolled back together with1941        // the slots if the commit fails.1942        val priorHistory = store.loadVaultHistory()1943        // P1-4 (codex): the recovery snapshot must land before we commit the1944        // slot pair, otherwise a failed restore cannot be rolled back to the1945        // pre-restore history. If the history write itself fails, refuse to1946        // proceed — the alternative is a successful restore with no undo path.1947        beforeDurableCommit()1948        if (!takeVaultSnapshot("restore", expectedEpoch)) {1949            return false1950        }1951        // H-04: legacy snapshots without vault_d, or vault_d that fails to1952        // decode, fall back to a fake duress of slot A's size — presence-1953        // indistinguishable from any other freshly-restored vault.1954        val nextD: StoredVault = restoredD ?: run {1955            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161956            val fakeBlob = ByteArray(target).also(rng::nextBytes)1957            val fakeSalt = VaultCrypto.generateSalt()1958            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1959        }1960        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1961            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1962            return false1963        }1964        lock()1965        true1966    }19671968    /**1969     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1970     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1971     * (a restore preserves the duress-shaped blob from history), not a fresh1972     * noise record. Any failure rolls back so the at-rest pair never holds a1973     * restored slot A against a pre-restore slot B. Caller is responsible for1974     * restoring the pre-snapshot history on a false return.1975     *1976     * H-04: duress slot is now part of the atomic triad — restored last so1977     * a duress-write failure rolls the A/B pair back too.1978     */1979    private fun commitRestoreAtomic(1980        nextA: StoredVault,1981        nextB: String,1982        nextD: StoredVault,1983        priorA: StoredVault?,1984        priorB: String?,1985        priorD: StoredVault?,1986        expectedEpoch: Long,1987    ): Boolean {1988        return withEpochCommit(expectedEpoch) {1989            if (!store.writeSlotA(nextA)) return@withEpochCommit false1990            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1991            // must trigger rollback; previously a throw escaped without restoring1992            // the prior slot A and history.1993            try {1994                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1995                // H-04: duress slot is part of the restore triad. Failures here1996                // roll A+B back so the at-rest state stays consistent.1997                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1998                store.stampLocalMutation()1999            } catch (_: Exception) {2000                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))2001                return@withEpochCommit false2002            }2003            true2004        } ?: false2005    }20062007    // ---- Sync apply (does NOT advance the version) ------------------------20082009    /**2010     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists2011     * WITHOUT bumping the version counter, so a sync round cannot cascade into2012     * another outbound sync. Returns the number of locally-applied changes.2013     */2014    suspend fun applyMergedRemoteEntries(2015        remoteEntries: List<VaultEntry>,2016        remoteTombstones: List<Tombstone>,2017        expectedEpoch: Long? = null,2018    ): Int = mutex.withLock {2019        val epoch = expectedEpoch ?: requireUnlocked()2020        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02021        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS2022        // BEFORE any comparison against a local one. Without this a peer can2023        // send a far-future modifiedAt and permanently pin its version of an2024        // entry against every honest local edit. The browser clamps in the2025        // matching place (vault.ts applyMergedRemoteEntries).2026        val mergeNow = System.currentTimeMillis()2027        val clamped = remoteEntries.mapNotNull { entry ->2028            // MN-3: drop peer entries that decode cleanly but are semantically2029            // hollow (blank title, credential-less login, husk passkey) and2030            // re-cap passwordHistory before anything merges into the vault.2031            SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2032                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2033                deletedAt = entry.deletedAt?.let {2034                    SyncLimits.clampSyncTimestamp(it, mergeNow)2035                },2036            )2037        }2038        val tombMap = remoteTombstones.associate {2039            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2040        }2041        // H-03: build the new entryList under stateLock so a concurrent lock()2042        // cannot zero derivedKey/entryList between the byId read and the merge.2043        val applied = mutateCurrentSession(epoch) {2044            var n = 02045            val byId = entryList.associateBy { it.id }.toMutableMap()20462047            for (remote in clamped) {2048                val tomb = tombMap[remote.id]2049                if (tomb != null && tomb >= remote.modifiedAt) continue2050                val local = byId[remote.id]2051                if (local == null) {2052                    entryList.add(remote); byId[remote.id] = remote; n++2053                } else if (remote.modifiedAt > local.modifiedAt) {2054                    val i = entryList.indexOfFirst { it.id == local.id }2055                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2056                }2057            }20582059            for ((id, deletedAt) in tombMap) {2060                val local = byId[id] ?: continue2061                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2062                if (deletedAt < local.modifiedAt) continue2063                val i = entryList.indexOfFirst { it.id == id }2064                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2065            }2066            n2067        } ?: return@withLock 020682069        if (applied > 0) {2070            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2071            mutateIfCurrentEpoch(epoch) { publish() }2072            runCatching { LockedSyncSpoolHooks.refresh(this) }2073        }2074        applied2075    }20762077    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2078        // Require a fully live session before exposing a settings mutation.  The2079        // epoch is captured with the read so a lock/new-session boundary cannot2080        // let stale work publish into the replacement session.2081        val epoch = requireUnlocked()2082        val prior = mutateCurrentSession(epoch) {2083            val old = _settings.value2084            _settings.value = settings2085            old2086        } ?: throw IllegalStateException("vault_locked")20872088        val committed = try {2089            beforeDurableCommit()2090            // The durable write is inside the same epoch fence as the in-memory2091            // publication.  If lock() won first, this returns null and no stale2092            // settings reach storage or the public StateFlow.2093            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2094        } catch (_: Exception) {2095            false2096        }2097        if (!committed) {2098            // A live-session storage failure rolls back the public value.  A2099            // stale epoch is already reset by lock()/the new session and must2100            // not be allowed to overwrite that safe state.2101            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2102            throw IllegalStateException("settings_write_failed")2103        }2104    }21052106    // ---- Recovery words (vault metadata) ----------------------------------21072108    /** True once the user has confirmed their recovery words for this vault. */2109    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }21102111    /**2112     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2113     * Persisted without bumping the version — recovery metadata is local-only and2114     * is not an entry mutation paired devices should sync against.2115     */2116    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2117        val epoch = requireUnlocked()2118        // H-03: hash + escrow-related state fields set under stateLock so a2119        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2120        // M-04: the flags have to be live before the escrows are built — both seal2121        // currentPayload() — so capture the prior pair and put it back if the write2122        // never lands. Otherwise the session reports words set against a slot A2123        // that still holds no escrow, and the recovery screen never offers a retry.2124        val prior = mutateCurrentSession(epoch) {2125            val priorPair = recoveryWordHash to recoveryWordsSet2126            recoveryWordHash = recoveryWordsHash(words)2127            recoveryWordsSet = true2128            priorPair2129        } ?: return@withLock false2130        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2131        // (cross-platform recovery of the sync root key) and Android's whole-payload2132        // escrow (offline forgot-password restore of entries). Both derive their key2133        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2134        val persisted = runCatching {2135            persistSlotAWithEscrows(2136                buildRecoveryEscrow(words),2137                buildRootKeyEscrow(words),2138                expectedEpoch = epoch,2139            )2140        }.getOrDefault(false)2141        if (!persisted) {2142            mutateIfCurrentEpoch(epoch) {2143                recoveryWordHash = prior.first2144                recoveryWordsSet = prior.second2145            }2146        }2147        persisted2148    }21492150    /** Verify entered words against the stored recovery hash. */2151    fun verifyRecoveryWords(words: List<String>): Boolean {2152        return mutateState {2153            val stored = recoveryWordHash ?: return@mutateState false2154            stored == recoveryWordsHash(words)2155        }2156    }21572158    // ---- Recovery escrow (forgot-password) --------------------------------21592160    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2161    fun hasRecoveryEscrow(): Boolean =2162        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21632164    /**2165     * One-time self-heal: if this vault has a root key but no recovery escrow2166     * on disk (adopted vaults created before the escrow fix), build and persist2167     * it now. Called on every successful unlock; no-ops once the escrow exists.2168     */2169    private fun ensureRecoveryEscrow() {2170        if (hasRecoveryEscrow()) return2171        val rk = mutateState { rootKey?.copyOf() } ?: return2172        runCatching {2173            val words = rootKeyToWords(rk)2174            val escrow = buildRecoveryEscrow(words)2175            val rkEscrow = buildRootKeyEscrow(words)2176            if (escrow != null || rkEscrow != null) {2177                val prior = store.loadSlotA() ?: return@runCatching2178                store.writeSlotA(prior.copy(2179                    recoveryEscrow = escrow ?: prior.recoveryEscrow,2180                    rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2181                ))2182                mutateState {2183                    recoveryWordHash = recoveryWordsHash(words)2184                    recoveryWordsSet = true2185                }2186            }2187        }.also { VaultCrypto.zero(rk) }2188    }21892190    /**2191     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2192     * the master password. On success the slot-A plaintext sealed in the escrow is2193     * adopted as the live session (entries, rootKey, settings), but no Argon2id2194     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2195     * new password and finish unlocking. Returns false when there is no escrow, the2196     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2197     *2198     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2199     * detached vault key to return here — slot A is wrapped under the rotating2200     * Argon2id key — so this opens the escrowed payload directly.2201     */2202    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2203        val expectedEpoch = currentEpoch()2204        val slotA = store.loadSlotA() ?: return false2205        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false22062207        // Prefer Android's whole-payload escrow: a full offline restore of entries,2208        // root key, and settings. Present for Android-created vaults.2209        val fullEscrow = slotA.recoveryEscrow2210        if (fullEscrow != null) {2211            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2212            if (material != null) {2213                var keyBytes: ByteArray? = null2214                var plaintext: ByteArray? = null2215                var candidateRoot: ByteArray? = null2216                var stateCommitStarted = false2217                try {2218                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2219                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2220                    val payload = VaultCodec.decode(plaintext)2221                    // Stage every fallible operation before touching live session2222                    // fields.  In particular, a settings/provider/decode failure2223                    // cannot leave entries, flags, or a root key resident while the2224                    // public session remains locked.2225                    val candidateSettings = store.loadSettings()2226                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2227                        val decodedRoot = unb64(payload.rootKeyB64)2228                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2229                            // Do not let a malformed authenticated payload abandon2230                            // this decoded root-key candidate before the outer2231                            // finally block owns it.2232                            VaultCrypto.zero(decodedRoot)2233                            throw IllegalArgumentException("invalid recovery root key")2234                        }2235                        decodedRoot2236                    } else {2237                        generateRecoverableRootKey()2238                    }2239                    stateCommitStarted = true2240                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2241                        clearSessionStateLocked()2242                        entryList = payload.entries.toMutableList()2243                        duressConfigured = payload.duressConfigured2244                        recoveryWordHash = payload.recoveryWordHash2245                        recoveryWordsSet = payload.recoveryWordsSet2246                        rootKey = candidateRoot2247                        candidateRoot = null2248                        _settings.value = candidateSettings2249                        publish()2250                        // This is the authenticated recovery half-unlock boundary:2251                        // only set it after every candidate field is installed.2252                        sessionDataAvailable = true2253                        _isUnlocked.value = false2254                        true2255                    }2256                    if (adopted == true) return@withLock true2257                } catch (_: Exception) {2258                    // Wrong words, malformed payloads, provider failures, and2259                    // settings failures all fall through to the browser-compatible2260                    // root-key escrow.  If the state commit had started, invalidate2261                    // that epoch so no partial candidate survives.2262                    if (stateCommitStarted) {2263                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2264                    }2265                } finally {2266                    plaintext?.let(VaultCrypto::zero)2267                    VaultCrypto.zero(material)2268                    keyBytes?.let(VaultCrypto::zero)2269                    candidateRoot?.let(VaultCrypto::zero)2270                }2271            }2272        }22732274        // Browser-format vault (only the root-key escrow): recover the 32-byte root2275        // key (sync identity) and reset the password; entries return via sync. This2276        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2277        val rootEscrow = slotA.rootKeyEscrow2278        if (rootEscrow != null) {2279            var candidateRoot: ByteArray? = null2280            var stateCommitStarted = false2281            try {2282                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2283                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2284                val candidateSettings = store.loadSettings()2285                stateCommitStarted = true2286                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2287                    clearSessionStateLocked()2288                    entryList = mutableListOf()2289                    duressConfigured = false2290                    recoveryWordHash = recoveryWordsHash(words)2291                    recoveryWordsSet = true2292                    rootKey = candidateRoot2293                    candidateRoot = null2294                    _settings.value = candidateSettings2295                    publish()2296                    sessionDataAvailable = true2297                    _isUnlocked.value = false2298                    true2299                }2300                if (adopted == true) return@withLock true2301            } catch (_: Exception) {2302                if (stateCommitStarted) {2303                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2304                }2305            } finally {2306                candidateRoot?.let(VaultCrypto::zero)2307            }2308        }2309        return false2310    }23112312    /**2313     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2314     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2315     * words, mark the session fully unlocked, and publish. Returns false if called2316     * without a prior successful recovery unlock (no in-memory root key).2317     *2318     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2319     * into one step, since there is no detached vault key here.2320     */2321    suspend fun resetPasswordWithRecoveryWords(2322        words: List<String>,2323        newPassword: String,2324    ): Boolean = mutex.withLock {2325        val expectedEpoch = currentEpoch()2326        // H-03: read rootKey + entryList under stateLock before computing the new2327        // plaintext, then re-publish + swap the derived key under stateLock so a2328        // concurrent lock() cannot straddle the two fences.2329        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2330        val priorA = store.loadSlotA() ?: return false2331        var newKey: ByteArray? = null2332        var plaintext: ByteArray? = null2333        return try {2334            val newSalt = VaultCrypto.generateSalt()2335            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2336            plaintext = VaultCodec.encode(currentPayload())2337            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2338            // Rebuild both escrows against the recovered session so their salts rotate2339            // with the password reset; fall back to the prior escrows if rebuild fails2340            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2341            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2342            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2343            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2344            val next = StoredVault(2345                version = priorA.version,2346                salt = b64(newSalt),2347                blob = blob,2348                recoveryEscrow = freshRecovery,2349                rootKeyEscrow = freshRootKey,2350            )2351            // H-01: see changePassword — both rotations share the same2352            // commit-or-keep-old-key contract so a partial write can't strand2353            // the session on a key that no longer matches disk.2354            val priorB = store.loadSlotB()2355            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2356            // H-03 (P1-2 codex): zero the old live key and install the new one2357            // inside the same stateLock critical section so a concurrent lock()2358            // can't observe the in-between.2359            if (mutateIfCurrentEpoch(expectedEpoch) {2360                derivedKey?.let(VaultCrypto::zero)2361                derivedKey = newKey; newKey = null2362                publish()2363                _isUnlocked.value = true2364            } == null) return false2365            true2366        } catch (_: Exception) {2367            false2368        } finally {2369            newKey?.let(VaultCrypto::zero)2370            plaintext?.let(VaultCrypto::zero)2371        }2372    }23732374    /**2375     * Seal the current slot-A plaintext under a key derived from the 12 BIP392376     * recovery [words] (Android offline-restore escrow). IKM is the browser's2377     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2378     * Returns null for a checksum-invalid phrase.2379     */2380    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2381        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2382        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2383        var keyBytes: ByteArray? = null2384        var plaintext: ByteArray? = null2385        return try {2386            val salt = VaultCrypto.generateSalt()2387            val recoveryKey = deriveRecoveryKey(material, salt)2388            keyBytes = recoveryKey.encoded2389            plaintext = VaultCodec.encode(currentPayload())2390            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2391            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2392        } finally {2393            VaultCrypto.zero(material)2394            keyBytes?.let(VaultCrypto::zero)2395            plaintext?.let(VaultCrypto::zero)2396        }2397    }23982399    /**2400     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2401     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2402     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2403     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2404     * the same root key on the browser. Returns null with no in-memory root key or2405     * a checksum-invalid phrase.2406     */2407    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2408        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2409        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2410        val rk = mutateState { rootKey?.copyOf() } ?: run {2411            VaultCrypto.zero(material)2412            return null2413        }2414        var keyBytes: ByteArray? = null2415        var plaintext: ByteArray? = null2416        return try {2417            val salt = VaultCrypto.generateSalt()2418            val recoveryKey = deriveRecoveryKey(material, salt)2419            keyBytes = recoveryKey.encoded2420            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2421            // combined = nonce(12) || ciphertext || tag(16)2422            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2423            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2424            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2425            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2426            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2427            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2428        } finally {2429            VaultCrypto.zero(rk)2430            VaultCrypto.zero(material)2431            keyBytes?.let(VaultCrypto::zero)2432            plaintext?.let(VaultCrypto::zero)2433        }2434    }24352436    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2437    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2438        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2439        var keyBytes: ByteArray? = null2440        var pt: ByteArray? = null2441        return try {2442            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2443            keyBytes = recoveryKey.encoded2444            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2445            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2446            hexToRootKey(String(pt, Charsets.US_ASCII))2447        } catch (_: Exception) {2448            null2449        } finally {2450            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2451            // IKM-equivalent of the vault identity. material+keyBytes were2452            // already zeroed; pt was leaking.2453            VaultCrypto.zero(material)2454            keyBytes?.let(VaultCrypto::zero)2455            pt?.let(VaultCrypto::zero)2456        }2457    }24582459    /**2460     * Re-seal slot A from the current in-memory state under the live key, carrying2461     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2462     * in decoy mode (recovery is never offered for the decoy) and persists without2463     * a version bump, like the recovery-words metadata write it replaces.2464     */2465    private fun persistSlotAWithEscrows(2466        recoveryEscrow: RecoveryEscrow?,2467        rootKeyEscrow: RootKeyEscrow?,2468        expectedEpoch: Long? = null,2469    ): Boolean {2470        val snapshot = snapshotForPersist(expectedEpoch)2471            ?: throw IllegalStateException("vault_locked_or_stale")2472        if (snapshot.decoy) {2473            VaultCrypto.zero(snapshot.key)2474            return false2475        }2476        try {2477            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2478            val plaintext = VaultCodec.encode(snapshot.payload)2479            val blob = try {2480                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2481            } finally {2482                // The escrow path used to drop this reference before zeroing it;2483                // keep it live through the fill on both success and failure.2484                VaultCrypto.zero(plaintext)2485            }2486            val next = prior.copy(2487                blob = blob,2488                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2489                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2490            )2491            beforeDurableCommit()2492            return withEpochCommit(snapshot.epoch) {2493                if (!store.writeSlotA(next)) return@withEpochCommit false2494                try {2495                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2496                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2497                    // the duress slot too. A real decoy is left alone; a fake is2498                    // re-sized to slot A's new ciphertext length.2499                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2500                } catch (e: Exception) {2501                    store.writeSlotA(prior)2502                    throw e2503                }2504                true2505            } ?: false2506        } finally {2507            VaultCrypto.zero(snapshot.key)2508        }2509    }25102511    // ---- Internals --------------------------------------------------------25122513    private fun requireUnlocked(): Long {2514        // H-03: read derivedKey atomically with [lock]'s reset; without it,2515        // [lock] from a background lifecycle event can run between this read2516        // and the caller's first state touch.2517        stateLock.lock()2518        try {2519            if (derivedKey == null) throw IllegalStateException("vault_locked")2520            return sessionEpoch2521        } finally {2522            stateLock.unlock()2523        }2524    }25252526    /** A locked or half-adopted session must not expose secret-bearing fields. */2527    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25282529    private fun countDuplicateEntriesLocked(): Int {2530        val seen = mutableMapOf<String, VaultEntry>()2531        var duplicates = 02532        for (entry in entryList.filter { it.deletedAt == null }) {2533            val key = entry.duplicateKey()2534            val existing = seen[key]2535            if (existing == null) {2536                seen[key] = entry2537            } else {2538                duplicates++2539                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2540                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2541                if (entryStamp > existingStamp) seen[key] = entry2542            }2543        }2544        return duplicates2545    }25462547    private fun VaultEntry.duplicateKey(): String {2548        if (type == "note") return "note|${title.lowercase()}"2549        val passkeyData = passkey2550        if (passkeyData != null) {2551            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2552        }2553        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2554    }25552556    /**2557     * Capture the current slot pair (and metadata) into the history list.2558     * Returns true when the history write landed, false when it didn't — callers2559     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2560     * must check the result and roll back if it does not.2561     */2562    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2563        val epoch = expectedEpoch ?: currentEpoch()2564        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2565        val slotA = store.loadSlotA() ?: return false2566        val vaultA = VaultCodec.encodeSlot(slotA)2567        val vaultB = store.loadSlotB() ?: slotA.blob2568        // H-04: capture the duress slot so a restore preserves the at-rest2569        // triad, not just the A/B pair. The duress slot is always present2570        // (real or fake), so loadDecoySlot() returning null is a logic error2571        // — fall through to a fake duress in that case rather than silently2572        // losing the record from the snapshot.2573        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2574            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162575            val fakeBlob = ByteArray(target).also(rng::nextBytes)2576            val fakeSalt = VaultCrypto.generateSalt()2577            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2578        }2579        val history = store.loadVaultHistory()2580        val latest = history.snapshots.firstOrNull()2581        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2582        val entryCount = mutateIfCurrentEpoch(epoch) {2583            if (derivedKey != null && !decoyMode) {2584                bucketEntryCount(entryList.count { it.deletedAt == null })2585            } else {2586                null2587            }2588        } ?: return false2589        return try {2590            val snapshot = VaultSnapshot(2591                id = UUID.randomUUID().toString(),2592                savedAt = System.currentTimeMillis(),2593                reason = reason,2594                vault_a = vaultA,2595                vault_b = vaultB,2596                vault_d = vaultD,2597                entryCount = entryCount,2598            )2599            // P1-4 (codex): return the history-write boolean to callers so a2600            // false return no longer leaves the snapshot out of history while2601            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2602            withEpochCommit(epoch) {2603                store.writeVaultHistory(2604                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2605                )2606            } ?: false2607        } catch (_: Exception) {2608            false2609        }2610    }26112612    private fun bucketEntryCount(n: Int): Int = when {2613        n <= 0 -> 02614        n <= 10 -> 102615        n <= 25 -> 252616        n <= 50 -> 502617        n <= 100 -> 1002618        n <= 250 -> 2502619        n <= 500 -> 5002620        else -> ((n + 499) / 500) * 5002621    }26222623    /**2624     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2625     * so it is stored explicitly and survives the sync wire format rather than being2626     * re-derived at render time. Non-card entries pass through unchanged.2627     *2628     * M-03: never overwrite a stored last4 with null. A card kept only as last42629     * (e.g. after a PeachScript import that had no PAN) must keep that value;2630     * re-deriving from an absent cardNumber would silently zero the field.2631     */2632    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2633        if (!entry.isCard()) return entry2634        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2635        val derived = if (digits.length >= 4) digits.takeLast(4) else null2636        return entry.copy(last4 = derived ?: entry.last4)2637    }26382639    /**2640     * On unlock, hard-delete trashed entries older than the retention window.2641     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2642     * (the app's "Never" option), so nothing is purged. Persists without a2643     * version bump and without touching the publish state (caller publishes).2644     */2645    private fun autoPurgeExpiredTrash() {2646        val days = _settings.value.trashRetentionDays ?: return2647        if (days <= 0) return2648        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2649        val before = entryList.size2650        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2651        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2652    }26532654    /**2655     * Browser parity (P1): merge orphaned passkey-only entries into matching2656     * login entries that share the same registrable domain. A passkey-only2657     * entry has passkey fields (rpId + credentialId) but no password. Runs on2658     * unlock so consolidation is idempotent and cheap.2659     */2660    private fun consolidatePasskeyEntries() {2661        val passkeyOnly = entryList.filter { entry ->2662            entry.deletedAt == null &&2663                entry.isLogin() &&2664                entry.password.isNullOrBlank() &&2665                !entry.rpId.isNullOrBlank() &&2666                !entry.credentialId.isNullOrBlank()2667        }2668        if (passkeyOnly.isEmpty()) return2669        var changed = false2670        val now = System.currentTimeMillis()2671        for (pkEntry in passkeyOnly) {2672            val rpId = pkEntry.rpId ?: continue2673            val target = entryList.firstOrNull { candidate ->2674                candidate.deletedAt == null &&2675                    candidate.id != pkEntry.id &&2676                    candidate.isLogin() &&2677                    candidate.passkey == null &&2678                    candidate.credentialId.isNullOrBlank() &&2679                    !candidate.password.isNullOrBlank() &&2680                    listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2681                        .any { com.peachpasswords.shared.extractDomain(it) == rpId }2682            } ?: continue2683            val idx = entryList.indexOfFirst { it.id == target.id }2684            if (idx < 0) continue2685            entryList[idx] = target.copy(2686                rpId = pkEntry.rpId,2687                rpName = pkEntry.rpName ?: target.rpName,2688                credentialId = pkEntry.credentialId,2689                publicKeyCose = pkEntry.publicKeyCose,2690                privateKeyJwk = pkEntry.privateKeyJwk,2691                userHandle = pkEntry.userHandle,2692                userName = pkEntry.userName,2693                userDisplayName = pkEntry.userDisplayName,2694                algorithm = pkEntry.algorithm,2695                signCount = pkEntry.signCount,2696                lastUsedAt = pkEntry.lastUsedAt,2697                modifiedAt = now,2698            )2699            // Soft-delete the orphan so it lands in trash (recoverable).2700            val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2701            if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2702            changed = true2703        }2704        if (changed) runCatching { persistSlotA(bumpVersion = false) }2705    }27062707    /** The slot-A plaintext envelope for the current in-memory session. */2708    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }27092710    private fun currentPayloadLocked(): VaultPayload =2711        VaultPayload(2712            entries = entryList.toList(),2713            duressConfigured = duressConfigured,2714            rootKeyB64 = rootKey?.let(::b64) ?: "",2715            recoveryWordHash = recoveryWordHash,2716            recoveryWordsSet = recoveryWordsSet,2717        )27182719    /**2720     * Adopt the payload's root key, lazily minting one for legacy payloads.2721     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2722     * a root key is not a mutation paired devices should sync against.2723     */2724    private fun ensureRootKey(payload: VaultPayload) {2725        if (payload.rootKeyB64.isNotBlank()) {2726            rootKey = unb64(payload.rootKeyB64)2727            return2728        }2729        rootKey = generateRecoverableRootKey()2730        runCatching { persistSlotA(bumpVersion = false) }2731    }27322733    private fun publish() {2734        _entries.value = entryList.filter { it.deletedAt == null }2735    }27362737    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2738        var plaintext = VaultCodec.encode(currentPayload())2739        return try {2740            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2741            StoredVault(version = 1, salt = b64(salt), blob = blob)2742        } finally {2743            // M-05: full vault plaintext is allocated here once per createVault.2744            // Zeroing in finally keeps the transient copy off the heap after the2745            // caller either succeeds or rolls back via lock().2746            VaultCrypto.zero(plaintext)2747        }2748    }27492750    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2751        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2752        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2753        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2754        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2755        val noise = ByteArray(approxBytes).also(rng::nextBytes)2756        return store.writeSlotB(b64(noise))2757    }27582759    /** H-04: the ciphertext length slot A is using right now, so the duress2760     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2761     *  length equals ciphertext length, so this also matches slot A's plaintext. */2762    private fun matchingCiphertextLength(slotA: StoredVault): Int =2763        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27642765    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2766     *  bytes. Replaces a real decoy when the user clears duress, and is the2767     *  initial duress slot when no decoy is configured — so the record's2768     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2769     *  matches slot A's blob length. */2770    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2771        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162772        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2773        val fakeSalt = VaultCrypto.generateSalt()2774        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2775    }27762777    /** H-04: keep the duress record presence- and size-indistinguishable from2778     *  slot A on every persist. A real decoy is left alone (it was padded at2779     *  setDuressPassphrase time, and we don't have the duress key here to2780     *  re-pad on entry growth — that's an accepted residual tell, see the2781     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2782     *  A's size. */2783    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2784        val isConfigured = mutateState { duressConfigured }2785        return if (isConfigured) true else writeFakeDuressSlot(slotA)2786    }27872788    /**2789     * H-04: convert a browser-shaped duress slot to Android StoredVault2790     * without decrypting. The browser seals slot "b" under the duress2791     * passphrase; decrypting with the main password silently drops it.2792     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2793     * the salt from hex to base64.2794     */2795    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2796        val salt = unhex(b.salt)2797        val nonce = unhex(b.nonce)2798        val ct = unhex(b.ciphertext)2799        val tag = unhex(b.tag)2800        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2801            System.arraycopy(nonce, 0, it, 0, nonce.size)2802            System.arraycopy(ct, 0, it, nonce.size, ct.size)2803            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2804        }2805        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2806    }28072808    /** Re-seal slot A from the current in-memory state under the live key. */2809    private fun persistSlotA(2810        bumpVersion: Boolean,2811        snapshotReason: String? = "local_save",2812        expectedEpoch: Long? = null,2813    ): Boolean {2814        val snapshot = snapshotForPersist(expectedEpoch)2815            ?: throw IllegalStateException("vault_locked_or_stale")2816        return try {2817            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2818            if (snapshot.decoy) {2819                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2820                val plaintext = VaultCodec.encode(snapshot.payload)2821                val blob = try {2822                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2823                } finally {2824                    // Keep the last reference alive until the zero, including the2825                    // successful encrypt path (M-05).2826                    VaultCrypto.zero(plaintext)2827                }2828                beforeDurableCommit()2829                return withEpochCommit(snapshot.epoch) {2830                    store.writeDecoySlot(prior.copy(blob = blob))2831                } ?: false2832            }2833            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2834            // H-01: captured BEFORE the noise write so a stamp failure can restore2835            // the prior slot B alongside the prior slot A. Slot B is always2836            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2837            // leaves (new A, new B, old version) on disk — partial commit.2838            val priorB = store.loadSlotB()2839            beforeDurableCommit()2840            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2841            val plaintext = VaultCodec.encode(snapshot.payload)2842            val blob = try {2843                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2844            } finally {2845                // Do not drop [plaintext] before fill(0): the serialized vault2846                // contains every entry password and is the primary M-05 target.2847                VaultCrypto.zero(plaintext)2848            }2849            val next = prior.copy(blob = blob)2850            // The epoch check and the entire A/B/D/version commit share one brief2851            // stateLock critical section. lock() either wins before this block or2852            // waits until the already-committed write is complete; it can never2853            // clear the session between the check and the durable write.2854            withEpochCommit(snapshot.epoch) {2855                if (!store.writeSlotA(next)) return@withEpochCommit false2856                try {2857                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2858                    // H-04: duress slot stays present (real one untouched, fake one2859                    // re-padded to slot A's current size on every persist).2860                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2861                    if (bumpVersion) store.stampLocalMutation()2862                } catch (e: Exception) {2863                    store.writeSlotA(prior)2864                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2865                    throw e2866                }2867                true2868            } ?: false2869        } finally {2870            // Snapshot keys are copies, so lock() may zero the live key while this2871            // operation is still deriving/encrypting; always scrub our copy too.2872            VaultCrypto.zero(snapshot.key)2873        }2874    }28752876    /**2877     * H-01: durably commit a slot-A rotation by writing slot A, its2878     * size-matched noise slot B, and the local-mutation stamp in order. Any2879     * failure rolls the previously-committed steps back so the at-rest pair2880     * matches what was there before the call. Returns true only when every2881     * step succeeded — callers must NOT swap their session key on false.2882     */2883    private fun commitVaultRotation(2884        priorA: StoredVault,2885        priorB: String?,2886        nextA: StoredVault,2887        expectedEpoch: Long,2888    ): Boolean {2889        beforeDurableCommit()2890        return withEpochCommit(expectedEpoch) {2891            if (!store.writeSlotA(nextA)) return@withEpochCommit false2892            // P1-1 (codex): the noise write's boolean AND any thrown exception from2893            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2894            // escaped the helper and left slot A committed.2895            try {2896                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2897                // H-04: a password rotation changes slot A's plaintext size (new2898                // salt → re-encrypted blob), so the duress slot must be re-padded2899                // alongside. writeCompanionDuressSlot leaves a real decoy alone2900                // and re-emits a fake of slot A's size otherwise.2901                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2902                store.stampLocalMutation()2903            } catch (_: Exception) {2904                rollbackRotation(priorA, priorB)2905                return@withEpochCommit false2906            }2907            true2908        } ?: false2909    }29102911    /**2912     * Best-effort undo of the writes [commitVaultRotation] may have committed.2913     * Each step runs independently — a failed rollback must not mask the2914     * original commit failure. Log is a throwing stub off-device.2915     */2916    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2917        val failures = mutableListOf<String>()2918        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2919            .onFailure { failures += "writeSlotA: $it" }2920        if (priorB != null) {2921            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2922                .onFailure { failures += "writeSlotB: $it" }2923        }2924        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2925    }29262927    /** Persist a local mutation, rolling the in-memory list back on failure. */2928    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2929        try {2930            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2931                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2932                throw IllegalStateException("storage_write_failed")2933            }2934        } catch (e: Exception) {2935            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2936            throw e2937        }2938        // A lock can win in the small interval after the durable commit and2939        // before publication. The epoch check prevents pre-lock entries from2940        // being republished into a newly locked (or newly unlocked) session.2941        mutateIfCurrentEpoch(expectedEpoch) { publish() }2942        runCatching { LockedSyncSpoolHooks.refresh(this) }2943    }2944}