VaultRepository.kt

131.1 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton2526/** Lenient parser for the Codex vault-blob wrapper only. */27private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)31private fun unhex(s: String): ByteArray {32    require(s.length % 2 == 0) { "odd-length hex" }33    return ByteArray(s.length / 2) { i ->34        val hi = Character.digit(s[i * 2], 16)35        val lo = Character.digit(s[i * 2 + 1], 16)36        require(hi >= 0 && lo >= 0) { "bad hex digit" }37        ((hi shl 4) or lo).toByte()38    }39}4041/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */42private const val PASSWORD_HISTORY_MAX = 5043private const val VAULT_HISTORY_MAX = 54445// How long the transient-background-lock suppression stays valid. If the46// launched system Activity never reports the expected lifecycle stop (picker47// crashed, user backed out into another app, OEM killed the picker process),48// the flag expires and the next genuine background event locks the vault49// rather than consuming a stale suppression.50private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L5152sealed interface UnlockResult {53    data object Success : UnlockResult54    data object WrongPassword : UnlockResult55    data class Error(val reason: String) : UnlockResult56}5758sealed interface CreateVaultResult {59    data object Success : CreateVaultResult60    data class Error(val reason: String) : CreateVaultResult61}6263sealed interface AdoptVaultResult {64    data class Success(val vaultId: String) : AdoptVaultResult65    data class Error(val reason: String) : AdoptVaultResult66}6768/**69 * In-memory vault session backed by [VaultStore]. Faithful port of the browser70 * `vault.ts` semantics that matter for correctness:71 *72 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched73 *    noise blob so a fresh install shows two similarly-shaped records.74 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a75 *    deletion always wins last-write-wins against an in-flight edit.76 *  - Local writes bump the version counter; sync-applied writes do NOT (so a77 *    sync round never cascades into another outbound sync).78 *  - add/update/delete roll back in-memory state if the disk write fails.79 *  - destroyVault never touches the Pro license (separate store).80 */81@Singleton82class VaultRepository @Inject constructor(83    private val store: VaultStorage,84) {85    private val mutex = Mutex()86    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend87    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync88    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so89    // lock() can serialize against an in-flight suspend op without blocking on90    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and91    // take [stateLock] only around the state-write portions. Acquisition order92    // is always mutex → stateLock.93    private val stateLock = ReentrantLock()94    private val rng = SecureRandom()9596    /**97     * Monotonic session generation.  [lock] advances this before clearing any98     * secret state; work that captured the previous generation is therefore99     * stale even if it is still running its (non-suspending) storage step.100     */101    private var sessionEpoch: Long = 0L102103    private fun nextSessionEpoch(epoch: Long): Long =104        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L105106    /** Test seam for deterministic "lock while waiting to commit" races. */107    @VisibleForTesting108    internal var beforeDurableCommit: () -> Unit = {}109110    /** Test seam after a failed Codex rollback, while the epoch fence is held. */111    @VisibleForTesting112    internal var afterCodexRollback: () -> Unit = {}113114    /**115     * Run [block] with [stateLock] held. Used to fence the small state-write116     * portions of suspend operations against [lock] and [requireUnlocked]117     * racing from another thread.118     */119    private inline fun <T> mutateState(block: () -> T): T {120        stateLock.lock()121        return try { block() } finally { stateLock.unlock() }122    }123124    /** Run a durable commit only while the session generation is still live. */125    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {126        stateLock.lock()127        return try {128            if (sessionEpoch != epoch) null else block()129        } finally {130            stateLock.unlock()131        }132    }133134    /** Test seam for a caller that must stage work before the epoch fence. */135    @VisibleForTesting136    internal var beforeSessionEpochCommit: () -> Unit = {}137138    /**139     * Test seam for the narrower H-03 race: a suspend mutation has authenticated140     * the session and staged its candidate state, but has not yet entered the141     * epoch-fenced in-memory write. Production callers leave this as a no-op.142     */143    @VisibleForTesting144    internal var beforeSessionStateMutation: () -> Unit = {}145146    /**147     * Run an external session-bound commit while the repository's epoch fence is148     * held. The callback is deliberately non-suspending: [lock] cannot advance149     * the epoch until the whole external batch has reached its linearization150     * point.151     */152    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {153        beforeSessionEpochCommit()154        return withEpochCommit(epoch, block)155    }156157    private fun currentEpoch(): Long = mutateState { sessionEpoch }158159    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =160        mutateState { if (sessionEpoch == epoch) block() else null }161162    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */163    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {164        beforeSessionStateMutation()165        return mutateIfCurrentEpoch(epoch, block)166    }167168    private data class PersistSnapshot(169        val epoch: Long,170        val key: ByteArray,171        val payload: VaultPayload,172        val decoy: Boolean,173    )174175    /** Copy all session material while fenced; callers own and must zero [key]. */176    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {177        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null178        val liveKey = derivedKey ?: return@mutateState null179        PersistSnapshot(180            epoch = sessionEpoch,181            key = liveKey.copyOf(),182            payload = currentPayloadLocked(),183            decoy = decoyMode,184        )185    }186187    private var derivedKey: ByteArray? = null188    private var rootKey: ByteArray? = null189    private var entryList: MutableList<VaultEntry> = mutableListOf()190    private var duressConfigured: Boolean = false191    private var recoveryWordHash: String? = null192    private var recoveryWordsSet: Boolean = false193    /** True when an authenticated vault payload is available for local reads. */194    private var sessionDataAvailable: Boolean = false195    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */196    private var decoyMode: Boolean = false197198    private val _isUnlocked = MutableStateFlow(false)199    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()200201    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())202    /** Active (non-deleted) entryList. */203    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()204205    private val _settings = MutableStateFlow(VaultSettings())206    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()207208    /** Clear every in-memory session field while [stateLock] is held. */209    private fun clearSessionStateLocked() {210        derivedKey?.let(VaultCrypto::zero)211        rootKey?.let(VaultCrypto::zero)212        derivedKey = null213        rootKey = null214        entryList = mutableListOf()215        duressConfigured = false216        recoveryWordHash = null217        recoveryWordsSet = false218        sessionDataAvailable = false219        decoyMode = false220        _settings.value = VaultSettings()221        _entries.value = emptyList()222        _isUnlocked.value = false223    }224225    fun hasVault(): Boolean = store.hasVault()226227    // ---- Codex (printed paper backup) -------------------------------------228229    /**230     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the231     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's232     * A/B naming where slot B is the duress decoy); the noise slot is local-only233     * and never crosses devices. Restorable on any device that has the master234     * password. Null when no vault exists.235     */236    fun codexVaultBlobBase64(): String? {237        val slotA = store.loadSlotA() ?: return null238        // H-04: duress slot is always present after createVault (real or fake),239        // so the wrapper can rely on it being loadable.240        val slotD = store.loadDecoySlot() ?: return null241        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""242        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))243    }244245    /**246     * Replace the local vault from a Codex blob, then unlock with [password].247     * Writes slot A (and a size-matched noise slot B) before unlocking so the248     * restored vault is at rest exactly as a freshly-created one would be.249     */250    /** A slot is stored either as a JSON string (browser) or inline object (Android). */251    private fun JsonElement?.asSlotJson(): String? = when {252        this == null || this is JsonNull -> null253        this is JsonPrimitive && isString -> content254        else -> toString()255    }256257    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {258        val expectedEpoch = currentEpoch()259        var candidateKey: ByteArray? = null260        var plaintext: ByteArray? = null261        return try {262            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)263            // Parsed as JSON, not matched with a regex. The wrapper is264            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which265            // generates most real Codexes — writes each slot as a JSON266            // *string*, with "b" present. A regex looking for an object267            // literal under "a" at the very end of the wrapper matches268            // neither of those, so every browser-generated Codex failed here269            // with "invalid_codex_blob" after decoding perfectly.270            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject271                ?: return UnlockResult.Error("invalid_codex_blob")272            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")273            // A Codex printed from the browser carries a browser-shaped slot,274            // which is the common case — most Codexes are printed there. It is275            // translated to Android's shape (see BrowserSlot) rather than276            // rejected. A translation failure is a wrong password, not a bad277            // blob: the codes decoded fine, the password just didn't open them.278            val slotA = VaultCodec.decodeSlot(slotJson)279                ?: BrowserSlot.parse(slotJson)?.let {280                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword281                }282                ?: return UnlockResult.Error("invalid_codex_blob")283            // C-01: authenticate the candidate ENTIRELY in memory before touching284            // storage. VaultCodec.decodeSlot is structural — an Android-shaped285            // Codex parses under ANY password — so writing slot A first and286            // unlocking afterwards destroyed the device's existing vault every287            // time the user mistyped the password.288            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))289            plaintext = decryptSlotABlob(slotA, candidateKey)290            // LV-1: a Codex printed from a vault sealed before NFC normalization291            // opens only under the raw password bytes. unlockLocked used to supply292            // this fallback; authenticating up front here has to keep it, or those293            // Codexes become unrestorable.294            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {295                VaultCrypto.zero(candidateKey)296                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))297                plaintext = decryptSlotABlob(slotA, candidateKey)298            }299            if (plaintext == null) return UnlockResult.WrongPassword300            val payload = try {301                VaultCodec.decode(plaintext)302            } catch (_: Exception) {303                return UnlockResult.Error("invalid_codex_blob")304            }305306            val priorA = store.loadSlotA()307            val priorB = store.loadSlotB()308            val priorD = store.loadDecoySlot()309            // Carry the real duress slot across when the Codex has one — the310            // browser writes "b" as its duress-shaped slot, and dropping it311            // here would silently destroy a configured decoy.312            // H-04: write browser B into the duress slot (not the noise slot),313            // translating browser-shaped slots to Android's StoredVault shape.314            val codexDuressJson = wrapper["b"].asSlotJson()315            val codexDuress: StoredVault? = codexDuressJson?.let {316                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->317                    // H-04: pass-through — the browser duress slot is sealed318                    // under the duress passphrase, not the main password. Trying319                    // to decrypt with the slot-A password fails the GCM tag and320                    // silently drops a real decoy. Re-encode the browser hex321                    // fields as Android base64 salt/blob so tryUnlockDuress can322                    // still open it with the duress passphrase.323                    browserDuressPassthrough(b)324                }325            }326327            beforeDurableCommit()328            val committed = runCatching {329                withEpochCommit(expectedEpoch) {330                    try {331                        // Keep every slot write, including the inverse, under the332                        // same epoch fence. Releasing stateLock after a partial333                        // write and reacquiring it for rollback lets lock() advance334                        // the epoch in between and strand the incoming slot A.335                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")336                        // Noise slot is always size-matched to slot A — local-only,337                        // never written from a Codex.338                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")339                        // H-04: duress slot always present; real one from the Codex or340                        // a fake the size of slot A.341                        val duressOk = if (codexDuress != null) {342                            store.writeDecoySlot(codexDuress)343                        } else {344                            writeFakeDuressSlot(slotA)345                        }346                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")347                        store.stampLocalMutation()348                        true349                    } catch (_: Exception) {350                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))351                        // Keep the fence held through the final rollback352                        // observation. A concurrent lock() may queue here, but353                        // cannot invalidate the restored A/B/D triad.354                        afterCodexRollback()355                        false356                    }357                } ?: false358            }.getOrDefault(false)359            if (!committed) {360                return UnlockResult.Error("storage_write_failed")361            }362363            val ownedKey = candidateKey!!364            candidateKey = null // the session owns the key from here on365            adoptSlotA(ownedKey, payload, expectedEpoch)366        } catch (e: Exception) {367            // A raw kotlinx parse error ("Unexpected JSON token at offset N")368            // reaching the user says nothing about WHICH json failed — the369            // wrapper, or the payload decrypted out of it. Codex screens are370            // FLAG_SECURE so this can't be screenshotted either. Debug builds371            // log the structure around the failure; alphanumerics are masked so372            // no ciphertext or vault content lands in a log.373            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))374            UnlockResult.Error(e.message ?: "restore_failed")375        } finally {376            plaintext?.let(VaultCrypto::zero)377            candidateKey?.let(VaultCrypto::zero)378        }379    }380381    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */382    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =383        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()384385    /**386     * Undo a partially-committed Codex restore while [stateLock] is already held.387     * Every step is attempted independently so one failure cannot strand the388     * others. Callers that need epoch validation must enter through389     * [withEpochCommit] first; this helper deliberately does not reacquire the390     * lock, avoiding a reentrant gap between a failed write and its inverse.391     * With no prior vault the accurate inverse is having none at all, rather392     * than a half-written slot A that [hasVault] would report as a real vault.393     */394    private fun rollbackRestoreLocked(395        priorA: StoredVault?,396        priorB: String?,397        priorD: StoredVault? = null,398    ): List<String> {399        val failures = mutableListOf<String>()400        if (priorA == null) {401            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }402        } else {403            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }404                .onFailure { failures += "writeSlotA: $it" }405            if (priorB != null) {406                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }407                    .onFailure { failures += "writeSlotB: $it" }408            }409            // H-04: restore the prior duress slot — a half-committed restore that410            // left the duress record on the Codex side and slot A on the prior411            // side would put the at-rest pair in a state that never existed.412            if (priorD != null) {413                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }414                    .onFailure { failures += "writeDecoySlot: $it" }415            } else {416                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }417            }418        }419        return failures420    }421422    private fun reportRollbackFailures(failures: List<String>) {423        // Log is a throwing stub off-device, and a failed log must never mask424        // the rollback result the caller is about to report.425        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }426    }427428    /** Debug-only: structure of a failed Codex restore, with content masked. */429    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {430        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)431        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()432        val window = offset?.let {433            val from = (it - 40).coerceAtLeast(0)434            val to = (it + 40).coerceAtMost(wrapperJson.length)435            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")436        }437        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()438        val slot = wrapper?.get("a")439        buildString {440            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)441            append(" | blobBase64=").append(vaultBlobBase64.length)442            append(" wrapperChars=").append(wrapperJson.length)443            append(" wrapperParses=").append(wrapper != null)444            append(" keys=").append(wrapper?.keys)445            append(" slotAKind=").append(446                when {447                    slot == null -> "absent"448                    slot is JsonPrimitive && slot.isString -> "string"449                    slot is JsonObject -> "object:" + slot.keys450                    else -> slot::class.simpleName451                },452            )453            if (window != null) append(" masked@").append(offset).append("=").append(window)454        }455    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }456457    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {458        val expectedEpoch = currentEpoch()459        var key: ByteArray? = null460        return try {461            val salt = VaultCrypto.generateSalt()462            key = VaultCrypto.deriveKey(password, salt)463            if (mutateIfCurrentEpoch(expectedEpoch) {464                entryList = mutableListOf()465                duressConfigured = false466                recoveryWordHash = null467                recoveryWordsSet = false468                decoyMode = false469                sessionDataAvailable = false470                rootKey = generateRecoverableRootKey()471            } == null) return CreateVaultResult.Error("stale_create")472            // M-04: the fields above have to be live before sealSlotA, since it473            // seals currentPayload(). That stages a root key the session would474            // otherwise keep after a failed create — [lock] discards the whole475            // staged session on every non-success exit below.476            val slotA = sealSlotA(key!!, salt)477            // P1-3 (codex): every later step must roll the slot pair back if it478            // fails — leaving a half-built vault is worse than refusing to make it.479            beforeDurableCommit()480            val created = withEpochCommit(expectedEpoch) {481                if (!store.writeSlotA(slotA)) return@withEpochCommit false482                try {483                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")484                    // H-04: duress slot must be present from the start so its485                    // existence on disk is independent of duressConfigured.486                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")487                    store.stampLocalMutation()488                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")489                    true490                } catch (_: Exception) {491                    // Half-built vault on disk is worse than no vault — refuse to492                    // create if any step past slot A fails.493                    store.clearVault()494                    false495                }496            } ?: false497            if (!created) {498                lock()499                return CreateVaultResult.Error("storage_write_failed")500            }501            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")502            if (mutateIfCurrentEpoch(expectedEpoch) {503                derivedKey = sessionKey504                key = null505                publish()506                _isUnlocked.value = true507                sessionDataAvailable = true508                sessionEpoch = nextSessionEpoch(sessionEpoch)509            } == null) return CreateVaultResult.Error("stale_create")510            CreateVaultResult.Success511        } catch (e: Exception) {512            lock()513            CreateVaultResult.Error(e.message ?: "create_failed")514        } finally {515            key?.let(VaultCrypto::zero)516        }517    }518519    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {520        val expectedEpoch = currentEpoch()521        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")522        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")523        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {524            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")525        }526        var adoptedRootKey: ByteArray? = null527        var nextDerivedKey: ByteArray? = null528        var sessionRootKey: ByteArray? = null529        return try {530            adoptedRootKey = hexToRootKey(rootKeyHex)531            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {532                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")533            }534            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)535            val salt = VaultCrypto.generateSalt()536            nextDerivedKey = VaultCrypto.deriveKey(password, salt)537            sessionRootKey = adoptedRootKey.copyOf()538            val payload = VaultPayload(539                entries = emptyList(),540                duressConfigured = false,541                rootKeyB64 = b64(sessionRootKey),542                recoveryWordHash = null,543                recoveryWordsSet = false,544            )545            val plaintext = VaultCodec.encode(payload)546            val blob = try {547                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))548            } finally {549                VaultCrypto.zero(plaintext)550            }551            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)552            // P1-3 (codex): same partial-commit guard as createVault — any failure553            // past slot A wipes the half-built vault and refuses adoption.554            beforeDurableCommit()555            val created = withEpochCommit(expectedEpoch) {556                if (!store.writeSlotA(slotA)) return@withEpochCommit false557                try {558                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")559                    // H-04: duress slot must be present from the start so its560                    // existence on disk is independent of duressConfigured.561                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")562                    store.stampLocalMutation()563                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")564                    true565                } catch (_: Exception) {566                    store.clearVault()567                    false568                }569            } ?: false570            if (!created) return AdoptVaultResult.Error("storage_write_failed")571572            val adopted = mutateIfCurrentEpoch(expectedEpoch) {573                val previousDerivedKey = derivedKey574                val previousRootKey = rootKey575                derivedKey = nextDerivedKey576                rootKey = sessionRootKey577                nextDerivedKey = null578                sessionRootKey = null579                entryList = mutableListOf()580                duressConfigured = false581                recoveryWordHash = null582                recoveryWordsSet = false583                decoyMode = false584                sessionDataAvailable = true585                publish()586                _isUnlocked.value = true587                sessionEpoch = nextSessionEpoch(sessionEpoch)588                previousDerivedKey to previousRootKey589            }590            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")591            val (previousDerivedKey, previousRootKey) = adopted592            previousDerivedKey?.let(VaultCrypto::zero)593            previousRootKey?.let(VaultCrypto::zero)594            AdoptVaultResult.Success(vaultId)595        } catch (e: Exception) {596            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")597        } finally {598            adoptedRootKey?.let(VaultCrypto::zero)599            nextDerivedKey?.let(VaultCrypto::zero)600            sessionRootKey?.let(VaultCrypto::zero)601        }602    }603604    suspend fun unlock(password: String): UnlockResult = mutex.withLock {605        unlockLocked(password, currentEpoch())606    }607608    /** Unlock body without acquiring the mutex — callers must already hold it. */609    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {610        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")611        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))612        val payload = try {613            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)614            try {615                VaultCodec.decode(plaintext)616            } finally {617                VaultCrypto.zero(plaintext)618            }619        } catch (_: Exception) {620            VaultCrypto.zero(key)621            // LV-1: a vault sealed before NFC normalization with a non-NFC622            // password derives a different key now. If the supplied password is623            // not already in NFC form, retry with the raw (un-normalized) bytes.624            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {625                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)626                if (legacy != null) return legacy627            }628            return UnlockResult.WrongPassword629        }630        return adoptSlotA(key, payload, expectedEpoch)631    }632633    /**634     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw635     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed636     * under the NFC-normalized key — so every future unlock takes the normal637     * path. Returns null when the raw key also fails (genuinely wrong password)638     * OR when any step of the heal commits fails (the original slot A is restored639     * so disk and session stay aligned).640     */641    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {642        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))643        var newKey: ByteArray? = null644        return try {645            val payload = try {646                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)647                try {648                    VaultCodec.decode(plaintext)649                } finally {650                    VaultCrypto.zero(plaintext)651                }652            } catch (_: Exception) {653                return null654            }655            // Heal: re-seal slot A under the NFC key (mirrors changePassword's656            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.657            val newSalt = VaultCrypto.generateSalt()658            newKey = VaultCrypto.deriveKey(password, newSalt)659            val plaintext = VaultCodec.encode(payload)660            val blob = try {661                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))662            } finally {663                VaultCrypto.zero(plaintext)664            }665            val next = StoredVault(666                version = slotA.version,667                salt = b64(newSalt),668                blob = blob,669                recoveryEscrow = slotA.recoveryEscrow,670                rootKeyEscrow = slotA.rootKeyEscrow,671            )672            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,673            // or stampLocalMutation must restore the prior slot A AND slot B so disk674            // and the eventual session key never disagree on the wrapping.675            val priorB = store.loadSlotB()676            beforeDurableCommit()677            val healed = withEpochCommit(expectedEpoch) {678                if (!store.writeSlotA(next)) {679                    false680                } else try {681                    if (!writeMatchingNoiseSlotB(next)) {682                        runCatching { store.writeSlotA(slotA) }683                        if (priorB != null) runCatching { store.writeSlotB(priorB) }684                        false685                    } else if (!writeCompanionDuressSlot(next)) {686                        runCatching { store.writeSlotA(slotA) }687                        if (priorB != null) runCatching { store.writeSlotB(priorB) }688                        false689                    } else {690                        store.stampLocalMutation()691                        true692                    }693                } catch (_: Exception) {694                    runCatching { store.writeSlotA(slotA) }695                    if (priorB != null) runCatching { store.writeSlotB(priorB) }696                    false697                }698            } ?: false699            if (healed) {700                VaultCrypto.zero(rawKey!!)701                rawKey = null702                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot703                newKey = null704                result705            } else {706                VaultCrypto.zero(newKey!!)707                newKey = null708                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key709                rawKey = null710                result711            }712        } finally {713            // Cover codec, encryption, storage, and stale-epoch failures before714            // either candidate key can be transferred into the live session.715            rawKey?.let(VaultCrypto::zero)716            newKey?.let(VaultCrypto::zero)717        }718    }719720    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */721    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {722        return try {723            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they724            // can throw after the fields above are live. There is no prior session725            // worth restoring — adoption has already replaced it — so a failed726            // adoption discards everything rather than leaving entries and keys727            // resident behind _isUnlocked=false.728            val adopted = mutateIfCurrentEpoch(expectedEpoch) {729                derivedKey = key730                decoyMode = false731                entryList = payload.entries.toMutableList()732                duressConfigured = payload.duressConfigured733                recoveryWordHash = payload.recoveryWordHash734                recoveryWordsSet = payload.recoveryWordsSet735                sessionDataAvailable = true736                _settings.value = store.loadSettings()737                ensureRootKey(payload)738                autoPurgeExpiredTrash()739                publish()740                _isUnlocked.value = true741                // A successful adoption starts a new generation. A sibling742                // SlotAUnlock captured in the prior epoch will fail closed at743                // the next commit instead of replacing this session.744                sessionEpoch = nextSessionEpoch(sessionEpoch)745            }746            if (adopted == null) {747                VaultCrypto.zero(key)748                UnlockResult.Error("stale_unlock")749            } else {750                UnlockResult.Success751            }752        } catch (e: Exception) {753            // Do not call lock() here: a concurrent newer session may already754            // own this repository. Only invalidate the failed epoch's fields.755            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }756            VaultCrypto.zero(key)757            UnlockResult.Error(e.message ?: "unlock_failed")758        }759    }760761    // ---- Constant-time unlock (MF-5) --------------------------------------762763    /**764     * A slot-A unlock that has been derived but not yet adopted. Holds the live765     * Argon2id key and decoded payload so the caller can derive several candidates766     * concurrently and commit only the winner. The key is owned by this token until767     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.768     */769    class SlotAUnlock internal constructor(770        key: ByteArray,771        internal val payload: VaultPayload,772        internal val epoch: Long,773    ) {774        private var ownedKey: ByteArray? = key775        private var consumed = false776777        /** Transfer the key exactly once to the adopter. */778        internal fun takeKey(): ByteArray? = synchronized(this) {779            if (consumed) return@synchronized null780            consumed = true781            ownedKey.also { ownedKey = null }782        }783784        /** Discard an uncommitted token without touching a transferred key. */785        internal fun discard() = synchronized(this) {786            if (consumed) return@synchronized787            consumed = true788            ownedKey?.let(VaultCrypto::zero)789            ownedKey = null790        }791    }792793    /**794     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.795     * Pure computation (no mutex), so it can run concurrently with other candidate796     * derivations before the caller decides which to commit (MF-5). Returns null on797     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).798     */799    fun tryUnlockSlotA(password: String): SlotAUnlock? {800        val epoch = currentEpoch()801        val slotA = store.loadSlotA() ?: return null802        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))803        return try {804            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)805            val payload = try {806                VaultCodec.decode(plaintext)807            } finally {808                VaultCrypto.zero(plaintext)809            }810            SlotAUnlock(key, payload, epoch)811        } catch (_: Exception) {812            VaultCrypto.zero(key)813            null814        }815    }816817    /** Adopt a previously-derived [SlotAUnlock] as the live session. */818    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {819        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")820        adoptSlotA(key, unlock.payload, unlock.epoch)821    }822823    /** Zero a derived [SlotAUnlock] that will not be adopted. */824    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()825826    // ---- Duress slot B (MF-6) ---------------------------------------------827828    /**829     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key830     * and decoded decoy payload so it can be derived concurrently with slot A and831     * only committed if the duress passphrase was the one entered (MF-5/MF-6).832     */833    class DuressUnlock internal constructor(834        key: ByteArray,835        internal val payload: VaultPayload,836        internal val epoch: Long,837    ) {838        private var ownedKey: ByteArray? = key839        private var consumed = false840841        internal fun takeKey(): ByteArray? = synchronized(this) {842            if (consumed) return@synchronized null843            consumed = true844            ownedKey.also { ownedKey = null }845        }846847        internal fun discard() = synchronized(this) {848            if (consumed) return@synchronized849            consumed = true850            ownedKey?.let(VaultCrypto::zero)851            ownedKey = null852        }853    }854855    /**856     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the857     * session. Success means the entered password is the duress passphrase that keys858     * slot B — indistinguishable from the real slot A at the lock screen. Pure859     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].860     * Returns null when no duress slot exists or the password is not the duress one.861     */862    fun tryUnlockDuress(password: String): DuressUnlock? {863        val epoch = currentEpoch()864        val slot = store.loadDecoySlot() ?: return null865        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))866        return try {867            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)868            val payload = try {869                VaultCodec.decode(plaintext)870            } finally {871                VaultCrypto.zero(plaintext)872            }873            DuressUnlock(key, payload, epoch)874        } catch (_: Exception) {875            VaultCrypto.zero(key)876            null877        }878    }879880    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */881    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {882        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")883        if (currentEpoch() != unlock.epoch) {884            VaultCrypto.zero(key)885            return@withLock UnlockResult.Error("stale_unlock")886        }887        return try {888            val adopted = mutateIfCurrentEpoch(unlock.epoch) {889                derivedKey = key890                decoyMode = true891                entryList = unlock.payload.entries.toMutableList()892                duressConfigured = false893                recoveryWordHash = unlock.payload.recoveryWordHash894                recoveryWordsSet = unlock.payload.recoveryWordsSet895                sessionDataAvailable = true896                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)897                          else VaultCrypto.generateRootKey()898                _settings.value = store.loadSettings()899                publish()900                _isUnlocked.value = true901                sessionEpoch = nextSessionEpoch(sessionEpoch)902            }903            if (adopted == null) {904                VaultCrypto.zero(key)905                UnlockResult.Error("stale_unlock")906            } else {907                UnlockResult.Success908            }909        } catch (e: Exception) {910            // A payload/root/settings failure can occur after the candidate key911            // has been assigned to derivedKey. Tear down only this epoch so a912            // newer session is not disturbed, and scrub the token reference on913            // every error path.914            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }915            VaultCrypto.zero(key)916            UnlockResult.Error(e.message ?: "decoy_unlock_failed")917        }918    }919920    /** Zero a derived [DuressUnlock] that will not be adopted. */921    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()922923    /**924     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an925     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The926     * passphrase is never stored — only its ability to decrypt slot B proves it.927     * Requires the real vault to be unlocked and not itself a decoy session.928     */929    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {930        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().931        val expectedEpoch = mutateState {932            if (derivedKey != null && !decoyMode) sessionEpoch else null933        } ?: return false934        // Reject a duress passphrase that also opens the real vault: it would make the935        // lock screen ambiguous (both slots would decrypt for the same input).936        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }937        var key: ByteArray? = null938        var basePlaintext: ByteArray? = null939        return try {940            val salt = VaultCrypto.generateSalt()941            key = VaultCrypto.deriveKey(duressPassphrase, salt)942            // An empty decoy vault with its own fresh sync root key — nothing real leaks.943            val decoyRoot = VaultCrypto.generateRootKey()944            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }945            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)946            basePlaintext = VaultCodec.encode(decoyPayload)947            // H-04: pad the duress plaintext to slot A's ciphertext length so the948            // duress blob matches slot A's blob on disk. VaultCodec.decode strips949            // trailing NULs, so the extra padding survives encrypt but never950            // reaches the JSON parser.951            // ponytail: only re-pads at configuration time — if slot A grows past952            // its initial bucket (entries cross 4096/16384/etc.), the real decoy953            // stays at the smaller bucket. Re-padding on every persist requires954            // keeping the duress passphrase in memory, which we don't.955            val slotA = store.loadSlotA()956            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0957            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!958            val blob = try {959                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))960            } finally {961                VaultCrypto.zero(plaintext)962                // Padding creates a second array; scrub the original encoded963                // payload too rather than dropping its last reference untouched.964                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)965                basePlaintext = null966                key?.let(VaultCrypto::zero)967                key = null968            }969            beforeDurableCommit()970            val ok = withEpochCommit(expectedEpoch) {971                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))972            } ?: false973            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {974                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }975            } else if (ok) {976                return false977            }978            ok979        } catch (_: Exception) {980            false981        } finally {982            // Cover failures before the encrypt block is entered (for example a983            // codec/copy exception) as well as any early return after key derivation.984            key?.let(VaultCrypto::zero)985            basePlaintext?.let(VaultCrypto::zero)986        }987    }988989    /**990     * Install a browser-shaped duress slot received through adoption.991     *992     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's993     * [StoredVault] via [browserDuressPassthrough] (no decryption), and994     * writes the result to the decoy slot. Epoch-fenced like the Codex995     * restore path so a concurrent lock() cannot strand the write.996     *997     * Returns false on invalid input, no live session, or write failure.998     */999    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1000        val b = BrowserSlot.parse(json) ?: return false1001        var stored: StoredVault? = null1002        var salt: ByteArray? = null1003        var nonce: ByteArray? = null1004        var ct: ByteArray? = null1005        var tag: ByteArray? = null1006        var blob: ByteArray? = null1007        try {1008            salt = unhex(b.salt)1009            nonce = unhex(b.nonce)1010            ct = unhex(b.ciphertext)1011            tag = unhex(b.tag)1012            blob = ByteArray(nonce.size + ct.size + tag.size).also {1013                System.arraycopy(nonce, 0, it, 0, nonce.size)1014                System.arraycopy(ct, 0, it, nonce.size, ct.size)1015                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1016            }1017            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1018            val expectedEpoch = sessionEpoch1019            beforeDurableCommit()1020            withEpochCommit(expectedEpoch) {1021                store.writeDecoySlot(stored)1022            } ?: false1023        } catch (_: Exception) {1024            false1025        } finally {1026            salt?.let(VaultCrypto::zero)1027            nonce?.let(VaultCrypto::zero)1028            ct?.let(VaultCrypto::zero)1029            tag?.let(VaultCrypto::zero)1030            blob?.let(VaultCrypto::zero)1031        }1032    }10331034    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1035    suspend fun clearDuress(): Boolean = mutex.withLock {1036        // H-04: presence-indistinguishable — replace the real decoy with a fake1037        // blob of slot A's size rather than removing the duress record. The1038        // record stays on disk at the same length, so an at-rest observer1039        // cannot tell "duress cleared" from "no duress ever configured".1040        val expectedEpoch = currentEpoch()1041        val replaced = withEpochCommit(expectedEpoch) {1042            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1043                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1044        } ?: false1045        if (!replaced) return@withLock false1046        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1047        // duressConfigured write so a concurrent lock() cannot land between them.1048        if (!isSessionEpochCurrent(expectedEpoch)) return false1049        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1050        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1051            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1052        }1053        true1054    }10551056    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1057    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1058        var current: Throwable? = error1059        while (current != null) {1060            if (current is javax.crypto.AEADBadTagException) return true1061            // Some Android providers expose a GCM tag failure as the parent1062            // BadPaddingException rather than the AEAD-specific subtype.  A1063            // short ciphertext is an IllegalBlockSizeException and therefore1064            // remains an internal/malformed-payload Error below.1065            if (current is javax.crypto.BadPaddingException) return true1066            current = current.cause1067        }1068        return false1069    }10701071    /** Unlock with an already-derived AES-256 key (biometric path). */1072    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1073        val expectedEpoch = currentEpoch()1074        val slotA = try {1075            store.loadSlotA()1076        } catch (_: Exception) {1077            return@withLock UnlockResult.Error("unlock_key_storage_failed")1078        } ?: return@withLock UnlockResult.Error("no_vault")1079        // Keep the caller-owned key separate from the staged session key. The1080        // biometric caller always zeroes its input in a finally block; adoption1081        // must therefore own a copy and use the same all-or-nothing path as a1082        // password unlock rather than publishing a partially decoded session.1083        var stagedKey: ByteArray? = key.copyOf()1084        return try {1085            val encrypted = try {1086                unb64(slotA.blob)1087            } catch (_: Exception) {1088                return@withLock UnlockResult.Error("invalid_vault_blob")1089            }1090            // A GCM record must include nonce + authentication tag.  Providers1091            // differ on whether a shorter record is reported as BadPadding or1092            // AEADBadTag; reject it before decrypt so malformed storage cannot be1093            // misreported as a user-entered wrong key.1094            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1095                return@withLock UnlockResult.Error("invalid_vault_blob")1096            }1097            val plaintext = try {1098                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1099            } catch (e: Exception) {1100                if (isGcmAuthenticationFailure(e)) {1101                    return@withLock UnlockResult.WrongPassword1102                }1103                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1104            }1105            val payload = try {1106                VaultCodec.decode(plaintext)1107            } catch (_: Exception) {1108                return@withLock UnlockResult.Error("invalid_vault_payload")1109            } finally {1110                VaultCrypto.zero(plaintext)1111            }1112            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1113            val result = adoptSlotA(candidate, payload, expectedEpoch)1114            if (result is UnlockResult.Success) stagedKey = null1115            result1116        } finally {1117            stagedKey?.let(VaultCrypto::zero)1118        }1119    }11201121    fun lock() {1122        // H-03: must observe derivedKey/entryList atomically with any in-flight1123        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1124        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1125        // guards just the brief state reset. Advancing the epoch first invalidates1126        // every operation that captured this session before it reaches a later1127        // durable commit or publication.1128        stateLock.lock()1129        try {1130            sessionEpoch = nextSessionEpoch(sessionEpoch)1131            clearSessionStateLocked()1132        } finally {1133            stateLock.unlock()1134        }1135    }11361137    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11381139    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1140    // is a stub off-device).1141    @VisibleForTesting1142    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11431144    /**1145     * Call immediately before launching a system Activity (a SAF file1146     * picker/save dialog, a share sheet) that briefly takes the whole process1147     * out of the foreground as an expected part of an in-app flow — not a1148     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1149     * two apart on its own (it fires for both), and without this, exporting or1150     * importing a file silently locked and wiped the vault mid-flow the moment1151     * the picker took over, before its result ever reached the app.1152     *1153     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1154     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1155     */1156    fun expectTransientBackground() {1157        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1158    }11591160    /**1161     * Consumed once by the next background-lock check; see [expectTransientBackground].1162     * Returns false (and clears the flag) if the suppression has expired or was1163     * never set — a genuine background event must always lock.1164     */1165    fun consumeTransientBackgroundSuppression(): Boolean {1166        val until = suppressNextBackgroundLockUntil1167        val active = until != 0L && nowMs() < until1168        suppressNextBackgroundLockUntil = 0L1169        return active1170    }11711172    // ---- Sync identity ----------------------------------------------------11731174    /**1175     * The vault's stable sync master key (a copy). Caller must zero it. Backs1176     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1177     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1178     */1179    /**1180     * HOLE 5 — the duress vault never syncs.1181     *1182     * [commitDuressUnlock] gives the decoy session a root key so its own codex1183     * and recovery paths work, which means (unlike the browser, where the decoy1184     * path simply has no root key) the sync surface has to refuse explicitly.1185     * Without this the decoy vault would present a second, different vault_id1186     * to a paired device, and a coerced user opening it would make sync visibly1187     * fail on their other devices — a duress indicator, which is the one thing1188     * a duress vault must never be.1189     */1190    // M-04: the "null when locked" half of the contract was documented but never1191    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1192    // [unlockWithRecoveryWords], which installs a root key with no password key1193    // yet) and in a create that failed after staging. Sync must fail closed1194    // there, so the authoritative unlocked flag gates it, not just decoyMode.1195    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)11961197    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1198        if (decoyMode || !_isUnlocked.value) null1199        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1200    }12011202    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1203        mutateState { sessionEpoch == epoch }12041205    internal fun unlockedSessionEpoch(): Long? = mutateState {1206        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1207    }12081209    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12101211    /**1212     * The current session's root key, decoy included. For local-only uses that1213     * must behave identically in a decoy session — Codex backup and recovery1214     * words — because a feature that errors under duress is itself a duress1215     * tell. In a decoy session this is the DECOY's root key (see1216     * [commitDuressUnlock]), never the real vault's, so neither surface can1217     * leak the real vault. Anything that leaves the device must use1218     * [syncMasterKey] instead.1219     */1220    fun sessionRootKey(): ByteArray? = mutateState {1221        if (!sessionDataAvailable) null else rootKey?.copyOf()1222    }12231224    /** Stable per-vault id two paired devices must share to sync. Null when1225     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1226    fun syncVaultId(): String? = mutateState {1227        if (decoyMode || !_isUnlocked.value) null1228        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1229    }12301231    /**1232     * Browser parity (MV-8): rotate the sync identity by replacing the stable1233     * root key. Entries remain under the master-password key, while sync1234     * credentials, vault_id, and recovery words all change.1235     */1236    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1237        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1238            ?: return false1239        installRootKey(generateRecoverableRootKey(), expectedEpoch)1240    }12411242    /**1243     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1244     * keeping every local entry.1245     *1246     * Sync identity (vault id, signing keys) derives from the root key, and1247     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1248     * created vaults can never reconcile. Taking the other side's root key is1249     * what makes them one vault; from the next sync on, both entry sets merge.1250     *1251     * Entries survive because they are sealed under the password-derived key,1252     * not the root key — the same property [rotateSyncIdentity] relies on.1253     *1254     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1255     * this device's previous recovery words stop working for good, and its new1256     * words are the source vault's words, because words are a deterministic1257     * encoding of the root key.1258     */1259    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1260        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1261            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1262        }1263        val expectedEpoch = mutateState {1264            when {1265                derivedKey == null -> null1266                decoyMode -> Long.MIN_VALUE1267                else -> sessionEpoch1268            }1269        }1270        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1271        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1272        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")12731274        val incoming = hexToRootKey(rootKeyHex)1275        if (incoming.size != VaultCrypto.KEY_SIZE) {1276            VaultCrypto.zero(incoming)1277            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1278        }1279        // Refuse before committing rather than leave this device holding a vault1280        // whose recovery words cannot be typed back. generateRecoverableRootKey1281        // screens its own keys for this; a key arriving from a peer has not been.1282        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1283        if (!roundTrips) {1284            VaultCrypto.zero(incoming)1285            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1286        }12871288        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1289        return if (installRootKey(incoming, expectedEpoch)) {1290            AdoptVaultResult.Success(vaultId)1291        } else {1292            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1293        }1294    }12951296    /**1297     * The recovery words [rootKeyHex] will produce once joined, or null if it is1298     * not a usable root key. Lets the join flow show the user the words that are1299     * about to replace theirs *before* anything is committed.1300     */1301    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1302        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1303        val bytes = hexToRootKey(rootKeyHex)1304        return try {1305            val words = rootKeyToWords(bytes)1306            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1307        } catch (_: Exception) {1308            null1309        } finally {1310            VaultCrypto.zero(bytes)1311        }1312    }13131314    /**1315     * Swap the vault's root key, keeping every entry, and re-escrow under the1316     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1317     * [mutex].1318     */1319    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1320        // H-03: snapshot the live fields under stateLock so the success and the1321        // rollback paths both see consistent prior/new state. Writing the new1322        // rootKey/recovery fields is inside its own mutateState at the end.1323        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1324        // — holding live references would let a zero-on-failure operation also1325        // wipe the session's still-live key arrays.1326        data class Snapshot(1327            val key: ByteArray,1328            val priorRootKey: ByteArray,1329            val priorRecoveryHash: String?,1330            val priorRecoverySet: Boolean,1331        )1332        val snap = mutateIfCurrentEpoch(expectedEpoch) {1333            val k = derivedKey ?: return@mutateIfCurrentEpoch null1334            if (decoyMode) return@mutateIfCurrentEpoch null1335            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1336            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1337        } ?: run {1338            VaultCrypto.zero(nextRootKey)1339            return false1340        }1341        val priorSlot = store.loadSlotA() ?: run {1342            VaultCrypto.zero(snap.key)1343            VaultCrypto.zero(snap.priorRootKey)1344            VaultCrypto.zero(nextRootKey)1345            return false1346        }1347        val nextWords = rootKeyToWords(nextRootKey)13481349        return try {1350            beforeDurableCommit()1351            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1352            if (mutateIfCurrentEpoch(expectedEpoch) {1353                rootKey = nextRootKey1354                recoveryWordHash = recoveryWordsHash(nextWords)1355                recoveryWordsSet = false1356            } == null) throw IllegalStateException("stale_root_rotation")13571358            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1359                ?: throw IllegalStateException("recovery_escrow_failed")1360            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1361                ?: throw IllegalStateException("root_escrow_failed")1362            val plaintext = VaultCodec.encode(currentPayload())1363            val blob = try {1364                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1365            } finally {1366                VaultCrypto.zero(plaintext)1367            }1368            val nextSlot = priorSlot.copy(1369                blob = blob,1370                recoveryEscrow = nextRecoveryEscrow,1371                rootKeyEscrow = nextRootKeyEscrow,1372            )1373            val committed = withEpochCommit(expectedEpoch) {1374                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1375                try {1376                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1377                    // H-04: a root-key rotation re-seals slot A, so the duress slot1378                    // (real or fake) must be re-padded to the new ciphertext length.1379                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1380                    store.stampLocalMutation()1381                } catch (_: Exception) {1382                    store.writeSlotA(priorSlot)1383                    return@withEpochCommit false1384                }1385                true1386            } ?: false1387            if (!committed) throw IllegalStateException("storage_write_failed")1388            mutateIfCurrentEpoch(expectedEpoch) { publish() }1389            true1390        } catch (_: Exception) {1391            val restored = mutateIfCurrentEpoch(expectedEpoch) {1392                rootKey?.let(VaultCrypto::zero)1393                rootKey = snap.priorRootKey.copyOf()1394                recoveryWordHash = snap.priorRecoveryHash1395                recoveryWordsSet = snap.priorRecoverySet1396            }1397            if (restored != null) {1398                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1399            }1400            false1401        } finally {1402            VaultCrypto.zero(snap.key)1403            VaultCrypto.zero(snap.priorRootKey)1404            // On success nextRootKey is owned by rootKey. On every other path1405            // it is not, including a stale epoch after lock(); scrub it while1406            // the local reference is still available.1407            val stillOwnedBySession = mutateState {1408                sessionEpoch == expectedEpoch && rootKey === nextRootKey1409            }1410            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1411        }1412    }14131414    /**1415     * A copy of the live Argon2id-derived vault key, for biometric escrow only1416     * (wrapped under a hardware KeyStore key on biometric enable). Null when1417     * locked. Caller must [VaultCrypto.zero] it after use.1418     */1419    fun currentVaultKey(): ByteArray? = mutateState {1420        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1421    }14221423    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1424        val expectedEpoch = currentEpoch()1425        val slotA = store.loadSlotA() ?: return false1426        var oldKey: ByteArray? = null1427        var newKey: ByteArray? = null1428        var plaintext: ByteArray? = null1429        return try {1430            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1431            // Keep ownership of the decrypted vault plaintext until decoding has1432            // finished.  Passing decrypt() inline made the byte array unreachable1433            // before the finally block, so a successful or failed password change1434            // could leave the full plaintext resident in the heap.1435            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1436            val payload = try {1437                VaultCodec.decode(decrypted)1438            } finally {1439                VaultCrypto.zero(decrypted)1440            }1441            val newSalt = VaultCrypto.generateSalt()1442            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1443            // Re-seal the existing payload under the new key/salt.1444            plaintext = VaultCodec.encode(payload)1445            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1446            // The escrow seals the slot-A PLAINTEXT, which a password change does not1447            // alter — only the wrapping changes — so carry it forward; rebuilding the1448            // StoredVault from scratch would silently drop it.1449            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1450            // content that a password change does not alter — only the slot-A1451            // wrapping rotates. The root-key escrow staying stable across password1452            // changes matches the browser (`vault.ts:363-364`).1453            val next = StoredVault(1454                version = slotA.version,1455                salt = b64(newSalt),1456                blob = blob,1457                recoveryEscrow = slotA.recoveryEscrow,1458                rootKeyEscrow = slotA.rootKeyEscrow,1459            )1460            // H-01: durably commit slot A + size-matched noise + version stamp1461            // before swapping the session key. A partial commit (new A on disk,1462            // old key in memory) would have the next save encrypt under the old1463            // key while matching disk on the new one.1464            val priorB = store.loadSlotB()1465            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1466            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1467            // inside the same stateLock critical section, so a concurrent lock()1468            // can't observe the in-between (zeroed but not yet reassigned).1469            if (mutateIfCurrentEpoch(expectedEpoch) {1470                oldKey?.let(VaultCrypto::zero); oldKey = null1471                derivedKey?.let(VaultCrypto::zero)1472                derivedKey = newKey; newKey = null1473            } == null) return false1474            true1475        } catch (_: Exception) {1476            false1477        } finally {1478            oldKey?.let(VaultCrypto::zero)1479            newKey?.let(VaultCrypto::zero)1480            plaintext?.let(VaultCrypto::zero)1481        }1482    }14831484    /**1485     * Irreversible wipe. Locks the session and clears every vault key. The Pro1486     * license is in a separate store and is preserved by construction.1487     */1488    suspend fun destroyVault() = mutex.withLock {1489        lock()1490        store.clearVault()1491        store.clearDecoySlot()1492    }14931494    // ---- Entry CRUD -------------------------------------------------------14951496    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1497        val epoch = requireUnlocked()1498        val stamp = VaultCrypto.monotonicEntryTimestamp()1499        val now = System.currentTimeMillis()1500        val entry = withDerivedLast4(draft.copy(1501            id = draft.id.ifBlank { UUID.randomUUID().toString() },1502            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1503            modifiedAt = stamp,1504            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1505        ))1506        // H-03: capture rollback AND write entryList under stateLock so a1507        // concurrent lock() cannot zero the session between the read and the write.1508        val rollback = mutateCurrentSession(epoch) {1509            val r = entryList.toMutableList()1510            entryList.add(entry)1511            r1512        } ?: throw IllegalStateException("vault_locked")1513        persistOrRollback(rollback, epoch)1514        entry1515    }15161517    /**1518     * Insert many entries with a single encrypt-and-write at the end.1519     *1520     * Importing a real vault through [addEntry] was quadratic: every entry took1521     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1522     * restore did 806 full-vault writes and hung the app long enough to ANR.1523     * Rolls back to the pre-import list if the one write fails, matching1524     * [addEntry]'s all-or-nothing behaviour.1525     */1526    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1527        val epoch = requireUnlocked()1528        if (drafts.isEmpty()) return 01529        val now = System.currentTimeMillis()1530        val ready = drafts.map { draft ->1531            val stamp = VaultCrypto.monotonicEntryTimestamp()1532            withDerivedLast4(1533                draft.copy(1534                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1535                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1536                    modifiedAt = stamp,1537                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1538                ),1539            )1540        }1541        // H-03: capture rollback AND append every entry under stateLock.1542        val rollback = mutateCurrentSession(epoch) {1543            val r = entryList.toMutableList()1544            entryList.addAll(ready)1545            r1546        } ?: throw IllegalStateException("vault_locked")1547        persistOrRollback(rollback, epoch)1548        return drafts.size1549    }15501551    /**1552     * M-01: replace the whole active set with [newEntries] in one write.1553     *1554     * Import's REPLACE mode used to soft-delete every current entry one durable1555     * write at a time (swallowing each failure) and then batch-add, so a failed1556     * add left the old entries stranded in Trash with nothing restored. Staging1557     * the entire next list in memory and committing it through1558     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1559     * pre-import list — active entries included — is restored.1560     *1561     * Existing tombstones are preserved so paired devices still see the1562     * deletions they already know about.1563     */1564    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1565        val epoch = requireUnlocked()1566        val now = System.currentTimeMillis()1567        val ready = newEntries.map { draft ->1568            val stamp = VaultCrypto.monotonicEntryTimestamp()1569            withDerivedLast4(1570                draft.copy(1571                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1572                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1573                    modifiedAt = stamp,1574                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1575                ),1576            )1577        }1578        // M-03: an incoming entry that shares an id with an existing live entry1579        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1580        // the old one and then appending the new one would leave the list with1581        // two entries of the same id — the tombstoned one would be returned by1582        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1583        // superseded entries from the tombstone set instead.1584        val incomingIds = ready.map { it.id }.toHashSet()1585        val rollback = mutateCurrentSession(epoch) {1586            val r = entryList.toMutableList()1587            val replaced = entryList.map { entry ->1588                if (entry.deletedAt != null) return@map entry1589                if (entry.id in incomingIds) return@map null1590                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1591                entry.copy(deletedAt = stamp, modifiedAt = stamp)1592            }.filterNotNull()1593            entryList = (replaced + ready).toMutableList()1594            r1595        } ?: throw IllegalStateException("vault_locked")1596        persistOrRollback(rollback, epoch)1597        return ready.size1598    }15991600    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1601        val epoch = requireUnlocked()1602        // H-03: read entryList, compute the patched entry, and write it under a1603        // single stateLock so a concurrent lock() cannot interleave between the1604        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1605        // list, or clobber the entry onto the wrong id after a relock).1606        val prepared = mutateCurrentSession(epoch) {1607            val idx = entryList.indexOfFirst { it.id == patch.id }1608            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1609            val current = entryList[idx]1610            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1611            // Only refresh passwordChangedAt when the password value actually changes.1612            val passwordChanged = patch.password != null && patch.password != current.password1613            // Capture the outgoing password (newest first) so the user can reveal/copy/1614            // restore it later. History is computed authoritatively from `current`, not1615            // the patch — updateEntry is a full-entry replace, so trusting patch's value1616            // would let a stale form clobber the real history. Record the age the old1617            // password carried, skip an empty prior, and skip an immediate exact dupe1618            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1619            val priorPassword = current.password1620            val shouldCapture = passwordChanged &&1621                !priorPassword.isNullOrEmpty() &&1622                current.passwordHistory?.firstOrNull()?.password != priorPassword1623            val nextHistory = if (shouldCapture) {1624                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1625                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1626            } else {1627                current.passwordHistory1628            }1629            val updated = withDerivedLast4(patch.copy(1630                createdAt = current.createdAt,1631                modifiedAt = stamp,1632                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1633                passwordHistory = nextHistory,1634            ))1635            val rollback = entryList.toMutableList()1636            entryList[idx] = updated1637            updated to rollback1638        } ?: throw IllegalStateException("vault_locked")1639        val (updated, rollback) = prepared1640        persistOrRollback(rollback, epoch)1641        updated1642    }16431644    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1645    suspend fun deleteEntry(id: String) = mutex.withLock {1646        val epoch = requireUnlocked()1647        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1648        // the list out from under the indexOfFirst.1649        val rollback = mutateCurrentSession(epoch) {1650            val idx = entryList.indexOfFirst { it.id == id }1651            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1652            val current = entryList[idx]1653            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1654            val prior = entryList.toMutableList()1655            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1656            prior1657        } ?: throw IllegalStateException("vault_locked")1658        persistOrRollback(rollback, epoch)1659    }16601661    suspend fun restoreEntry(id: String) = mutex.withLock {1662        val epoch = requireUnlocked()1663        val rollback = mutateCurrentSession(epoch) {1664            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1665            if (idx == -1) throw IllegalStateException("deleted entry not found")1666            val current = entryList[idx]1667            val prior = entryList.toMutableList()1668            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1669            prior1670        } ?: throw IllegalStateException("vault_locked")1671        persistOrRollback(rollback, epoch)1672    }16731674    /** Permanent delete — no tombstone, no recovery. */1675    suspend fun purgeEntry(id: String) = mutex.withLock {1676        val epoch = requireUnlocked()1677        val rollback = mutateCurrentSession(epoch) {1678            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1679            if (idx == -1) throw IllegalStateException("deleted entry not found")1680            val prior = entryList.toMutableList()1681            entryList.removeAt(idx)1682            prior1683        } ?: throw IllegalStateException("vault_locked")1684        persistOrRollback(rollback, epoch)1685    }16861687    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1688    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1689        val epoch = requireUnlocked()1690        // H-03: read+remove under stateLock so lock() can't reorder the remove.1691        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1692        val r = mutateCurrentSession(epoch) {1693            val rollback = entryList.toMutableList()1694            val before = entryList.size1695            entryList.removeAll { it.deletedAt != null }1696            R(rollback, before - entryList.size)1697        } ?: throw IllegalStateException("vault_locked")1698        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1699        r.removed1700    }17011702    /** Browser parity: number of active entries that dedupe would move to trash. */1703    suspend fun countDuplicateEntries(): Int = mutex.withLock {1704        val epoch = requireUnlocked()1705        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1706        // lock() can't race the read with state writes.1707        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1708            ?: throw IllegalStateException("vault_locked")1709    }17101711    /**1712     * Browser parity: soft-delete duplicate entries, keeping the most recently1713     * modified member of each group. Refuse in decoy mode so the duress vault is1714     * not reshaped into a forensically distinct state.1715     */1716    suspend fun deduplicateEntries(): Int = mutex.withLock {1717        val epoch = requireUnlocked()1718        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1719        val ready = mutateCurrentSession(epoch) {1720            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1721            val active = entryList.filter { it.deletedAt == null }1722            val keepIds = mutableSetOf<String>()1723            active.groupBy { it.duplicateKey() }.values.forEach { group ->1724                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1725            }1726            val rollback = entryList.toMutableList()1727            var removed = 01728            entryList = entryList.map { entry ->1729                if (entry.deletedAt == null && entry.id !in keepIds) {1730                    removed++1731                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1732                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1733                } else {1734                    entry1735                }1736            }.toMutableList()1737            R(rollback, removed)1738        } ?: throw IllegalStateException("vault_locked")1739        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1740        ready.removed1741    }17421743    fun getEntries(): List<VaultEntry> = mutateState {1744        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1745    }1746    fun getEntry(id: String): VaultEntry? = mutateState {1747        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1748    }1749    fun getDeletedEntries(): List<VaultEntry> = mutateState {1750        if (!sessionDataAvailable) emptyList()1751        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1752    }17531754    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1755    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1756        return mutateState {1757            if (!sessionDataAvailable) return@mutateState emptyList()1758            val active = entryList.filter { it.deletedAt == null }1759            if (tagFilter == null) return@mutateState active1760            val needle = tagFilter.trim().lowercase()1761            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1762        }1763    }17641765    /** Sorted, de-duplicated tags across all active entries. */1766    fun getDistinctTags(): List<String> = mutateState {1767        if (!sessionDataAvailable) return@mutateState emptyList()1768        entryList.asSequence()1769            .filter { it.deletedAt == null }1770            .flatMap { it.tags?.asSequence() ?: emptySequence() }1771            .map { it.trim().lowercase() }1772            .filter { it.isNotEmpty() }1773            .distinct()1774            .sorted()1775            .toList()1776    }17771778    /**1779     * Distinct non-blank values for an identity field across active entries,1780     * most-frequently-used first. Supports "username" and "email"; unknown field1781     * names return an empty list. Values keep original casing (deduped1782     * case-insensitively) so a suggestion matches what the user typed.1783     */1784    fun getDistinctFieldValues(field: String): List<String> {1785        val select: (VaultEntry) -> String? = when (field) {1786            "username" -> { e -> e.username }1787            "email" -> { e -> e.email }1788            else -> return emptyList()1789        }1790        return mutateState {1791            if (!sessionDataAvailable) return@mutateState emptyList()1792            entryList.asSequence()1793                .filter { it.deletedAt == null }1794                .mapNotNull { select(it)?.trim() }1795                .filter { it.isNotEmpty() }1796                .groupingBy { it.lowercase() }1797                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1798                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1799                }1800                .values1801                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1802                .map { it.first }1803        }1804    }18051806    fun getTombstones(): List<Tombstone> = mutateState {1807        if (!sessionDataAvailable) emptyList()1808        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1809    }1810    fun getVaultVersion(): Long = store.version()1811    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18121813    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1814        val expectedEpoch = currentEpoch()1815        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1816        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1817        // H-04: snapshots now carry the duress slot too — restore preserves1818        // the at-rest triad (A/B/D), not just the A/B pair.1819        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1820        val priorA = store.loadSlotA()1821        val priorB = store.loadSlotB()1822        val priorD = store.loadDecoySlot()1823        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1824        // slot write — the old code took this snapshot, then stranded it on1825        // every failure, polluting history with a "restore" entry that never1826        // restored anything. The history below is rolled back together with1827        // the slots if the commit fails.1828        val priorHistory = store.loadVaultHistory()1829        // P1-4 (codex): the recovery snapshot must land before we commit the1830        // slot pair, otherwise a failed restore cannot be rolled back to the1831        // pre-restore history. If the history write itself fails, refuse to1832        // proceed — the alternative is a successful restore with no undo path.1833        beforeDurableCommit()1834        if (!takeVaultSnapshot("restore", expectedEpoch)) {1835            return false1836        }1837        // H-04: legacy snapshots without vault_d, or vault_d that fails to1838        // decode, fall back to a fake duress of slot A's size — presence-1839        // indistinguishable from any other freshly-restored vault.1840        val nextD: StoredVault = restoredD ?: run {1841            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161842            val fakeBlob = ByteArray(target).also(rng::nextBytes)1843            val fakeSalt = VaultCrypto.generateSalt()1844            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1845        }1846        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1847            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1848            return false1849        }1850        lock()1851        true1852    }18531854    /**1855     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1856     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1857     * (a restore preserves the duress-shaped blob from history), not a fresh1858     * noise record. Any failure rolls back so the at-rest pair never holds a1859     * restored slot A against a pre-restore slot B. Caller is responsible for1860     * restoring the pre-snapshot history on a false return.1861     *1862     * H-04: duress slot is now part of the atomic triad — restored last so1863     * a duress-write failure rolls the A/B pair back too.1864     */1865    private fun commitRestoreAtomic(1866        nextA: StoredVault,1867        nextB: String,1868        nextD: StoredVault,1869        priorA: StoredVault?,1870        priorB: String?,1871        priorD: StoredVault?,1872        expectedEpoch: Long,1873    ): Boolean {1874        return withEpochCommit(expectedEpoch) {1875            if (!store.writeSlotA(nextA)) return@withEpochCommit false1876            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1877            // must trigger rollback; previously a throw escaped without restoring1878            // the prior slot A and history.1879            try {1880                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1881                // H-04: duress slot is part of the restore triad. Failures here1882                // roll A+B back so the at-rest state stays consistent.1883                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1884                store.stampLocalMutation()1885            } catch (_: Exception) {1886                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1887                return@withEpochCommit false1888            }1889            true1890        } ?: false1891    }18921893    // ---- Sync apply (does NOT advance the version) ------------------------18941895    /**1896     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1897     * WITHOUT bumping the version counter, so a sync round cannot cascade into1898     * another outbound sync. Returns the number of locally-applied changes.1899     */1900    suspend fun applyMergedRemoteEntries(1901        remoteEntries: List<VaultEntry>,1902        remoteTombstones: List<Tombstone>,1903        expectedEpoch: Long? = null,1904    ): Int = mutex.withLock {1905        val epoch = expectedEpoch ?: requireUnlocked()1906        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01907        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1908        // BEFORE any comparison against a local one. Without this a peer can1909        // send a far-future modifiedAt and permanently pin its version of an1910        // entry against every honest local edit. The browser clamps in the1911        // matching place (vault.ts applyMergedRemoteEntries).1912        val mergeNow = System.currentTimeMillis()1913        val clamped = remoteEntries.map { entry ->1914            entry.copy(1915                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1916                deletedAt = entry.deletedAt?.let {1917                    SyncLimits.clampSyncTimestamp(it, mergeNow)1918                },1919            )1920        }1921        val tombMap = remoteTombstones.associate {1922            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1923        }1924        // H-03: build the new entryList under stateLock so a concurrent lock()1925        // cannot zero derivedKey/entryList between the byId read and the merge.1926        val applied = mutateCurrentSession(epoch) {1927            var n = 01928            val byId = entryList.associateBy { it.id }.toMutableMap()19291930            for (remote in clamped) {1931                val tomb = tombMap[remote.id]1932                if (tomb != null && tomb >= remote.modifiedAt) continue1933                val local = byId[remote.id]1934                if (local == null) {1935                    entryList.add(remote); byId[remote.id] = remote; n++1936                } else if (remote.modifiedAt > local.modifiedAt) {1937                    val i = entryList.indexOfFirst { it.id == local.id }1938                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1939                }1940            }19411942            for ((id, deletedAt) in tombMap) {1943                val local = byId[id] ?: continue1944                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1945                if (deletedAt < local.modifiedAt) continue1946                val i = entryList.indexOfFirst { it.id == id }1947                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1948            }1949            n1950        } ?: return@withLock 019511952        if (applied > 0) {1953            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)1954            mutateIfCurrentEpoch(epoch) { publish() }1955        }1956        applied1957    }19581959    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1960        // Require a fully live session before exposing a settings mutation.  The1961        // epoch is captured with the read so a lock/new-session boundary cannot1962        // let stale work publish into the replacement session.1963        val epoch = requireUnlocked()1964        val prior = mutateCurrentSession(epoch) {1965            val old = _settings.value1966            _settings.value = settings1967            old1968        } ?: throw IllegalStateException("vault_locked")19691970        val committed = try {1971            beforeDurableCommit()1972            // The durable write is inside the same epoch fence as the in-memory1973            // publication.  If lock() won first, this returns null and no stale1974            // settings reach storage or the public StateFlow.1975            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true1976        } catch (_: Exception) {1977            false1978        }1979        if (!committed) {1980            // A live-session storage failure rolls back the public value.  A1981            // stale epoch is already reset by lock()/the new session and must1982            // not be allowed to overwrite that safe state.1983            mutateIfCurrentEpoch(epoch) { _settings.value = prior }1984            throw IllegalStateException("settings_write_failed")1985        }1986    }19871988    // ---- Recovery words (vault metadata) ----------------------------------19891990    /** True once the user has confirmed their recovery words for this vault. */1991    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }19921993    /**1994     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1995     * Persisted without bumping the version — recovery metadata is local-only and1996     * is not an entry mutation paired devices should sync against.1997     */1998    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1999        val epoch = requireUnlocked()2000        // H-03: hash + escrow-related state fields set under stateLock so a2001        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2002        // M-04: the flags have to be live before the escrows are built — both seal2003        // currentPayload() — so capture the prior pair and put it back if the write2004        // never lands. Otherwise the session reports words set against a slot A2005        // that still holds no escrow, and the recovery screen never offers a retry.2006        val prior = mutateCurrentSession(epoch) {2007            val priorPair = recoveryWordHash to recoveryWordsSet2008            recoveryWordHash = recoveryWordsHash(words)2009            recoveryWordsSet = true2010            priorPair2011        } ?: return@withLock false2012        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2013        // (cross-platform recovery of the sync root key) and Android's whole-payload2014        // escrow (offline forgot-password restore of entries). Both derive their key2015        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2016        val persisted = runCatching {2017            persistSlotAWithEscrows(2018                buildRecoveryEscrow(words),2019                buildRootKeyEscrow(words),2020                expectedEpoch = epoch,2021            )2022        }.getOrDefault(false)2023        if (!persisted) {2024            mutateIfCurrentEpoch(epoch) {2025                recoveryWordHash = prior.first2026                recoveryWordsSet = prior.second2027            }2028        }2029        persisted2030    }20312032    /** Verify entered words against the stored recovery hash. */2033    fun verifyRecoveryWords(words: List<String>): Boolean {2034        return mutateState {2035            val stored = recoveryWordHash ?: return@mutateState false2036            stored == recoveryWordsHash(words)2037        }2038    }20392040    // ---- Recovery escrow (forgot-password) --------------------------------20412042    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2043    fun hasRecoveryEscrow(): Boolean =2044        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20452046    /**2047     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2048     * the master password. On success the slot-A plaintext sealed in the escrow is2049     * adopted as the live session (entries, rootKey, settings), but no Argon2id2050     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2051     * new password and finish unlocking. Returns false when there is no escrow, the2052     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2053     *2054     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2055     * detached vault key to return here — slot A is wrapped under the rotating2056     * Argon2id key — so this opens the escrowed payload directly.2057     */2058    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2059        val expectedEpoch = currentEpoch()2060        val slotA = store.loadSlotA() ?: return false2061        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false20622063        // Prefer Android's whole-payload escrow: a full offline restore of entries,2064        // root key, and settings. Present for Android-created vaults.2065        val fullEscrow = slotA.recoveryEscrow2066        if (fullEscrow != null) {2067            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2068            if (material != null) {2069                var keyBytes: ByteArray? = null2070                var plaintext: ByteArray? = null2071                var candidateRoot: ByteArray? = null2072                var stateCommitStarted = false2073                try {2074                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2075                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2076                    val payload = VaultCodec.decode(plaintext)2077                    // Stage every fallible operation before touching live session2078                    // fields.  In particular, a settings/provider/decode failure2079                    // cannot leave entries, flags, or a root key resident while the2080                    // public session remains locked.2081                    val candidateSettings = store.loadSettings()2082                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2083                        val decodedRoot = unb64(payload.rootKeyB64)2084                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2085                            // Do not let a malformed authenticated payload abandon2086                            // this decoded root-key candidate before the outer2087                            // finally block owns it.2088                            VaultCrypto.zero(decodedRoot)2089                            throw IllegalArgumentException("invalid recovery root key")2090                        }2091                        decodedRoot2092                    } else {2093                        generateRecoverableRootKey()2094                    }2095                    stateCommitStarted = true2096                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2097                        clearSessionStateLocked()2098                        entryList = payload.entries.toMutableList()2099                        duressConfigured = payload.duressConfigured2100                        recoveryWordHash = payload.recoveryWordHash2101                        recoveryWordsSet = payload.recoveryWordsSet2102                        rootKey = candidateRoot2103                        candidateRoot = null2104                        _settings.value = candidateSettings2105                        publish()2106                        // This is the authenticated recovery half-unlock boundary:2107                        // only set it after every candidate field is installed.2108                        sessionDataAvailable = true2109                        _isUnlocked.value = false2110                        true2111                    }2112                    if (adopted == true) return@withLock true2113                } catch (_: Exception) {2114                    // Wrong words, malformed payloads, provider failures, and2115                    // settings failures all fall through to the browser-compatible2116                    // root-key escrow.  If the state commit had started, invalidate2117                    // that epoch so no partial candidate survives.2118                    if (stateCommitStarted) {2119                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2120                    }2121                } finally {2122                    plaintext?.let(VaultCrypto::zero)2123                    VaultCrypto.zero(material)2124                    keyBytes?.let(VaultCrypto::zero)2125                    candidateRoot?.let(VaultCrypto::zero)2126                }2127            }2128        }21292130        // Browser-format vault (only the root-key escrow): recover the 32-byte root2131        // key (sync identity) and reset the password; entries return via sync. This2132        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2133        val rootEscrow = slotA.rootKeyEscrow2134        if (rootEscrow != null) {2135            var candidateRoot: ByteArray? = null2136            var stateCommitStarted = false2137            try {2138                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2139                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2140                val candidateSettings = store.loadSettings()2141                stateCommitStarted = true2142                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2143                    clearSessionStateLocked()2144                    entryList = mutableListOf()2145                    duressConfigured = false2146                    recoveryWordHash = recoveryWordsHash(words)2147                    recoveryWordsSet = true2148                    rootKey = candidateRoot2149                    candidateRoot = null2150                    _settings.value = candidateSettings2151                    publish()2152                    sessionDataAvailable = true2153                    _isUnlocked.value = false2154                    true2155                }2156                if (adopted == true) return@withLock true2157            } catch (_: Exception) {2158                if (stateCommitStarted) {2159                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2160                }2161            } finally {2162                candidateRoot?.let(VaultCrypto::zero)2163            }2164        }2165        return false2166    }21672168    /**2169     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2170     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2171     * words, mark the session fully unlocked, and publish. Returns false if called2172     * without a prior successful recovery unlock (no in-memory root key).2173     *2174     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2175     * into one step, since there is no detached vault key here.2176     */2177    suspend fun resetPasswordWithRecoveryWords(2178        words: List<String>,2179        newPassword: String,2180    ): Boolean = mutex.withLock {2181        val expectedEpoch = currentEpoch()2182        // H-03: read rootKey + entryList under stateLock before computing the new2183        // plaintext, then re-publish + swap the derived key under stateLock so a2184        // concurrent lock() cannot straddle the two fences.2185        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2186        val priorA = store.loadSlotA() ?: return false2187        var newKey: ByteArray? = null2188        var plaintext: ByteArray? = null2189        return try {2190            val newSalt = VaultCrypto.generateSalt()2191            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2192            plaintext = VaultCodec.encode(currentPayload())2193            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2194            // Rebuild both escrows against the recovered session so their salts rotate2195            // with the password reset; fall back to the prior escrows if rebuild fails2196            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2197            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2198            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2199            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2200            val next = StoredVault(2201                version = priorA.version,2202                salt = b64(newSalt),2203                blob = blob,2204                recoveryEscrow = freshRecovery,2205                rootKeyEscrow = freshRootKey,2206            )2207            // H-01: see changePassword — both rotations share the same2208            // commit-or-keep-old-key contract so a partial write can't strand2209            // the session on a key that no longer matches disk.2210            val priorB = store.loadSlotB()2211            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2212            // H-03 (P1-2 codex): zero the old live key and install the new one2213            // inside the same stateLock critical section so a concurrent lock()2214            // can't observe the in-between.2215            if (mutateIfCurrentEpoch(expectedEpoch) {2216                derivedKey?.let(VaultCrypto::zero)2217                derivedKey = newKey; newKey = null2218                publish()2219                _isUnlocked.value = true2220            } == null) return false2221            true2222        } catch (_: Exception) {2223            false2224        } finally {2225            newKey?.let(VaultCrypto::zero)2226            plaintext?.let(VaultCrypto::zero)2227        }2228    }22292230    /**2231     * Seal the current slot-A plaintext under a key derived from the 12 BIP392232     * recovery [words] (Android offline-restore escrow). IKM is the browser's2233     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2234     * Returns null for a checksum-invalid phrase.2235     */2236    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2237        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2238        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2239        var keyBytes: ByteArray? = null2240        var plaintext: ByteArray? = null2241        return try {2242            val salt = VaultCrypto.generateSalt()2243            val recoveryKey = deriveRecoveryKey(material, salt)2244            keyBytes = recoveryKey.encoded2245            plaintext = VaultCodec.encode(currentPayload())2246            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2247            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2248        } finally {2249            VaultCrypto.zero(material)2250            keyBytes?.let(VaultCrypto::zero)2251            plaintext?.let(VaultCrypto::zero)2252        }2253    }22542255    /**2256     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2257     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2258     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2259     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2260     * the same root key on the browser. Returns null with no in-memory root key or2261     * a checksum-invalid phrase.2262     */2263    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2264        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2265        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2266        val rk = mutateState { rootKey?.copyOf() } ?: run {2267            VaultCrypto.zero(material)2268            return null2269        }2270        var keyBytes: ByteArray? = null2271        var plaintext: ByteArray? = null2272        return try {2273            val salt = VaultCrypto.generateSalt()2274            val recoveryKey = deriveRecoveryKey(material, salt)2275            keyBytes = recoveryKey.encoded2276            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2277            // combined = nonce(12) || ciphertext || tag(16)2278            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2279            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2280            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2281            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2282            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2283            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2284        } finally {2285            VaultCrypto.zero(rk)2286            VaultCrypto.zero(material)2287            keyBytes?.let(VaultCrypto::zero)2288            plaintext?.let(VaultCrypto::zero)2289        }2290    }22912292    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2293    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2294        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2295        var keyBytes: ByteArray? = null2296        var pt: ByteArray? = null2297        return try {2298            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2299            keyBytes = recoveryKey.encoded2300            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2301            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2302            hexToRootKey(String(pt, Charsets.US_ASCII))2303        } catch (_: Exception) {2304            null2305        } finally {2306            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2307            // IKM-equivalent of the vault identity. material+keyBytes were2308            // already zeroed; pt was leaking.2309            VaultCrypto.zero(material)2310            keyBytes?.let(VaultCrypto::zero)2311            pt?.let(VaultCrypto::zero)2312        }2313    }23142315    /**2316     * Re-seal slot A from the current in-memory state under the live key, carrying2317     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2318     * in decoy mode (recovery is never offered for the decoy) and persists without2319     * a version bump, like the recovery-words metadata write it replaces.2320     */2321    private fun persistSlotAWithEscrows(2322        recoveryEscrow: RecoveryEscrow?,2323        rootKeyEscrow: RootKeyEscrow?,2324        expectedEpoch: Long? = null,2325    ): Boolean {2326        val snapshot = snapshotForPersist(expectedEpoch)2327            ?: throw IllegalStateException("vault_locked_or_stale")2328        if (snapshot.decoy) {2329            VaultCrypto.zero(snapshot.key)2330            return false2331        }2332        try {2333            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2334            val plaintext = VaultCodec.encode(snapshot.payload)2335            val blob = try {2336                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2337            } finally {2338                // The escrow path used to drop this reference before zeroing it;2339                // keep it live through the fill on both success and failure.2340                VaultCrypto.zero(plaintext)2341            }2342            val next = prior.copy(2343                blob = blob,2344                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2345                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2346            )2347            beforeDurableCommit()2348            return withEpochCommit(snapshot.epoch) {2349                if (!store.writeSlotA(next)) return@withEpochCommit false2350                try {2351                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2352                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2353                    // the duress slot too. A real decoy is left alone; a fake is2354                    // re-sized to slot A's new ciphertext length.2355                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2356                } catch (e: Exception) {2357                    store.writeSlotA(prior)2358                    throw e2359                }2360                true2361            } ?: false2362        } finally {2363            VaultCrypto.zero(snapshot.key)2364        }2365    }23662367    // ---- Internals --------------------------------------------------------23682369    private fun requireUnlocked(): Long {2370        // H-03: read derivedKey atomically with [lock]'s reset; without it,2371        // [lock] from a background lifecycle event can run between this read2372        // and the caller's first state touch.2373        stateLock.lock()2374        try {2375            if (derivedKey == null) throw IllegalStateException("vault_locked")2376            return sessionEpoch2377        } finally {2378            stateLock.unlock()2379        }2380    }23812382    /** A locked or half-adopted session must not expose secret-bearing fields. */2383    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null23842385    private fun countDuplicateEntriesLocked(): Int {2386        val seen = mutableMapOf<String, VaultEntry>()2387        var duplicates = 02388        for (entry in entryList.filter { it.deletedAt == null }) {2389            val key = entry.duplicateKey()2390            val existing = seen[key]2391            if (existing == null) {2392                seen[key] = entry2393            } else {2394                duplicates++2395                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2396                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2397                if (entryStamp > existingStamp) seen[key] = entry2398            }2399        }2400        return duplicates2401    }24022403    private fun VaultEntry.duplicateKey(): String {2404        if (type == "note") return "note|${title.lowercase()}"2405        val passkeyData = passkey2406        if (passkeyData != null) {2407            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2408        }2409        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2410    }24112412    /**2413     * Capture the current slot pair (and metadata) into the history list.2414     * Returns true when the history write landed, false when it didn't — callers2415     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2416     * must check the result and roll back if it does not.2417     */2418    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2419        val epoch = expectedEpoch ?: currentEpoch()2420        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2421        val slotA = store.loadSlotA() ?: return false2422        val vaultA = VaultCodec.encodeSlot(slotA)2423        val vaultB = store.loadSlotB() ?: slotA.blob2424        // H-04: capture the duress slot so a restore preserves the at-rest2425        // triad, not just the A/B pair. The duress slot is always present2426        // (real or fake), so loadDecoySlot() returning null is a logic error2427        // — fall through to a fake duress in that case rather than silently2428        // losing the record from the snapshot.2429        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2430            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162431            val fakeBlob = ByteArray(target).also(rng::nextBytes)2432            val fakeSalt = VaultCrypto.generateSalt()2433            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2434        }2435        val history = store.loadVaultHistory()2436        val latest = history.snapshots.firstOrNull()2437        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2438        val entryCount = mutateIfCurrentEpoch(epoch) {2439            if (derivedKey != null && !decoyMode) {2440                bucketEntryCount(entryList.count { it.deletedAt == null })2441            } else {2442                null2443            }2444        } ?: return false2445        return try {2446            val snapshot = VaultSnapshot(2447                id = UUID.randomUUID().toString(),2448                savedAt = System.currentTimeMillis(),2449                reason = reason,2450                vault_a = vaultA,2451                vault_b = vaultB,2452                vault_d = vaultD,2453                entryCount = entryCount,2454            )2455            // P1-4 (codex): return the history-write boolean to callers so a2456            // false return no longer leaves the snapshot out of history while2457            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2458            withEpochCommit(epoch) {2459                store.writeVaultHistory(2460                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2461                )2462            } ?: false2463        } catch (_: Exception) {2464            false2465        }2466    }24672468    private fun bucketEntryCount(n: Int): Int = when {2469        n <= 0 -> 02470        n <= 10 -> 102471        n <= 25 -> 252472        n <= 50 -> 502473        n <= 100 -> 1002474        n <= 250 -> 2502475        n <= 500 -> 5002476        else -> ((n + 499) / 500) * 5002477    }24782479    /**2480     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2481     * so it is stored explicitly and survives the sync wire format rather than being2482     * re-derived at render time. Non-card entries pass through unchanged.2483     *2484     * M-03: never overwrite a stored last4 with null. A card kept only as last42485     * (e.g. after a PeachScript import that had no PAN) must keep that value;2486     * re-deriving from an absent cardNumber would silently zero the field.2487     */2488    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2489        if (!entry.isCard()) return entry2490        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2491        val derived = if (digits.length >= 4) digits.takeLast(4) else null2492        return entry.copy(last4 = derived ?: entry.last4)2493    }24942495    /**2496     * On unlock, hard-delete trashed entries older than the retention window.2497     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2498     * (the app's "Never" option), so nothing is purged. Persists without a2499     * version bump and without touching the publish state (caller publishes).2500     */2501    private fun autoPurgeExpiredTrash() {2502        val days = _settings.value.trashRetentionDays ?: return2503        if (days <= 0) return2504        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2505        val before = entryList.size2506        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2507        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2508    }25092510    /** The slot-A plaintext envelope for the current in-memory session. */2511    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25122513    private fun currentPayloadLocked(): VaultPayload =2514        VaultPayload(2515            entries = entryList.toList(),2516            duressConfigured = duressConfigured,2517            rootKeyB64 = rootKey?.let(::b64) ?: "",2518            recoveryWordHash = recoveryWordHash,2519            recoveryWordsSet = recoveryWordsSet,2520        )25212522    /**2523     * Adopt the payload's root key, lazily minting one for legacy payloads.2524     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2525     * a root key is not a mutation paired devices should sync against.2526     */2527    private fun ensureRootKey(payload: VaultPayload) {2528        if (payload.rootKeyB64.isNotBlank()) {2529            rootKey = unb64(payload.rootKeyB64)2530            return2531        }2532        rootKey = generateRecoverableRootKey()2533        runCatching { persistSlotA(bumpVersion = false) }2534    }25352536    private fun publish() {2537        _entries.value = entryList.filter { it.deletedAt == null }2538    }25392540    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2541        var plaintext = VaultCodec.encode(currentPayload())2542        return try {2543            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2544            StoredVault(version = 1, salt = b64(salt), blob = blob)2545        } finally {2546            // M-05: full vault plaintext is allocated here once per createVault.2547            // Zeroing in finally keeps the transient copy off the heap after the2548            // caller either succeeds or rolls back via lock().2549            VaultCrypto.zero(plaintext)2550        }2551    }25522553    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2554        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2555        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2556        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2557        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2558        val noise = ByteArray(approxBytes).also(rng::nextBytes)2559        return store.writeSlotB(b64(noise))2560    }25612562    /** H-04: the ciphertext length slot A is using right now, so the duress2563     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2564     *  length equals ciphertext length, so this also matches slot A's plaintext. */2565    private fun matchingCiphertextLength(slotA: StoredVault): Int =2566        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)25672568    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2569     *  bytes. Replaces a real decoy when the user clears duress, and is the2570     *  initial duress slot when no decoy is configured — so the record's2571     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2572     *  matches slot A's blob length. */2573    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2574        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162575        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2576        val fakeSalt = VaultCrypto.generateSalt()2577        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2578    }25792580    /** H-04: keep the duress record presence- and size-indistinguishable from2581     *  slot A on every persist. A real decoy is left alone (it was padded at2582     *  setDuressPassphrase time, and we don't have the duress key here to2583     *  re-pad on entry growth — that's an accepted residual tell, see the2584     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2585     *  A's size. */2586    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2587        val isConfigured = mutateState { duressConfigured }2588        return if (isConfigured) true else writeFakeDuressSlot(slotA)2589    }25902591    /**2592     * H-04: convert a browser-shaped duress slot to Android StoredVault2593     * without decrypting. The browser seals slot "b" under the duress2594     * passphrase; decrypting with the main password silently drops it.2595     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2596     * the salt from hex to base64.2597     */2598    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2599        val salt = unhex(b.salt)2600        val nonce = unhex(b.nonce)2601        val ct = unhex(b.ciphertext)2602        val tag = unhex(b.tag)2603        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2604            System.arraycopy(nonce, 0, it, 0, nonce.size)2605            System.arraycopy(ct, 0, it, nonce.size, ct.size)2606            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2607        }2608        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2609    }26102611    /** Re-seal slot A from the current in-memory state under the live key. */2612    private fun persistSlotA(2613        bumpVersion: Boolean,2614        snapshotReason: String? = "local_save",2615        expectedEpoch: Long? = null,2616    ): Boolean {2617        val snapshot = snapshotForPersist(expectedEpoch)2618            ?: throw IllegalStateException("vault_locked_or_stale")2619        return try {2620            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2621            if (snapshot.decoy) {2622                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2623                val plaintext = VaultCodec.encode(snapshot.payload)2624                val blob = try {2625                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2626                } finally {2627                    // Keep the last reference alive until the zero, including the2628                    // successful encrypt path (M-05).2629                    VaultCrypto.zero(plaintext)2630                }2631                beforeDurableCommit()2632                return withEpochCommit(snapshot.epoch) {2633                    store.writeDecoySlot(prior.copy(blob = blob))2634                } ?: false2635            }2636            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2637            // H-01: captured BEFORE the noise write so a stamp failure can restore2638            // the prior slot B alongside the prior slot A. Slot B is always2639            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2640            // leaves (new A, new B, old version) on disk — partial commit.2641            val priorB = store.loadSlotB()2642            beforeDurableCommit()2643            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2644            val plaintext = VaultCodec.encode(snapshot.payload)2645            val blob = try {2646                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2647            } finally {2648                // Do not drop [plaintext] before fill(0): the serialized vault2649                // contains every entry password and is the primary M-05 target.2650                VaultCrypto.zero(plaintext)2651            }2652            val next = prior.copy(blob = blob)2653            // The epoch check and the entire A/B/D/version commit share one brief2654            // stateLock critical section. lock() either wins before this block or2655            // waits until the already-committed write is complete; it can never2656            // clear the session between the check and the durable write.2657            withEpochCommit(snapshot.epoch) {2658                if (!store.writeSlotA(next)) return@withEpochCommit false2659                try {2660                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2661                    // H-04: duress slot stays present (real one untouched, fake one2662                    // re-padded to slot A's current size on every persist).2663                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2664                    if (bumpVersion) store.stampLocalMutation()2665                } catch (e: Exception) {2666                    store.writeSlotA(prior)2667                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2668                    throw e2669                }2670                true2671            } ?: false2672        } finally {2673            // Snapshot keys are copies, so lock() may zero the live key while this2674            // operation is still deriving/encrypting; always scrub our copy too.2675            VaultCrypto.zero(snapshot.key)2676        }2677    }26782679    /**2680     * H-01: durably commit a slot-A rotation by writing slot A, its2681     * size-matched noise slot B, and the local-mutation stamp in order. Any2682     * failure rolls the previously-committed steps back so the at-rest pair2683     * matches what was there before the call. Returns true only when every2684     * step succeeded — callers must NOT swap their session key on false.2685     */2686    private fun commitVaultRotation(2687        priorA: StoredVault,2688        priorB: String?,2689        nextA: StoredVault,2690        expectedEpoch: Long,2691    ): Boolean {2692        beforeDurableCommit()2693        return withEpochCommit(expectedEpoch) {2694            if (!store.writeSlotA(nextA)) return@withEpochCommit false2695            // P1-1 (codex): the noise write's boolean AND any thrown exception from2696            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2697            // escaped the helper and left slot A committed.2698            try {2699                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2700                // H-04: a password rotation changes slot A's plaintext size (new2701                // salt → re-encrypted blob), so the duress slot must be re-padded2702                // alongside. writeCompanionDuressSlot leaves a real decoy alone2703                // and re-emits a fake of slot A's size otherwise.2704                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2705                store.stampLocalMutation()2706            } catch (_: Exception) {2707                rollbackRotation(priorA, priorB)2708                return@withEpochCommit false2709            }2710            true2711        } ?: false2712    }27132714    /**2715     * Best-effort undo of the writes [commitVaultRotation] may have committed.2716     * Each step runs independently — a failed rollback must not mask the2717     * original commit failure. Log is a throwing stub off-device.2718     */2719    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2720        val failures = mutableListOf<String>()2721        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2722            .onFailure { failures += "writeSlotA: $it" }2723        if (priorB != null) {2724            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2725                .onFailure { failures += "writeSlotB: $it" }2726        }2727        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2728    }27292730    /** Persist a local mutation, rolling the in-memory list back on failure. */2731    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2732        try {2733            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2734                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2735                throw IllegalStateException("storage_write_failed")2736            }2737        } catch (e: Exception) {2738            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2739            throw e2740        }2741        // A lock can win in the small interval after the durable commit and2742        // before publication. The epoch check prevents pre-lock entries from2743        // being republished into a newly locked (or newly unlocked) session.2744        mutateIfCurrentEpoch(expectedEpoch) { publish() }2745    }2746}