VaultRepository.kt

129.3 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    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */990    suspend fun clearDuress(): Boolean = mutex.withLock {991        // H-04: presence-indistinguishable — replace the real decoy with a fake992        // blob of slot A's size rather than removing the duress record. The993        // record stays on disk at the same length, so an at-rest observer994        // cannot tell "duress cleared" from "no duress ever configured".995        val expectedEpoch = currentEpoch()996        val replaced = withEpochCommit(expectedEpoch) {997            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }998                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)999        } ?: false1000        if (!replaced) return@withLock false1001        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1002        // duressConfigured write so a concurrent lock() cannot land between them.1003        if (!isSessionEpochCurrent(expectedEpoch)) return false1004        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1005        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1006            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1007        }1008        true1009    }10101011    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1012    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1013        var current: Throwable? = error1014        while (current != null) {1015            if (current is javax.crypto.AEADBadTagException) return true1016            // Some Android providers expose a GCM tag failure as the parent1017            // BadPaddingException rather than the AEAD-specific subtype.  A1018            // short ciphertext is an IllegalBlockSizeException and therefore1019            // remains an internal/malformed-payload Error below.1020            if (current is javax.crypto.BadPaddingException) return true1021            current = current.cause1022        }1023        return false1024    }10251026    /** Unlock with an already-derived AES-256 key (biometric path). */1027    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1028        val expectedEpoch = currentEpoch()1029        val slotA = try {1030            store.loadSlotA()1031        } catch (_: Exception) {1032            return@withLock UnlockResult.Error("unlock_key_storage_failed")1033        } ?: return@withLock UnlockResult.Error("no_vault")1034        // Keep the caller-owned key separate from the staged session key. The1035        // biometric caller always zeroes its input in a finally block; adoption1036        // must therefore own a copy and use the same all-or-nothing path as a1037        // password unlock rather than publishing a partially decoded session.1038        var stagedKey: ByteArray? = key.copyOf()1039        return try {1040            val encrypted = try {1041                unb64(slotA.blob)1042            } catch (_: Exception) {1043                return@withLock UnlockResult.Error("invalid_vault_blob")1044            }1045            // A GCM record must include nonce + authentication tag.  Providers1046            // differ on whether a shorter record is reported as BadPadding or1047            // AEADBadTag; reject it before decrypt so malformed storage cannot be1048            // misreported as a user-entered wrong key.1049            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1050                return@withLock UnlockResult.Error("invalid_vault_blob")1051            }1052            val plaintext = try {1053                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1054            } catch (e: Exception) {1055                if (isGcmAuthenticationFailure(e)) {1056                    return@withLock UnlockResult.WrongPassword1057                }1058                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1059            }1060            val payload = try {1061                VaultCodec.decode(plaintext)1062            } catch (_: Exception) {1063                return@withLock UnlockResult.Error("invalid_vault_payload")1064            } finally {1065                VaultCrypto.zero(plaintext)1066            }1067            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1068            val result = adoptSlotA(candidate, payload, expectedEpoch)1069            if (result is UnlockResult.Success) stagedKey = null1070            result1071        } finally {1072            stagedKey?.let(VaultCrypto::zero)1073        }1074    }10751076    fun lock() {1077        // H-03: must observe derivedKey/entryList atomically with any in-flight1078        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1079        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1080        // guards just the brief state reset. Advancing the epoch first invalidates1081        // every operation that captured this session before it reaches a later1082        // durable commit or publication.1083        stateLock.lock()1084        try {1085            sessionEpoch = nextSessionEpoch(sessionEpoch)1086            clearSessionStateLocked()1087        } finally {1088            stateLock.unlock()1089        }1090    }10911092    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L10931094    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1095    // is a stub off-device).1096    @VisibleForTesting1097    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }10981099    /**1100     * Call immediately before launching a system Activity (a SAF file1101     * picker/save dialog, a share sheet) that briefly takes the whole process1102     * out of the foreground as an expected part of an in-app flow — not a1103     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1104     * two apart on its own (it fires for both), and without this, exporting or1105     * importing a file silently locked and wiped the vault mid-flow the moment1106     * the picker took over, before its result ever reached the app.1107     *1108     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1109     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1110     */1111    fun expectTransientBackground() {1112        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1113    }11141115    /**1116     * Consumed once by the next background-lock check; see [expectTransientBackground].1117     * Returns false (and clears the flag) if the suppression has expired or was1118     * never set — a genuine background event must always lock.1119     */1120    fun consumeTransientBackgroundSuppression(): Boolean {1121        val until = suppressNextBackgroundLockUntil1122        val active = until != 0L && nowMs() < until1123        suppressNextBackgroundLockUntil = 0L1124        return active1125    }11261127    // ---- Sync identity ----------------------------------------------------11281129    /**1130     * The vault's stable sync master key (a copy). Caller must zero it. Backs1131     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1132     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1133     */1134    /**1135     * HOLE 5 — the duress vault never syncs.1136     *1137     * [commitDuressUnlock] gives the decoy session a root key so its own codex1138     * and recovery paths work, which means (unlike the browser, where the decoy1139     * path simply has no root key) the sync surface has to refuse explicitly.1140     * Without this the decoy vault would present a second, different vault_id1141     * to a paired device, and a coerced user opening it would make sync visibly1142     * fail on their other devices — a duress indicator, which is the one thing1143     * a duress vault must never be.1144     */1145    // M-04: the "null when locked" half of the contract was documented but never1146    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1147    // [unlockWithRecoveryWords], which installs a root key with no password key1148    // yet) and in a create that failed after staging. Sync must fail closed1149    // there, so the authoritative unlocked flag gates it, not just decoyMode.1150    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)11511152    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1153        if (decoyMode || !_isUnlocked.value) null1154        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1155    }11561157    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1158        mutateState { sessionEpoch == epoch }11591160    internal fun unlockedSessionEpoch(): Long? = mutateState {1161        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1162    }11631164    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key11651166    /**1167     * The current session's root key, decoy included. For local-only uses that1168     * must behave identically in a decoy session — Codex backup and recovery1169     * words — because a feature that errors under duress is itself a duress1170     * tell. In a decoy session this is the DECOY's root key (see1171     * [commitDuressUnlock]), never the real vault's, so neither surface can1172     * leak the real vault. Anything that leaves the device must use1173     * [syncMasterKey] instead.1174     */1175    fun sessionRootKey(): ByteArray? = mutateState {1176        if (!sessionDataAvailable) null else rootKey?.copyOf()1177    }11781179    /** Stable per-vault id two paired devices must share to sync. Null when1180     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1181    fun syncVaultId(): String? = mutateState {1182        if (decoyMode || !_isUnlocked.value) null1183        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1184    }11851186    /**1187     * Browser parity (MV-8): rotate the sync identity by replacing the stable1188     * root key. Entries remain under the master-password key, while sync1189     * credentials, vault_id, and recovery words all change.1190     */1191    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1192        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1193            ?: return false1194        installRootKey(generateRecoverableRootKey(), expectedEpoch)1195    }11961197    /**1198     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1199     * keeping every local entry.1200     *1201     * Sync identity (vault id, signing keys) derives from the root key, and1202     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1203     * created vaults can never reconcile. Taking the other side's root key is1204     * what makes them one vault; from the next sync on, both entry sets merge.1205     *1206     * Entries survive because they are sealed under the password-derived key,1207     * not the root key — the same property [rotateSyncIdentity] relies on.1208     *1209     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1210     * this device's previous recovery words stop working for good, and its new1211     * words are the source vault's words, because words are a deterministic1212     * encoding of the root key.1213     */1214    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1215        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1216            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1217        }1218        val expectedEpoch = mutateState {1219            when {1220                derivedKey == null -> null1221                decoyMode -> Long.MIN_VALUE1222                else -> sessionEpoch1223            }1224        }1225        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1226        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1227        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")12281229        val incoming = hexToRootKey(rootKeyHex)1230        if (incoming.size != VaultCrypto.KEY_SIZE) {1231            VaultCrypto.zero(incoming)1232            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1233        }1234        // Refuse before committing rather than leave this device holding a vault1235        // whose recovery words cannot be typed back. generateRecoverableRootKey1236        // screens its own keys for this; a key arriving from a peer has not been.1237        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1238        if (!roundTrips) {1239            VaultCrypto.zero(incoming)1240            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1241        }12421243        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1244        return if (installRootKey(incoming, expectedEpoch)) {1245            AdoptVaultResult.Success(vaultId)1246        } else {1247            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1248        }1249    }12501251    /**1252     * The recovery words [rootKeyHex] will produce once joined, or null if it is1253     * not a usable root key. Lets the join flow show the user the words that are1254     * about to replace theirs *before* anything is committed.1255     */1256    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1257        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1258        val bytes = hexToRootKey(rootKeyHex)1259        return try {1260            val words = rootKeyToWords(bytes)1261            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1262        } catch (_: Exception) {1263            null1264        } finally {1265            VaultCrypto.zero(bytes)1266        }1267    }12681269    /**1270     * Swap the vault's root key, keeping every entry, and re-escrow under the1271     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1272     * [mutex].1273     */1274    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1275        // H-03: snapshot the live fields under stateLock so the success and the1276        // rollback paths both see consistent prior/new state. Writing the new1277        // rootKey/recovery fields is inside its own mutateState at the end.1278        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1279        // — holding live references would let a zero-on-failure operation also1280        // wipe the session's still-live key arrays.1281        data class Snapshot(1282            val key: ByteArray,1283            val priorRootKey: ByteArray,1284            val priorRecoveryHash: String?,1285            val priorRecoverySet: Boolean,1286        )1287        val snap = mutateIfCurrentEpoch(expectedEpoch) {1288            val k = derivedKey ?: return@mutateIfCurrentEpoch null1289            if (decoyMode) return@mutateIfCurrentEpoch null1290            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1291            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1292        } ?: run {1293            VaultCrypto.zero(nextRootKey)1294            return false1295        }1296        val priorSlot = store.loadSlotA() ?: run {1297            VaultCrypto.zero(snap.key)1298            VaultCrypto.zero(snap.priorRootKey)1299            VaultCrypto.zero(nextRootKey)1300            return false1301        }1302        val nextWords = rootKeyToWords(nextRootKey)13031304        return try {1305            beforeDurableCommit()1306            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1307            if (mutateIfCurrentEpoch(expectedEpoch) {1308                rootKey = nextRootKey1309                recoveryWordHash = recoveryWordsHash(nextWords)1310                recoveryWordsSet = false1311            } == null) throw IllegalStateException("stale_root_rotation")13121313            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1314                ?: throw IllegalStateException("recovery_escrow_failed")1315            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1316                ?: throw IllegalStateException("root_escrow_failed")1317            val plaintext = VaultCodec.encode(currentPayload())1318            val blob = try {1319                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1320            } finally {1321                VaultCrypto.zero(plaintext)1322            }1323            val nextSlot = priorSlot.copy(1324                blob = blob,1325                recoveryEscrow = nextRecoveryEscrow,1326                rootKeyEscrow = nextRootKeyEscrow,1327            )1328            val committed = withEpochCommit(expectedEpoch) {1329                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1330                try {1331                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1332                    // H-04: a root-key rotation re-seals slot A, so the duress slot1333                    // (real or fake) must be re-padded to the new ciphertext length.1334                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1335                    store.stampLocalMutation()1336                } catch (_: Exception) {1337                    store.writeSlotA(priorSlot)1338                    return@withEpochCommit false1339                }1340                true1341            } ?: false1342            if (!committed) throw IllegalStateException("storage_write_failed")1343            mutateIfCurrentEpoch(expectedEpoch) { publish() }1344            true1345        } catch (_: Exception) {1346            val restored = mutateIfCurrentEpoch(expectedEpoch) {1347                rootKey?.let(VaultCrypto::zero)1348                rootKey = snap.priorRootKey.copyOf()1349                recoveryWordHash = snap.priorRecoveryHash1350                recoveryWordsSet = snap.priorRecoverySet1351            }1352            if (restored != null) {1353                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1354            }1355            false1356        } finally {1357            VaultCrypto.zero(snap.key)1358            VaultCrypto.zero(snap.priorRootKey)1359            // On success nextRootKey is owned by rootKey. On every other path1360            // it is not, including a stale epoch after lock(); scrub it while1361            // the local reference is still available.1362            val stillOwnedBySession = mutateState {1363                sessionEpoch == expectedEpoch && rootKey === nextRootKey1364            }1365            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1366        }1367    }13681369    /**1370     * A copy of the live Argon2id-derived vault key, for biometric escrow only1371     * (wrapped under a hardware KeyStore key on biometric enable). Null when1372     * locked. Caller must [VaultCrypto.zero] it after use.1373     */1374    fun currentVaultKey(): ByteArray? = mutateState {1375        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1376    }13771378    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1379        val expectedEpoch = currentEpoch()1380        val slotA = store.loadSlotA() ?: return false1381        var oldKey: ByteArray? = null1382        var newKey: ByteArray? = null1383        var plaintext: ByteArray? = null1384        return try {1385            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1386            // Keep ownership of the decrypted vault plaintext until decoding has1387            // finished.  Passing decrypt() inline made the byte array unreachable1388            // before the finally block, so a successful or failed password change1389            // could leave the full plaintext resident in the heap.1390            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1391            val payload = try {1392                VaultCodec.decode(decrypted)1393            } finally {1394                VaultCrypto.zero(decrypted)1395            }1396            val newSalt = VaultCrypto.generateSalt()1397            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1398            // Re-seal the existing payload under the new key/salt.1399            plaintext = VaultCodec.encode(payload)1400            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1401            // The escrow seals the slot-A PLAINTEXT, which a password change does not1402            // alter — only the wrapping changes — so carry it forward; rebuilding the1403            // StoredVault from scratch would silently drop it.1404            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1405            // content that a password change does not alter — only the slot-A1406            // wrapping rotates. The root-key escrow staying stable across password1407            // changes matches the browser (`vault.ts:363-364`).1408            val next = StoredVault(1409                version = slotA.version,1410                salt = b64(newSalt),1411                blob = blob,1412                recoveryEscrow = slotA.recoveryEscrow,1413                rootKeyEscrow = slotA.rootKeyEscrow,1414            )1415            // H-01: durably commit slot A + size-matched noise + version stamp1416            // before swapping the session key. A partial commit (new A on disk,1417            // old key in memory) would have the next save encrypt under the old1418            // key while matching disk on the new one.1419            val priorB = store.loadSlotB()1420            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1421            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1422            // inside the same stateLock critical section, so a concurrent lock()1423            // can't observe the in-between (zeroed but not yet reassigned).1424            if (mutateIfCurrentEpoch(expectedEpoch) {1425                oldKey?.let(VaultCrypto::zero); oldKey = null1426                derivedKey?.let(VaultCrypto::zero)1427                derivedKey = newKey; newKey = null1428            } == null) return false1429            true1430        } catch (_: Exception) {1431            false1432        } finally {1433            oldKey?.let(VaultCrypto::zero)1434            newKey?.let(VaultCrypto::zero)1435            plaintext?.let(VaultCrypto::zero)1436        }1437    }14381439    /**1440     * Irreversible wipe. Locks the session and clears every vault key. The Pro1441     * license is in a separate store and is preserved by construction.1442     */1443    suspend fun destroyVault() = mutex.withLock {1444        lock()1445        store.clearVault()1446        store.clearDecoySlot()1447    }14481449    // ---- Entry CRUD -------------------------------------------------------14501451    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1452        val epoch = requireUnlocked()1453        val stamp = VaultCrypto.monotonicEntryTimestamp()1454        val now = System.currentTimeMillis()1455        val entry = withDerivedLast4(draft.copy(1456            id = draft.id.ifBlank { UUID.randomUUID().toString() },1457            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1458            modifiedAt = stamp,1459            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1460        ))1461        // H-03: capture rollback AND write entryList under stateLock so a1462        // concurrent lock() cannot zero the session between the read and the write.1463        val rollback = mutateCurrentSession(epoch) {1464            val r = entryList.toMutableList()1465            entryList.add(entry)1466            r1467        } ?: throw IllegalStateException("vault_locked")1468        persistOrRollback(rollback, epoch)1469        entry1470    }14711472    /**1473     * Insert many entries with a single encrypt-and-write at the end.1474     *1475     * Importing a real vault through [addEntry] was quadratic: every entry took1476     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1477     * restore did 806 full-vault writes and hung the app long enough to ANR.1478     * Rolls back to the pre-import list if the one write fails, matching1479     * [addEntry]'s all-or-nothing behaviour.1480     */1481    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1482        val epoch = requireUnlocked()1483        if (drafts.isEmpty()) return 01484        val now = System.currentTimeMillis()1485        val ready = drafts.map { draft ->1486            val stamp = VaultCrypto.monotonicEntryTimestamp()1487            withDerivedLast4(1488                draft.copy(1489                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1490                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1491                    modifiedAt = stamp,1492                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1493                ),1494            )1495        }1496        // H-03: capture rollback AND append every entry under stateLock.1497        val rollback = mutateCurrentSession(epoch) {1498            val r = entryList.toMutableList()1499            entryList.addAll(ready)1500            r1501        } ?: throw IllegalStateException("vault_locked")1502        persistOrRollback(rollback, epoch)1503        return drafts.size1504    }15051506    /**1507     * M-01: replace the whole active set with [newEntries] in one write.1508     *1509     * Import's REPLACE mode used to soft-delete every current entry one durable1510     * write at a time (swallowing each failure) and then batch-add, so a failed1511     * add left the old entries stranded in Trash with nothing restored. Staging1512     * the entire next list in memory and committing it through1513     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1514     * pre-import list — active entries included — is restored.1515     *1516     * Existing tombstones are preserved so paired devices still see the1517     * deletions they already know about.1518     */1519    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1520        val epoch = requireUnlocked()1521        val now = System.currentTimeMillis()1522        val ready = newEntries.map { draft ->1523            val stamp = VaultCrypto.monotonicEntryTimestamp()1524            withDerivedLast4(1525                draft.copy(1526                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1527                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1528                    modifiedAt = stamp,1529                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1530                ),1531            )1532        }1533        // M-03: an incoming entry that shares an id with an existing live entry1534        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1535        // the old one and then appending the new one would leave the list with1536        // two entries of the same id — the tombstoned one would be returned by1537        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1538        // superseded entries from the tombstone set instead.1539        val incomingIds = ready.map { it.id }.toHashSet()1540        val rollback = mutateCurrentSession(epoch) {1541            val r = entryList.toMutableList()1542            val replaced = entryList.map { entry ->1543                if (entry.deletedAt != null) return@map entry1544                if (entry.id in incomingIds) return@map null1545                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1546                entry.copy(deletedAt = stamp, modifiedAt = stamp)1547            }.filterNotNull()1548            entryList = (replaced + ready).toMutableList()1549            r1550        } ?: throw IllegalStateException("vault_locked")1551        persistOrRollback(rollback, epoch)1552        return ready.size1553    }15541555    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1556        val epoch = requireUnlocked()1557        // H-03: read entryList, compute the patched entry, and write it under a1558        // single stateLock so a concurrent lock() cannot interleave between the1559        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1560        // list, or clobber the entry onto the wrong id after a relock).1561        val prepared = mutateCurrentSession(epoch) {1562            val idx = entryList.indexOfFirst { it.id == patch.id }1563            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1564            val current = entryList[idx]1565            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1566            // Only refresh passwordChangedAt when the password value actually changes.1567            val passwordChanged = patch.password != null && patch.password != current.password1568            // Capture the outgoing password (newest first) so the user can reveal/copy/1569            // restore it later. History is computed authoritatively from `current`, not1570            // the patch — updateEntry is a full-entry replace, so trusting patch's value1571            // would let a stale form clobber the real history. Record the age the old1572            // password carried, skip an empty prior, and skip an immediate exact dupe1573            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1574            val priorPassword = current.password1575            val shouldCapture = passwordChanged &&1576                !priorPassword.isNullOrEmpty() &&1577                current.passwordHistory?.firstOrNull()?.password != priorPassword1578            val nextHistory = if (shouldCapture) {1579                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1580                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1581            } else {1582                current.passwordHistory1583            }1584            val updated = withDerivedLast4(patch.copy(1585                createdAt = current.createdAt,1586                modifiedAt = stamp,1587                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1588                passwordHistory = nextHistory,1589            ))1590            val rollback = entryList.toMutableList()1591            entryList[idx] = updated1592            updated to rollback1593        } ?: throw IllegalStateException("vault_locked")1594        val (updated, rollback) = prepared1595        persistOrRollback(rollback, epoch)1596        updated1597    }15981599    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1600    suspend fun deleteEntry(id: String) = mutex.withLock {1601        val epoch = requireUnlocked()1602        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1603        // the list out from under the indexOfFirst.1604        val rollback = mutateCurrentSession(epoch) {1605            val idx = entryList.indexOfFirst { it.id == id }1606            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1607            val current = entryList[idx]1608            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1609            val prior = entryList.toMutableList()1610            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1611            prior1612        } ?: throw IllegalStateException("vault_locked")1613        persistOrRollback(rollback, epoch)1614    }16151616    suspend fun restoreEntry(id: String) = mutex.withLock {1617        val epoch = requireUnlocked()1618        val rollback = mutateCurrentSession(epoch) {1619            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1620            if (idx == -1) throw IllegalStateException("deleted entry not found")1621            val current = entryList[idx]1622            val prior = entryList.toMutableList()1623            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1624            prior1625        } ?: throw IllegalStateException("vault_locked")1626        persistOrRollback(rollback, epoch)1627    }16281629    /** Permanent delete — no tombstone, no recovery. */1630    suspend fun purgeEntry(id: String) = mutex.withLock {1631        val epoch = requireUnlocked()1632        val rollback = mutateCurrentSession(epoch) {1633            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1634            if (idx == -1) throw IllegalStateException("deleted entry not found")1635            val prior = entryList.toMutableList()1636            entryList.removeAt(idx)1637            prior1638        } ?: throw IllegalStateException("vault_locked")1639        persistOrRollback(rollback, epoch)1640    }16411642    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1643    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1644        val epoch = requireUnlocked()1645        // H-03: read+remove under stateLock so lock() can't reorder the remove.1646        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1647        val r = mutateCurrentSession(epoch) {1648            val rollback = entryList.toMutableList()1649            val before = entryList.size1650            entryList.removeAll { it.deletedAt != null }1651            R(rollback, before - entryList.size)1652        } ?: throw IllegalStateException("vault_locked")1653        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1654        r.removed1655    }16561657    /** Browser parity: number of active entries that dedupe would move to trash. */1658    suspend fun countDuplicateEntries(): Int = mutex.withLock {1659        val epoch = requireUnlocked()1660        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1661        // lock() can't race the read with state writes.1662        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1663            ?: throw IllegalStateException("vault_locked")1664    }16651666    /**1667     * Browser parity: soft-delete duplicate entries, keeping the most recently1668     * modified member of each group. Refuse in decoy mode so the duress vault is1669     * not reshaped into a forensically distinct state.1670     */1671    suspend fun deduplicateEntries(): Int = mutex.withLock {1672        val epoch = requireUnlocked()1673        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1674        val ready = mutateCurrentSession(epoch) {1675            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1676            val active = entryList.filter { it.deletedAt == null }1677            val keepIds = mutableSetOf<String>()1678            active.groupBy { it.duplicateKey() }.values.forEach { group ->1679                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1680            }1681            val rollback = entryList.toMutableList()1682            var removed = 01683            entryList = entryList.map { entry ->1684                if (entry.deletedAt == null && entry.id !in keepIds) {1685                    removed++1686                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1687                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1688                } else {1689                    entry1690                }1691            }.toMutableList()1692            R(rollback, removed)1693        } ?: throw IllegalStateException("vault_locked")1694        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1695        ready.removed1696    }16971698    fun getEntries(): List<VaultEntry> = mutateState {1699        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1700    }1701    fun getEntry(id: String): VaultEntry? = mutateState {1702        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1703    }1704    fun getDeletedEntries(): List<VaultEntry> = mutateState {1705        if (!sessionDataAvailable) emptyList()1706        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1707    }17081709    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1710    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1711        return mutateState {1712            if (!sessionDataAvailable) return@mutateState emptyList()1713            val active = entryList.filter { it.deletedAt == null }1714            if (tagFilter == null) return@mutateState active1715            val needle = tagFilter.trim().lowercase()1716            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1717        }1718    }17191720    /** Sorted, de-duplicated tags across all active entries. */1721    fun getDistinctTags(): List<String> = mutateState {1722        if (!sessionDataAvailable) return@mutateState emptyList()1723        entryList.asSequence()1724            .filter { it.deletedAt == null }1725            .flatMap { it.tags?.asSequence() ?: emptySequence() }1726            .map { it.trim().lowercase() }1727            .filter { it.isNotEmpty() }1728            .distinct()1729            .sorted()1730            .toList()1731    }17321733    /**1734     * Distinct non-blank values for an identity field across active entries,1735     * most-frequently-used first. Supports "username" and "email"; unknown field1736     * names return an empty list. Values keep original casing (deduped1737     * case-insensitively) so a suggestion matches what the user typed.1738     */1739    fun getDistinctFieldValues(field: String): List<String> {1740        val select: (VaultEntry) -> String? = when (field) {1741            "username" -> { e -> e.username }1742            "email" -> { e -> e.email }1743            else -> return emptyList()1744        }1745        return mutateState {1746            if (!sessionDataAvailable) return@mutateState emptyList()1747            entryList.asSequence()1748                .filter { it.deletedAt == null }1749                .mapNotNull { select(it)?.trim() }1750                .filter { it.isNotEmpty() }1751                .groupingBy { it.lowercase() }1752                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1753                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1754                }1755                .values1756                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1757                .map { it.first }1758        }1759    }17601761    fun getTombstones(): List<Tombstone> = mutateState {1762        if (!sessionDataAvailable) emptyList()1763        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1764    }1765    fun getVaultVersion(): Long = store.version()1766    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots17671768    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1769        val expectedEpoch = currentEpoch()1770        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1771        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1772        // H-04: snapshots now carry the duress slot too — restore preserves1773        // the at-rest triad (A/B/D), not just the A/B pair.1774        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1775        val priorA = store.loadSlotA()1776        val priorB = store.loadSlotB()1777        val priorD = store.loadDecoySlot()1778        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1779        // slot write — the old code took this snapshot, then stranded it on1780        // every failure, polluting history with a "restore" entry that never1781        // restored anything. The history below is rolled back together with1782        // the slots if the commit fails.1783        val priorHistory = store.loadVaultHistory()1784        // P1-4 (codex): the recovery snapshot must land before we commit the1785        // slot pair, otherwise a failed restore cannot be rolled back to the1786        // pre-restore history. If the history write itself fails, refuse to1787        // proceed — the alternative is a successful restore with no undo path.1788        beforeDurableCommit()1789        if (!takeVaultSnapshot("restore", expectedEpoch)) {1790            return false1791        }1792        // H-04: legacy snapshots without vault_d, or vault_d that fails to1793        // decode, fall back to a fake duress of slot A's size — presence-1794        // indistinguishable from any other freshly-restored vault.1795        val nextD: StoredVault = restoredD ?: run {1796            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161797            val fakeBlob = ByteArray(target).also(rng::nextBytes)1798            val fakeSalt = VaultCrypto.generateSalt()1799            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1800        }1801        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1802            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1803            return false1804        }1805        lock()1806        true1807    }18081809    /**1810     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1811     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1812     * (a restore preserves the duress-shaped blob from history), not a fresh1813     * noise record. Any failure rolls back so the at-rest pair never holds a1814     * restored slot A against a pre-restore slot B. Caller is responsible for1815     * restoring the pre-snapshot history on a false return.1816     *1817     * H-04: duress slot is now part of the atomic triad — restored last so1818     * a duress-write failure rolls the A/B pair back too.1819     */1820    private fun commitRestoreAtomic(1821        nextA: StoredVault,1822        nextB: String,1823        nextD: StoredVault,1824        priorA: StoredVault?,1825        priorB: String?,1826        priorD: StoredVault?,1827        expectedEpoch: Long,1828    ): Boolean {1829        return withEpochCommit(expectedEpoch) {1830            if (!store.writeSlotA(nextA)) return@withEpochCommit false1831            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1832            // must trigger rollback; previously a throw escaped without restoring1833            // the prior slot A and history.1834            try {1835                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1836                // H-04: duress slot is part of the restore triad. Failures here1837                // roll A+B back so the at-rest state stays consistent.1838                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1839                store.stampLocalMutation()1840            } catch (_: Exception) {1841                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1842                return@withEpochCommit false1843            }1844            true1845        } ?: false1846    }18471848    // ---- Sync apply (does NOT advance the version) ------------------------18491850    /**1851     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1852     * WITHOUT bumping the version counter, so a sync round cannot cascade into1853     * another outbound sync. Returns the number of locally-applied changes.1854     */1855    suspend fun applyMergedRemoteEntries(1856        remoteEntries: List<VaultEntry>,1857        remoteTombstones: List<Tombstone>,1858        expectedEpoch: Long? = null,1859    ): Int = mutex.withLock {1860        val epoch = expectedEpoch ?: requireUnlocked()1861        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01862        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1863        // BEFORE any comparison against a local one. Without this a peer can1864        // send a far-future modifiedAt and permanently pin its version of an1865        // entry against every honest local edit. The browser clamps in the1866        // matching place (vault.ts applyMergedRemoteEntries).1867        val mergeNow = System.currentTimeMillis()1868        val clamped = remoteEntries.map { entry ->1869            entry.copy(1870                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1871                deletedAt = entry.deletedAt?.let {1872                    SyncLimits.clampSyncTimestamp(it, mergeNow)1873                },1874            )1875        }1876        val tombMap = remoteTombstones.associate {1877            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1878        }1879        // H-03: build the new entryList under stateLock so a concurrent lock()1880        // cannot zero derivedKey/entryList between the byId read and the merge.1881        val applied = mutateCurrentSession(epoch) {1882            var n = 01883            val byId = entryList.associateBy { it.id }.toMutableMap()18841885            for (remote in clamped) {1886                val tomb = tombMap[remote.id]1887                if (tomb != null && tomb >= remote.modifiedAt) continue1888                val local = byId[remote.id]1889                if (local == null) {1890                    entryList.add(remote); byId[remote.id] = remote; n++1891                } else if (remote.modifiedAt > local.modifiedAt) {1892                    val i = entryList.indexOfFirst { it.id == local.id }1893                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1894                }1895            }18961897            for ((id, deletedAt) in tombMap) {1898                val local = byId[id] ?: continue1899                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1900                if (deletedAt < local.modifiedAt) continue1901                val i = entryList.indexOfFirst { it.id == id }1902                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1903            }1904            n1905        } ?: return@withLock 019061907        if (applied > 0) {1908            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)1909            mutateIfCurrentEpoch(epoch) { publish() }1910        }1911        applied1912    }19131914    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1915        // Require a fully live session before exposing a settings mutation.  The1916        // epoch is captured with the read so a lock/new-session boundary cannot1917        // let stale work publish into the replacement session.1918        val epoch = requireUnlocked()1919        val prior = mutateCurrentSession(epoch) {1920            val old = _settings.value1921            _settings.value = settings1922            old1923        } ?: throw IllegalStateException("vault_locked")19241925        val committed = try {1926            beforeDurableCommit()1927            // The durable write is inside the same epoch fence as the in-memory1928            // publication.  If lock() won first, this returns null and no stale1929            // settings reach storage or the public StateFlow.1930            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true1931        } catch (_: Exception) {1932            false1933        }1934        if (!committed) {1935            // A live-session storage failure rolls back the public value.  A1936            // stale epoch is already reset by lock()/the new session and must1937            // not be allowed to overwrite that safe state.1938            mutateIfCurrentEpoch(epoch) { _settings.value = prior }1939            throw IllegalStateException("settings_write_failed")1940        }1941    }19421943    // ---- Recovery words (vault metadata) ----------------------------------19441945    /** True once the user has confirmed their recovery words for this vault. */1946    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }19471948    /**1949     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1950     * Persisted without bumping the version — recovery metadata is local-only and1951     * is not an entry mutation paired devices should sync against.1952     */1953    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1954        val epoch = requireUnlocked()1955        // H-03: hash + escrow-related state fields set under stateLock so a1956        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.1957        // M-04: the flags have to be live before the escrows are built — both seal1958        // currentPayload() — so capture the prior pair and put it back if the write1959        // never lands. Otherwise the session reports words set against a slot A1960        // that still holds no escrow, and the recovery screen never offers a retry.1961        val prior = mutateCurrentSession(epoch) {1962            val priorPair = recoveryWordHash to recoveryWordsSet1963            recoveryWordHash = recoveryWordsHash(words)1964            recoveryWordsSet = true1965            priorPair1966        } ?: return@withLock false1967        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow1968        // (cross-platform recovery of the sync root key) and Android's whole-payload1969        // escrow (offline forgot-password restore of entries). Both derive their key1970        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.1971        val persisted = runCatching {1972            persistSlotAWithEscrows(1973                buildRecoveryEscrow(words),1974                buildRootKeyEscrow(words),1975                expectedEpoch = epoch,1976            )1977        }.getOrDefault(false)1978        if (!persisted) {1979            mutateIfCurrentEpoch(epoch) {1980                recoveryWordHash = prior.first1981                recoveryWordsSet = prior.second1982            }1983        }1984        persisted1985    }19861987    /** Verify entered words against the stored recovery hash. */1988    fun verifyRecoveryWords(words: List<String>): Boolean {1989        return mutateState {1990            val stored = recoveryWordHash ?: return@mutateState false1991            stored == recoveryWordsHash(words)1992        }1993    }19941995    // ---- Recovery escrow (forgot-password) --------------------------------19961997    /** True at rest if this vault has any recovery escrow the recovery flow can use. */1998    fun hasRecoveryEscrow(): Boolean =1999        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20002001    /**2002     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2003     * the master password. On success the slot-A plaintext sealed in the escrow is2004     * adopted as the live session (entries, rootKey, settings), but no Argon2id2005     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2006     * new password and finish unlocking. Returns false when there is no escrow, the2007     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2008     *2009     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2010     * detached vault key to return here — slot A is wrapped under the rotating2011     * Argon2id key — so this opens the escrowed payload directly.2012     */2013    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2014        val expectedEpoch = currentEpoch()2015        val slotA = store.loadSlotA() ?: return false2016        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false20172018        // Prefer Android's whole-payload escrow: a full offline restore of entries,2019        // root key, and settings. Present for Android-created vaults.2020        val fullEscrow = slotA.recoveryEscrow2021        if (fullEscrow != null) {2022            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2023            if (material != null) {2024                var keyBytes: ByteArray? = null2025                var plaintext: ByteArray? = null2026                var candidateRoot: ByteArray? = null2027                var stateCommitStarted = false2028                try {2029                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2030                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2031                    val payload = VaultCodec.decode(plaintext)2032                    // Stage every fallible operation before touching live session2033                    // fields.  In particular, a settings/provider/decode failure2034                    // cannot leave entries, flags, or a root key resident while the2035                    // public session remains locked.2036                    val candidateSettings = store.loadSettings()2037                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2038                        val decodedRoot = unb64(payload.rootKeyB64)2039                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2040                            // Do not let a malformed authenticated payload abandon2041                            // this decoded root-key candidate before the outer2042                            // finally block owns it.2043                            VaultCrypto.zero(decodedRoot)2044                            throw IllegalArgumentException("invalid recovery root key")2045                        }2046                        decodedRoot2047                    } else {2048                        generateRecoverableRootKey()2049                    }2050                    stateCommitStarted = true2051                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2052                        clearSessionStateLocked()2053                        entryList = payload.entries.toMutableList()2054                        duressConfigured = payload.duressConfigured2055                        recoveryWordHash = payload.recoveryWordHash2056                        recoveryWordsSet = payload.recoveryWordsSet2057                        rootKey = candidateRoot2058                        candidateRoot = null2059                        _settings.value = candidateSettings2060                        publish()2061                        // This is the authenticated recovery half-unlock boundary:2062                        // only set it after every candidate field is installed.2063                        sessionDataAvailable = true2064                        _isUnlocked.value = false2065                        true2066                    }2067                    if (adopted == true) return@withLock true2068                } catch (_: Exception) {2069                    // Wrong words, malformed payloads, provider failures, and2070                    // settings failures all fall through to the browser-compatible2071                    // root-key escrow.  If the state commit had started, invalidate2072                    // that epoch so no partial candidate survives.2073                    if (stateCommitStarted) {2074                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2075                    }2076                } finally {2077                    plaintext?.let(VaultCrypto::zero)2078                    VaultCrypto.zero(material)2079                    keyBytes?.let(VaultCrypto::zero)2080                    candidateRoot?.let(VaultCrypto::zero)2081                }2082            }2083        }20842085        // Browser-format vault (only the root-key escrow): recover the 32-byte root2086        // key (sync identity) and reset the password; entries return via sync. This2087        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2088        val rootEscrow = slotA.rootKeyEscrow2089        if (rootEscrow != null) {2090            var candidateRoot: ByteArray? = null2091            var stateCommitStarted = false2092            try {2093                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2094                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2095                val candidateSettings = store.loadSettings()2096                stateCommitStarted = true2097                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2098                    clearSessionStateLocked()2099                    entryList = mutableListOf()2100                    duressConfigured = false2101                    recoveryWordHash = recoveryWordsHash(words)2102                    recoveryWordsSet = true2103                    rootKey = candidateRoot2104                    candidateRoot = null2105                    _settings.value = candidateSettings2106                    publish()2107                    sessionDataAvailable = true2108                    _isUnlocked.value = false2109                    true2110                }2111                if (adopted == true) return@withLock true2112            } catch (_: Exception) {2113                if (stateCommitStarted) {2114                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2115                }2116            } finally {2117                candidateRoot?.let(VaultCrypto::zero)2118            }2119        }2120        return false2121    }21222123    /**2124     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2125     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2126     * words, mark the session fully unlocked, and publish. Returns false if called2127     * without a prior successful recovery unlock (no in-memory root key).2128     *2129     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2130     * into one step, since there is no detached vault key here.2131     */2132    suspend fun resetPasswordWithRecoveryWords(2133        words: List<String>,2134        newPassword: String,2135    ): Boolean = mutex.withLock {2136        val expectedEpoch = currentEpoch()2137        // H-03: read rootKey + entryList under stateLock before computing the new2138        // plaintext, then re-publish + swap the derived key under stateLock so a2139        // concurrent lock() cannot straddle the two fences.2140        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2141        val priorA = store.loadSlotA() ?: return false2142        var newKey: ByteArray? = null2143        var plaintext: ByteArray? = null2144        return try {2145            val newSalt = VaultCrypto.generateSalt()2146            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2147            plaintext = VaultCodec.encode(currentPayload())2148            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2149            // Rebuild both escrows against the recovered session so their salts rotate2150            // with the password reset; fall back to the prior escrows if rebuild fails2151            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2152            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2153            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2154            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2155            val next = StoredVault(2156                version = priorA.version,2157                salt = b64(newSalt),2158                blob = blob,2159                recoveryEscrow = freshRecovery,2160                rootKeyEscrow = freshRootKey,2161            )2162            // H-01: see changePassword — both rotations share the same2163            // commit-or-keep-old-key contract so a partial write can't strand2164            // the session on a key that no longer matches disk.2165            val priorB = store.loadSlotB()2166            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2167            // H-03 (P1-2 codex): zero the old live key and install the new one2168            // inside the same stateLock critical section so a concurrent lock()2169            // can't observe the in-between.2170            if (mutateIfCurrentEpoch(expectedEpoch) {2171                derivedKey?.let(VaultCrypto::zero)2172                derivedKey = newKey; newKey = null2173                publish()2174                _isUnlocked.value = true2175            } == null) return false2176            true2177        } catch (_: Exception) {2178            false2179        } finally {2180            newKey?.let(VaultCrypto::zero)2181            plaintext?.let(VaultCrypto::zero)2182        }2183    }21842185    /**2186     * Seal the current slot-A plaintext under a key derived from the 12 BIP392187     * recovery [words] (Android offline-restore escrow). IKM is the browser's2188     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2189     * Returns null for a checksum-invalid phrase.2190     */2191    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2192        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2193        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2194        var keyBytes: ByteArray? = null2195        var plaintext: ByteArray? = null2196        return try {2197            val salt = VaultCrypto.generateSalt()2198            val recoveryKey = deriveRecoveryKey(material, salt)2199            keyBytes = recoveryKey.encoded2200            plaintext = VaultCodec.encode(currentPayload())2201            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2202            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2203        } finally {2204            VaultCrypto.zero(material)2205            keyBytes?.let(VaultCrypto::zero)2206            plaintext?.let(VaultCrypto::zero)2207        }2208    }22092210    /**2211     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2212     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2213     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2214     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2215     * the same root key on the browser. Returns null with no in-memory root key or2216     * a checksum-invalid phrase.2217     */2218    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2219        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2220        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2221        val rk = mutateState { rootKey?.copyOf() } ?: run {2222            VaultCrypto.zero(material)2223            return null2224        }2225        var keyBytes: ByteArray? = null2226        var plaintext: ByteArray? = null2227        return try {2228            val salt = VaultCrypto.generateSalt()2229            val recoveryKey = deriveRecoveryKey(material, salt)2230            keyBytes = recoveryKey.encoded2231            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2232            // combined = nonce(12) || ciphertext || tag(16)2233            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2234            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2235            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2236            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2237            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2238            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2239        } finally {2240            VaultCrypto.zero(rk)2241            VaultCrypto.zero(material)2242            keyBytes?.let(VaultCrypto::zero)2243            plaintext?.let(VaultCrypto::zero)2244        }2245    }22462247    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2248    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2249        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2250        var keyBytes: ByteArray? = null2251        var pt: ByteArray? = null2252        return try {2253            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2254            keyBytes = recoveryKey.encoded2255            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2256            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2257            hexToRootKey(String(pt, Charsets.US_ASCII))2258        } catch (_: Exception) {2259            null2260        } finally {2261            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2262            // IKM-equivalent of the vault identity. material+keyBytes were2263            // already zeroed; pt was leaking.2264            VaultCrypto.zero(material)2265            keyBytes?.let(VaultCrypto::zero)2266            pt?.let(VaultCrypto::zero)2267        }2268    }22692270    /**2271     * Re-seal slot A from the current in-memory state under the live key, carrying2272     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2273     * in decoy mode (recovery is never offered for the decoy) and persists without2274     * a version bump, like the recovery-words metadata write it replaces.2275     */2276    private fun persistSlotAWithEscrows(2277        recoveryEscrow: RecoveryEscrow?,2278        rootKeyEscrow: RootKeyEscrow?,2279        expectedEpoch: Long? = null,2280    ): Boolean {2281        val snapshot = snapshotForPersist(expectedEpoch)2282            ?: throw IllegalStateException("vault_locked_or_stale")2283        if (snapshot.decoy) {2284            VaultCrypto.zero(snapshot.key)2285            return false2286        }2287        try {2288            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2289            val plaintext = VaultCodec.encode(snapshot.payload)2290            val blob = try {2291                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2292            } finally {2293                // The escrow path used to drop this reference before zeroing it;2294                // keep it live through the fill on both success and failure.2295                VaultCrypto.zero(plaintext)2296            }2297            val next = prior.copy(2298                blob = blob,2299                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2300                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2301            )2302            beforeDurableCommit()2303            return withEpochCommit(snapshot.epoch) {2304                if (!store.writeSlotA(next)) return@withEpochCommit false2305                try {2306                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2307                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2308                    // the duress slot too. A real decoy is left alone; a fake is2309                    // re-sized to slot A's new ciphertext length.2310                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2311                } catch (e: Exception) {2312                    store.writeSlotA(prior)2313                    throw e2314                }2315                true2316            } ?: false2317        } finally {2318            VaultCrypto.zero(snapshot.key)2319        }2320    }23212322    // ---- Internals --------------------------------------------------------23232324    private fun requireUnlocked(): Long {2325        // H-03: read derivedKey atomically with [lock]'s reset; without it,2326        // [lock] from a background lifecycle event can run between this read2327        // and the caller's first state touch.2328        stateLock.lock()2329        try {2330            if (derivedKey == null) throw IllegalStateException("vault_locked")2331            return sessionEpoch2332        } finally {2333            stateLock.unlock()2334        }2335    }23362337    /** A locked or half-adopted session must not expose secret-bearing fields. */2338    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null23392340    private fun countDuplicateEntriesLocked(): Int {2341        val seen = mutableMapOf<String, VaultEntry>()2342        var duplicates = 02343        for (entry in entryList.filter { it.deletedAt == null }) {2344            val key = entry.duplicateKey()2345            val existing = seen[key]2346            if (existing == null) {2347                seen[key] = entry2348            } else {2349                duplicates++2350                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2351                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2352                if (entryStamp > existingStamp) seen[key] = entry2353            }2354        }2355        return duplicates2356    }23572358    private fun VaultEntry.duplicateKey(): String {2359        if (type == "note") return "note|${title.lowercase()}"2360        val passkeyData = passkey2361        if (passkeyData != null) {2362            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2363        }2364        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2365    }23662367    /**2368     * Capture the current slot pair (and metadata) into the history list.2369     * Returns true when the history write landed, false when it didn't — callers2370     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2371     * must check the result and roll back if it does not.2372     */2373    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2374        val epoch = expectedEpoch ?: currentEpoch()2375        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2376        val slotA = store.loadSlotA() ?: return false2377        val vaultA = VaultCodec.encodeSlot(slotA)2378        val vaultB = store.loadSlotB() ?: slotA.blob2379        // H-04: capture the duress slot so a restore preserves the at-rest2380        // triad, not just the A/B pair. The duress slot is always present2381        // (real or fake), so loadDecoySlot() returning null is a logic error2382        // — fall through to a fake duress in that case rather than silently2383        // losing the record from the snapshot.2384        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2385            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162386            val fakeBlob = ByteArray(target).also(rng::nextBytes)2387            val fakeSalt = VaultCrypto.generateSalt()2388            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2389        }2390        val history = store.loadVaultHistory()2391        val latest = history.snapshots.firstOrNull()2392        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2393        val entryCount = mutateIfCurrentEpoch(epoch) {2394            if (derivedKey != null && !decoyMode) {2395                bucketEntryCount(entryList.count { it.deletedAt == null })2396            } else {2397                null2398            }2399        } ?: return false2400        return try {2401            val snapshot = VaultSnapshot(2402                id = UUID.randomUUID().toString(),2403                savedAt = System.currentTimeMillis(),2404                reason = reason,2405                vault_a = vaultA,2406                vault_b = vaultB,2407                vault_d = vaultD,2408                entryCount = entryCount,2409            )2410            // P1-4 (codex): return the history-write boolean to callers so a2411            // false return no longer leaves the snapshot out of history while2412            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2413            withEpochCommit(epoch) {2414                store.writeVaultHistory(2415                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2416                )2417            } ?: false2418        } catch (_: Exception) {2419            false2420        }2421    }24222423    private fun bucketEntryCount(n: Int): Int = when {2424        n <= 0 -> 02425        n <= 10 -> 102426        n <= 25 -> 252427        n <= 50 -> 502428        n <= 100 -> 1002429        n <= 250 -> 2502430        n <= 500 -> 5002431        else -> ((n + 499) / 500) * 5002432    }24332434    /**2435     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2436     * so it is stored explicitly and survives the sync wire format rather than being2437     * re-derived at render time. Non-card entries pass through unchanged.2438     *2439     * M-03: never overwrite a stored last4 with null. A card kept only as last42440     * (e.g. after a PeachScript import that had no PAN) must keep that value;2441     * re-deriving from an absent cardNumber would silently zero the field.2442     */2443    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2444        if (!entry.isCard()) return entry2445        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2446        val derived = if (digits.length >= 4) digits.takeLast(4) else null2447        return entry.copy(last4 = derived ?: entry.last4)2448    }24492450    /**2451     * On unlock, hard-delete trashed entries older than the retention window.2452     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2453     * (the app's "Never" option), so nothing is purged. Persists without a2454     * version bump and without touching the publish state (caller publishes).2455     */2456    private fun autoPurgeExpiredTrash() {2457        val days = _settings.value.trashRetentionDays ?: return2458        if (days <= 0) return2459        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2460        val before = entryList.size2461        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2462        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2463    }24642465    /** The slot-A plaintext envelope for the current in-memory session. */2466    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }24672468    private fun currentPayloadLocked(): VaultPayload =2469        VaultPayload(2470            entries = entryList.toList(),2471            duressConfigured = duressConfigured,2472            rootKeyB64 = rootKey?.let(::b64) ?: "",2473            recoveryWordHash = recoveryWordHash,2474            recoveryWordsSet = recoveryWordsSet,2475        )24762477    /**2478     * Adopt the payload's root key, lazily minting one for legacy payloads.2479     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2480     * a root key is not a mutation paired devices should sync against.2481     */2482    private fun ensureRootKey(payload: VaultPayload) {2483        if (payload.rootKeyB64.isNotBlank()) {2484            rootKey = unb64(payload.rootKeyB64)2485            return2486        }2487        rootKey = generateRecoverableRootKey()2488        runCatching { persistSlotA(bumpVersion = false) }2489    }24902491    private fun publish() {2492        _entries.value = entryList.filter { it.deletedAt == null }2493    }24942495    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2496        var plaintext = VaultCodec.encode(currentPayload())2497        return try {2498            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2499            StoredVault(version = 1, salt = b64(salt), blob = blob)2500        } finally {2501            // M-05: full vault plaintext is allocated here once per createVault.2502            // Zeroing in finally keeps the transient copy off the heap after the2503            // caller either succeeds or rolls back via lock().2504            VaultCrypto.zero(plaintext)2505        }2506    }25072508    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2509        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2510        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2511        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2512        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2513        val noise = ByteArray(approxBytes).also(rng::nextBytes)2514        return store.writeSlotB(b64(noise))2515    }25162517    /** H-04: the ciphertext length slot A is using right now, so the duress2518     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2519     *  length equals ciphertext length, so this also matches slot A's plaintext. */2520    private fun matchingCiphertextLength(slotA: StoredVault): Int =2521        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)25222523    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2524     *  bytes. Replaces a real decoy when the user clears duress, and is the2525     *  initial duress slot when no decoy is configured — so the record's2526     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2527     *  matches slot A's blob length. */2528    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2529        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162530        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2531        val fakeSalt = VaultCrypto.generateSalt()2532        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2533    }25342535    /** H-04: keep the duress record presence- and size-indistinguishable from2536     *  slot A on every persist. A real decoy is left alone (it was padded at2537     *  setDuressPassphrase time, and we don't have the duress key here to2538     *  re-pad on entry growth — that's an accepted residual tell, see the2539     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2540     *  A's size. */2541    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2542        val isConfigured = mutateState { duressConfigured }2543        return if (isConfigured) true else writeFakeDuressSlot(slotA)2544    }25452546    /**2547     * H-04: convert a browser-shaped duress slot to Android StoredVault2548     * without decrypting. The browser seals slot "b" under the duress2549     * passphrase; decrypting with the main password silently drops it.2550     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2551     * the salt from hex to base64.2552     */2553    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2554        val salt = unhex(b.salt)2555        val nonce = unhex(b.nonce)2556        val ct = unhex(b.ciphertext)2557        val tag = unhex(b.tag)2558        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2559            System.arraycopy(nonce, 0, it, 0, nonce.size)2560            System.arraycopy(ct, 0, it, nonce.size, ct.size)2561            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2562        }2563        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2564    }25652566    /** Re-seal slot A from the current in-memory state under the live key. */2567    private fun persistSlotA(2568        bumpVersion: Boolean,2569        snapshotReason: String? = "local_save",2570        expectedEpoch: Long? = null,2571    ): Boolean {2572        val snapshot = snapshotForPersist(expectedEpoch)2573            ?: throw IllegalStateException("vault_locked_or_stale")2574        return try {2575            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2576            if (snapshot.decoy) {2577                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2578                val plaintext = VaultCodec.encode(snapshot.payload)2579                val blob = try {2580                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2581                } finally {2582                    // Keep the last reference alive until the zero, including the2583                    // successful encrypt path (M-05).2584                    VaultCrypto.zero(plaintext)2585                }2586                beforeDurableCommit()2587                return withEpochCommit(snapshot.epoch) {2588                    store.writeDecoySlot(prior.copy(blob = blob))2589                } ?: false2590            }2591            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2592            // H-01: captured BEFORE the noise write so a stamp failure can restore2593            // the prior slot B alongside the prior slot A. Slot B is always2594            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2595            // leaves (new A, new B, old version) on disk — partial commit.2596            val priorB = store.loadSlotB()2597            beforeDurableCommit()2598            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2599            val plaintext = VaultCodec.encode(snapshot.payload)2600            val blob = try {2601                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2602            } finally {2603                // Do not drop [plaintext] before fill(0): the serialized vault2604                // contains every entry password and is the primary M-05 target.2605                VaultCrypto.zero(plaintext)2606            }2607            val next = prior.copy(blob = blob)2608            // The epoch check and the entire A/B/D/version commit share one brief2609            // stateLock critical section. lock() either wins before this block or2610            // waits until the already-committed write is complete; it can never2611            // clear the session between the check and the durable write.2612            withEpochCommit(snapshot.epoch) {2613                if (!store.writeSlotA(next)) return@withEpochCommit false2614                try {2615                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2616                    // H-04: duress slot stays present (real one untouched, fake one2617                    // re-padded to slot A's current size on every persist).2618                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2619                    if (bumpVersion) store.stampLocalMutation()2620                } catch (e: Exception) {2621                    store.writeSlotA(prior)2622                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2623                    throw e2624                }2625                true2626            } ?: false2627        } finally {2628            // Snapshot keys are copies, so lock() may zero the live key while this2629            // operation is still deriving/encrypting; always scrub our copy too.2630            VaultCrypto.zero(snapshot.key)2631        }2632    }26332634    /**2635     * H-01: durably commit a slot-A rotation by writing slot A, its2636     * size-matched noise slot B, and the local-mutation stamp in order. Any2637     * failure rolls the previously-committed steps back so the at-rest pair2638     * matches what was there before the call. Returns true only when every2639     * step succeeded — callers must NOT swap their session key on false.2640     */2641    private fun commitVaultRotation(2642        priorA: StoredVault,2643        priorB: String?,2644        nextA: StoredVault,2645        expectedEpoch: Long,2646    ): Boolean {2647        beforeDurableCommit()2648        return withEpochCommit(expectedEpoch) {2649            if (!store.writeSlotA(nextA)) return@withEpochCommit false2650            // P1-1 (codex): the noise write's boolean AND any thrown exception from2651            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2652            // escaped the helper and left slot A committed.2653            try {2654                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2655                // H-04: a password rotation changes slot A's plaintext size (new2656                // salt → re-encrypted blob), so the duress slot must be re-padded2657                // alongside. writeCompanionDuressSlot leaves a real decoy alone2658                // and re-emits a fake of slot A's size otherwise.2659                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2660                store.stampLocalMutation()2661            } catch (_: Exception) {2662                rollbackRotation(priorA, priorB)2663                return@withEpochCommit false2664            }2665            true2666        } ?: false2667    }26682669    /**2670     * Best-effort undo of the writes [commitVaultRotation] may have committed.2671     * Each step runs independently — a failed rollback must not mask the2672     * original commit failure. Log is a throwing stub off-device.2673     */2674    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2675        val failures = mutableListOf<String>()2676        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2677            .onFailure { failures += "writeSlotA: $it" }2678        if (priorB != null) {2679            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2680                .onFailure { failures += "writeSlotB: $it" }2681        }2682        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2683    }26842685    /** Persist a local mutation, rolling the in-memory list back on failure. */2686    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2687        try {2688            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2689                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2690                throw IllegalStateException("storage_write_failed")2691            }2692        } catch (e: Exception) {2693            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2694            throw e2695        }2696        // A lock can win in the small interval after the durable commit and2697        // before publication. The epoch check prevents pre-lock entries from2698        // being republished into a newly locked (or newly unlocked) session.2699        mutateIfCurrentEpoch(expectedEpoch) { publish() }2700    }2701}