VaultRepository.kt

127.9 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)3132/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */33private const val PASSWORD_HISTORY_MAX = 5034private const val VAULT_HISTORY_MAX = 53536// How long the transient-background-lock suppression stays valid. If the37// launched system Activity never reports the expected lifecycle stop (picker38// crashed, user backed out into another app, OEM killed the picker process),39// the flag expires and the next genuine background event locks the vault40// rather than consuming a stale suppression.41private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4243sealed interface UnlockResult {44    data object Success : UnlockResult45    data object WrongPassword : UnlockResult46    data class Error(val reason: String) : UnlockResult47}4849sealed interface CreateVaultResult {50    data object Success : CreateVaultResult51    data class Error(val reason: String) : CreateVaultResult52}5354sealed interface AdoptVaultResult {55    data class Success(val vaultId: String) : AdoptVaultResult56    data class Error(val reason: String) : AdoptVaultResult57}5859/**60 * In-memory vault session backed by [VaultStore]. Faithful port of the browser61 * `vault.ts` semantics that matter for correctness:62 *63 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched64 *    noise blob so a fresh install shows two similarly-shaped records.65 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a66 *    deletion always wins last-write-wins against an in-flight edit.67 *  - Local writes bump the version counter; sync-applied writes do NOT (so a68 *    sync round never cascades into another outbound sync).69 *  - add/update/delete roll back in-memory state if the disk write fails.70 *  - destroyVault never touches the Pro license (separate store).71 */72@Singleton73class VaultRepository @Inject constructor(74    private val store: VaultStorage,75) {76    private val mutex = Mutex()77    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend78    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync79    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so80    // lock() can serialize against an in-flight suspend op without blocking on81    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and82    // take [stateLock] only around the state-write portions. Acquisition order83    // is always mutex → stateLock.84    private val stateLock = ReentrantLock()85    private val rng = SecureRandom()8687    /**88     * Monotonic session generation.  [lock] advances this before clearing any89     * secret state; work that captured the previous generation is therefore90     * stale even if it is still running its (non-suspending) storage step.91     */92    private var sessionEpoch: Long = 0L9394    private fun nextSessionEpoch(epoch: Long): Long =95        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9697    /** Test seam for deterministic "lock while waiting to commit" races. */98    @VisibleForTesting99    internal var beforeDurableCommit: () -> Unit = {}100101    /** Test seam after a failed Codex rollback, while the epoch fence is held. */102    @VisibleForTesting103    internal var afterCodexRollback: () -> Unit = {}104105    /**106     * Run [block] with [stateLock] held. Used to fence the small state-write107     * portions of suspend operations against [lock] and [requireUnlocked]108     * racing from another thread.109     */110    private inline fun <T> mutateState(block: () -> T): T {111        stateLock.lock()112        return try { block() } finally { stateLock.unlock() }113    }114115    /** Run a durable commit only while the session generation is still live. */116    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {117        stateLock.lock()118        return try {119            if (sessionEpoch != epoch) null else block()120        } finally {121            stateLock.unlock()122        }123    }124125    /** Test seam for a caller that must stage work before the epoch fence. */126    @VisibleForTesting127    internal var beforeSessionEpochCommit: () -> Unit = {}128129    /**130     * Test seam for the narrower H-03 race: a suspend mutation has authenticated131     * the session and staged its candidate state, but has not yet entered the132     * epoch-fenced in-memory write. Production callers leave this as a no-op.133     */134    @VisibleForTesting135    internal var beforeSessionStateMutation: () -> Unit = {}136137    /**138     * Run an external session-bound commit while the repository's epoch fence is139     * held. The callback is deliberately non-suspending: [lock] cannot advance140     * the epoch until the whole external batch has reached its linearization141     * point.142     */143    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {144        beforeSessionEpochCommit()145        return withEpochCommit(epoch, block)146    }147148    private fun currentEpoch(): Long = mutateState { sessionEpoch }149150    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =151        mutateState { if (sessionEpoch == epoch) block() else null }152153    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */154    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {155        beforeSessionStateMutation()156        return mutateIfCurrentEpoch(epoch, block)157    }158159    private data class PersistSnapshot(160        val epoch: Long,161        val key: ByteArray,162        val payload: VaultPayload,163        val decoy: Boolean,164    )165166    /** Copy all session material while fenced; callers own and must zero [key]. */167    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {168        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null169        val liveKey = derivedKey ?: return@mutateState null170        PersistSnapshot(171            epoch = sessionEpoch,172            key = liveKey.copyOf(),173            payload = currentPayloadLocked(),174            decoy = decoyMode,175        )176    }177178    private var derivedKey: ByteArray? = null179    private var rootKey: ByteArray? = null180    private var entryList: MutableList<VaultEntry> = mutableListOf()181    private var duressConfigured: Boolean = false182    private var recoveryWordHash: String? = null183    private var recoveryWordsSet: Boolean = false184    /** True when an authenticated vault payload is available for local reads. */185    private var sessionDataAvailable: Boolean = false186    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */187    private var decoyMode: Boolean = false188189    private val _isUnlocked = MutableStateFlow(false)190    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()191192    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())193    /** Active (non-deleted) entryList. */194    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()195196    private val _settings = MutableStateFlow(VaultSettings())197    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()198199    /** Clear every in-memory session field while [stateLock] is held. */200    private fun clearSessionStateLocked() {201        derivedKey?.let(VaultCrypto::zero)202        rootKey?.let(VaultCrypto::zero)203        derivedKey = null204        rootKey = null205        entryList = mutableListOf()206        duressConfigured = false207        recoveryWordHash = null208        recoveryWordsSet = false209        sessionDataAvailable = false210        decoyMode = false211        _settings.value = VaultSettings()212        _entries.value = emptyList()213        _isUnlocked.value = false214    }215216    fun hasVault(): Boolean = store.hasVault()217218    // ---- Codex (printed paper backup) -------------------------------------219220    /**221     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the222     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's223     * A/B naming where slot B is the duress decoy); the noise slot is local-only224     * and never crosses devices. Restorable on any device that has the master225     * password. Null when no vault exists.226     */227    fun codexVaultBlobBase64(): String? {228        val slotA = store.loadSlotA() ?: return null229        // H-04: duress slot is always present after createVault (real or fake),230        // so the wrapper can rely on it being loadable.231        val slotD = store.loadDecoySlot() ?: return null232        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""233        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))234    }235236    /**237     * Replace the local vault from a Codex blob, then unlock with [password].238     * Writes slot A (and a size-matched noise slot B) before unlocking so the239     * restored vault is at rest exactly as a freshly-created one would be.240     */241    /** A slot is stored either as a JSON string (browser) or inline object (Android). */242    private fun JsonElement?.asSlotJson(): String? = when {243        this == null || this is JsonNull -> null244        this is JsonPrimitive && isString -> content245        else -> toString()246    }247248    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {249        val expectedEpoch = currentEpoch()250        var candidateKey: ByteArray? = null251        var plaintext: ByteArray? = null252        return try {253            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)254            // Parsed as JSON, not matched with a regex. The wrapper is255            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which256            // generates most real Codexes — writes each slot as a JSON257            // *string*, with "b" present. A regex looking for an object258            // literal under "a" at the very end of the wrapper matches259            // neither of those, so every browser-generated Codex failed here260            // with "invalid_codex_blob" after decoding perfectly.261            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject262                ?: return UnlockResult.Error("invalid_codex_blob")263            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")264            // A Codex printed from the browser carries a browser-shaped slot,265            // which is the common case — most Codexes are printed there. It is266            // translated to Android's shape (see BrowserSlot) rather than267            // rejected. A translation failure is a wrong password, not a bad268            // blob: the codes decoded fine, the password just didn't open them.269            val slotA = VaultCodec.decodeSlot(slotJson)270                ?: BrowserSlot.parse(slotJson)?.let {271                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword272                }273                ?: return UnlockResult.Error("invalid_codex_blob")274            // C-01: authenticate the candidate ENTIRELY in memory before touching275            // storage. VaultCodec.decodeSlot is structural — an Android-shaped276            // Codex parses under ANY password — so writing slot A first and277            // unlocking afterwards destroyed the device's existing vault every278            // time the user mistyped the password.279            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))280            plaintext = decryptSlotABlob(slotA, candidateKey)281            // LV-1: a Codex printed from a vault sealed before NFC normalization282            // opens only under the raw password bytes. unlockLocked used to supply283            // this fallback; authenticating up front here has to keep it, or those284            // Codexes become unrestorable.285            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {286                VaultCrypto.zero(candidateKey)287                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))288                plaintext = decryptSlotABlob(slotA, candidateKey)289            }290            if (plaintext == null) return UnlockResult.WrongPassword291            val payload = try {292                VaultCodec.decode(plaintext)293            } catch (_: Exception) {294                return UnlockResult.Error("invalid_codex_blob")295            }296297            val priorA = store.loadSlotA()298            val priorB = store.loadSlotB()299            val priorD = store.loadDecoySlot()300            // Carry the real duress slot across when the Codex has one — the301            // browser writes "b" as its duress-shaped slot, and dropping it302            // here would silently destroy a configured decoy.303            // H-04: write browser B into the duress slot (not the noise slot),304            // translating browser-shaped slots to Android's StoredVault shape.305            val codexDuressJson = wrapper["b"].asSlotJson()306            val codexDuress: StoredVault? = codexDuressJson?.let {307                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->308                    // Wrong-password on the duress slot only (browser decoys can309                    // use a different passphrase). Fall through to a fake duress310                    // — the slot-A password was already authenticated.311                    BrowserSlot.toAndroidSlot(b, password)312                }313            }314315            beforeDurableCommit()316            val committed = runCatching {317                withEpochCommit(expectedEpoch) {318                    try {319                        // Keep every slot write, including the inverse, under the320                        // same epoch fence. Releasing stateLock after a partial321                        // write and reacquiring it for rollback lets lock() advance322                        // the epoch in between and strand the incoming slot A.323                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")324                        // Noise slot is always size-matched to slot A — local-only,325                        // never written from a Codex.326                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")327                        // H-04: duress slot always present; real one from the Codex or328                        // a fake the size of slot A.329                        val duressOk = if (codexDuress != null) {330                            store.writeDecoySlot(codexDuress)331                        } else {332                            writeFakeDuressSlot(slotA)333                        }334                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")335                        store.stampLocalMutation()336                        true337                    } catch (_: Exception) {338                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))339                        // Keep the fence held through the final rollback340                        // observation. A concurrent lock() may queue here, but341                        // cannot invalidate the restored A/B/D triad.342                        afterCodexRollback()343                        false344                    }345                } ?: false346            }.getOrDefault(false)347            if (!committed) {348                return UnlockResult.Error("storage_write_failed")349            }350351            val ownedKey = candidateKey!!352            candidateKey = null // the session owns the key from here on353            adoptSlotA(ownedKey, payload, expectedEpoch)354        } catch (e: Exception) {355            // A raw kotlinx parse error ("Unexpected JSON token at offset N")356            // reaching the user says nothing about WHICH json failed — the357            // wrapper, or the payload decrypted out of it. Codex screens are358            // FLAG_SECURE so this can't be screenshotted either. Debug builds359            // log the structure around the failure; alphanumerics are masked so360            // no ciphertext or vault content lands in a log.361            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))362            UnlockResult.Error(e.message ?: "restore_failed")363        } finally {364            plaintext?.let(VaultCrypto::zero)365            candidateKey?.let(VaultCrypto::zero)366        }367    }368369    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */370    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =371        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()372373    /**374     * Undo a partially-committed Codex restore while [stateLock] is already held.375     * Every step is attempted independently so one failure cannot strand the376     * others. Callers that need epoch validation must enter through377     * [withEpochCommit] first; this helper deliberately does not reacquire the378     * lock, avoiding a reentrant gap between a failed write and its inverse.379     * With no prior vault the accurate inverse is having none at all, rather380     * than a half-written slot A that [hasVault] would report as a real vault.381     */382    private fun rollbackRestoreLocked(383        priorA: StoredVault?,384        priorB: String?,385        priorD: StoredVault? = null,386    ): List<String> {387        val failures = mutableListOf<String>()388        if (priorA == null) {389            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }390        } else {391            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }392                .onFailure { failures += "writeSlotA: $it" }393            if (priorB != null) {394                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }395                    .onFailure { failures += "writeSlotB: $it" }396            }397            // H-04: restore the prior duress slot — a half-committed restore that398            // left the duress record on the Codex side and slot A on the prior399            // side would put the at-rest pair in a state that never existed.400            if (priorD != null) {401                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }402                    .onFailure { failures += "writeDecoySlot: $it" }403            } else {404                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }405            }406        }407        return failures408    }409410    private fun reportRollbackFailures(failures: List<String>) {411        // Log is a throwing stub off-device, and a failed log must never mask412        // the rollback result the caller is about to report.413        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }414    }415416    /** Debug-only: structure of a failed Codex restore, with content masked. */417    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {418        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)419        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()420        val window = offset?.let {421            val from = (it - 40).coerceAtLeast(0)422            val to = (it + 40).coerceAtMost(wrapperJson.length)423            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")424        }425        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()426        val slot = wrapper?.get("a")427        buildString {428            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)429            append(" | blobBase64=").append(vaultBlobBase64.length)430            append(" wrapperChars=").append(wrapperJson.length)431            append(" wrapperParses=").append(wrapper != null)432            append(" keys=").append(wrapper?.keys)433            append(" slotAKind=").append(434                when {435                    slot == null -> "absent"436                    slot is JsonPrimitive && slot.isString -> "string"437                    slot is JsonObject -> "object:" + slot.keys438                    else -> slot::class.simpleName439                },440            )441            if (window != null) append(" masked@").append(offset).append("=").append(window)442        }443    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }444445    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {446        val expectedEpoch = currentEpoch()447        var key: ByteArray? = null448        return try {449            val salt = VaultCrypto.generateSalt()450            key = VaultCrypto.deriveKey(password, salt)451            if (mutateIfCurrentEpoch(expectedEpoch) {452                entryList = mutableListOf()453                duressConfigured = false454                recoveryWordHash = null455                recoveryWordsSet = false456                decoyMode = false457                sessionDataAvailable = false458                rootKey = generateRecoverableRootKey()459            } == null) return CreateVaultResult.Error("stale_create")460            // M-04: the fields above have to be live before sealSlotA, since it461            // seals currentPayload(). That stages a root key the session would462            // otherwise keep after a failed create — [lock] discards the whole463            // staged session on every non-success exit below.464            val slotA = sealSlotA(key!!, salt)465            // P1-3 (codex): every later step must roll the slot pair back if it466            // fails — leaving a half-built vault is worse than refusing to make it.467            beforeDurableCommit()468            val created = withEpochCommit(expectedEpoch) {469                if (!store.writeSlotA(slotA)) return@withEpochCommit false470                try {471                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")472                    // H-04: duress slot must be present from the start so its473                    // existence on disk is independent of duressConfigured.474                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")475                    store.stampLocalMutation()476                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")477                    true478                } catch (_: Exception) {479                    // Half-built vault on disk is worse than no vault — refuse to480                    // create if any step past slot A fails.481                    store.clearVault()482                    false483                }484            } ?: false485            if (!created) {486                lock()487                return CreateVaultResult.Error("storage_write_failed")488            }489            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")490            if (mutateIfCurrentEpoch(expectedEpoch) {491                derivedKey = sessionKey492                key = null493                publish()494                _isUnlocked.value = true495                sessionDataAvailable = true496                sessionEpoch = nextSessionEpoch(sessionEpoch)497            } == null) return CreateVaultResult.Error("stale_create")498            CreateVaultResult.Success499        } catch (e: Exception) {500            lock()501            CreateVaultResult.Error(e.message ?: "create_failed")502        } finally {503            key?.let(VaultCrypto::zero)504        }505    }506507    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {508        val expectedEpoch = currentEpoch()509        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")510        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")511        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {512            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")513        }514        var adoptedRootKey: ByteArray? = null515        var nextDerivedKey: ByteArray? = null516        var sessionRootKey: ByteArray? = null517        return try {518            adoptedRootKey = hexToRootKey(rootKeyHex)519            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {520                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")521            }522            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)523            val salt = VaultCrypto.generateSalt()524            nextDerivedKey = VaultCrypto.deriveKey(password, salt)525            sessionRootKey = adoptedRootKey.copyOf()526            val payload = VaultPayload(527                entries = emptyList(),528                duressConfigured = false,529                rootKeyB64 = b64(sessionRootKey),530                recoveryWordHash = null,531                recoveryWordsSet = false,532            )533            val plaintext = VaultCodec.encode(payload)534            val blob = try {535                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))536            } finally {537                VaultCrypto.zero(plaintext)538            }539            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)540            // P1-3 (codex): same partial-commit guard as createVault — any failure541            // past slot A wipes the half-built vault and refuses adoption.542            beforeDurableCommit()543            val created = withEpochCommit(expectedEpoch) {544                if (!store.writeSlotA(slotA)) return@withEpochCommit false545                try {546                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")547                    // H-04: duress slot must be present from the start so its548                    // existence on disk is independent of duressConfigured.549                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")550                    store.stampLocalMutation()551                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")552                    true553                } catch (_: Exception) {554                    store.clearVault()555                    false556                }557            } ?: false558            if (!created) return AdoptVaultResult.Error("storage_write_failed")559560            val adopted = mutateIfCurrentEpoch(expectedEpoch) {561                val previousDerivedKey = derivedKey562                val previousRootKey = rootKey563                derivedKey = nextDerivedKey564                rootKey = sessionRootKey565                nextDerivedKey = null566                sessionRootKey = null567                entryList = mutableListOf()568                duressConfigured = false569                recoveryWordHash = null570                recoveryWordsSet = false571                decoyMode = false572                sessionDataAvailable = true573                publish()574                _isUnlocked.value = true575                sessionEpoch = nextSessionEpoch(sessionEpoch)576                previousDerivedKey to previousRootKey577            }578            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")579            val (previousDerivedKey, previousRootKey) = adopted580            previousDerivedKey?.let(VaultCrypto::zero)581            previousRootKey?.let(VaultCrypto::zero)582            AdoptVaultResult.Success(vaultId)583        } catch (e: Exception) {584            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")585        } finally {586            adoptedRootKey?.let(VaultCrypto::zero)587            nextDerivedKey?.let(VaultCrypto::zero)588            sessionRootKey?.let(VaultCrypto::zero)589        }590    }591592    suspend fun unlock(password: String): UnlockResult = mutex.withLock {593        unlockLocked(password, currentEpoch())594    }595596    /** Unlock body without acquiring the mutex — callers must already hold it. */597    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {598        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")599        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))600        val payload = try {601            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)602            try {603                VaultCodec.decode(plaintext)604            } finally {605                VaultCrypto.zero(plaintext)606            }607        } catch (_: Exception) {608            VaultCrypto.zero(key)609            // LV-1: a vault sealed before NFC normalization with a non-NFC610            // password derives a different key now. If the supplied password is611            // not already in NFC form, retry with the raw (un-normalized) bytes.612            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {613                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)614                if (legacy != null) return legacy615            }616            return UnlockResult.WrongPassword617        }618        return adoptSlotA(key, payload, expectedEpoch)619    }620621    /**622     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw623     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed624     * under the NFC-normalized key — so every future unlock takes the normal625     * path. Returns null when the raw key also fails (genuinely wrong password)626     * OR when any step of the heal commits fails (the original slot A is restored627     * so disk and session stay aligned).628     */629    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {630        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))631        var newKey: ByteArray? = null632        return try {633            val payload = try {634                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)635                try {636                    VaultCodec.decode(plaintext)637                } finally {638                    VaultCrypto.zero(plaintext)639                }640            } catch (_: Exception) {641                return null642            }643            // Heal: re-seal slot A under the NFC key (mirrors changePassword's644            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.645            val newSalt = VaultCrypto.generateSalt()646            newKey = VaultCrypto.deriveKey(password, newSalt)647            val plaintext = VaultCodec.encode(payload)648            val blob = try {649                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))650            } finally {651                VaultCrypto.zero(plaintext)652            }653            val next = StoredVault(654                version = slotA.version,655                salt = b64(newSalt),656                blob = blob,657                recoveryEscrow = slotA.recoveryEscrow,658                rootKeyEscrow = slotA.rootKeyEscrow,659            )660            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,661            // or stampLocalMutation must restore the prior slot A AND slot B so disk662            // and the eventual session key never disagree on the wrapping.663            val priorB = store.loadSlotB()664            beforeDurableCommit()665            val healed = withEpochCommit(expectedEpoch) {666                if (!store.writeSlotA(next)) {667                    false668                } else try {669                    if (!writeMatchingNoiseSlotB(next)) {670                        runCatching { store.writeSlotA(slotA) }671                        if (priorB != null) runCatching { store.writeSlotB(priorB) }672                        false673                    } else if (!writeCompanionDuressSlot(next)) {674                        runCatching { store.writeSlotA(slotA) }675                        if (priorB != null) runCatching { store.writeSlotB(priorB) }676                        false677                    } else {678                        store.stampLocalMutation()679                        true680                    }681                } catch (_: Exception) {682                    runCatching { store.writeSlotA(slotA) }683                    if (priorB != null) runCatching { store.writeSlotB(priorB) }684                    false685                }686            } ?: false687            if (healed) {688                VaultCrypto.zero(rawKey!!)689                rawKey = null690                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot691                newKey = null692                result693            } else {694                VaultCrypto.zero(newKey!!)695                newKey = null696                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key697                rawKey = null698                result699            }700        } finally {701            // Cover codec, encryption, storage, and stale-epoch failures before702            // either candidate key can be transferred into the live session.703            rawKey?.let(VaultCrypto::zero)704            newKey?.let(VaultCrypto::zero)705        }706    }707708    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */709    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {710        return try {711            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they712            // can throw after the fields above are live. There is no prior session713            // worth restoring — adoption has already replaced it — so a failed714            // adoption discards everything rather than leaving entries and keys715            // resident behind _isUnlocked=false.716            val adopted = mutateIfCurrentEpoch(expectedEpoch) {717                derivedKey = key718                decoyMode = false719                entryList = payload.entries.toMutableList()720                duressConfigured = payload.duressConfigured721                recoveryWordHash = payload.recoveryWordHash722                recoveryWordsSet = payload.recoveryWordsSet723                sessionDataAvailable = true724                _settings.value = store.loadSettings()725                ensureRootKey(payload)726                autoPurgeExpiredTrash()727                publish()728                _isUnlocked.value = true729                // A successful adoption starts a new generation. A sibling730                // SlotAUnlock captured in the prior epoch will fail closed at731                // the next commit instead of replacing this session.732                sessionEpoch = nextSessionEpoch(sessionEpoch)733            }734            if (adopted == null) {735                VaultCrypto.zero(key)736                UnlockResult.Error("stale_unlock")737            } else {738                UnlockResult.Success739            }740        } catch (e: Exception) {741            // Do not call lock() here: a concurrent newer session may already742            // own this repository. Only invalidate the failed epoch's fields.743            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }744            VaultCrypto.zero(key)745            UnlockResult.Error(e.message ?: "unlock_failed")746        }747    }748749    // ---- Constant-time unlock (MF-5) --------------------------------------750751    /**752     * A slot-A unlock that has been derived but not yet adopted. Holds the live753     * Argon2id key and decoded payload so the caller can derive several candidates754     * concurrently and commit only the winner. The key is owned by this token until755     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.756     */757    class SlotAUnlock internal constructor(758        key: ByteArray,759        internal val payload: VaultPayload,760        internal val epoch: Long,761    ) {762        private var ownedKey: ByteArray? = key763        private var consumed = false764765        /** Transfer the key exactly once to the adopter. */766        internal fun takeKey(): ByteArray? = synchronized(this) {767            if (consumed) return@synchronized null768            consumed = true769            ownedKey.also { ownedKey = null }770        }771772        /** Discard an uncommitted token without touching a transferred key. */773        internal fun discard() = synchronized(this) {774            if (consumed) return@synchronized775            consumed = true776            ownedKey?.let(VaultCrypto::zero)777            ownedKey = null778        }779    }780781    /**782     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.783     * Pure computation (no mutex), so it can run concurrently with other candidate784     * derivations before the caller decides which to commit (MF-5). Returns null on785     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).786     */787    fun tryUnlockSlotA(password: String): SlotAUnlock? {788        val epoch = currentEpoch()789        val slotA = store.loadSlotA() ?: return null790        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))791        return try {792            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)793            val payload = try {794                VaultCodec.decode(plaintext)795            } finally {796                VaultCrypto.zero(plaintext)797            }798            SlotAUnlock(key, payload, epoch)799        } catch (_: Exception) {800            VaultCrypto.zero(key)801            null802        }803    }804805    /** Adopt a previously-derived [SlotAUnlock] as the live session. */806    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {807        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")808        adoptSlotA(key, unlock.payload, unlock.epoch)809    }810811    /** Zero a derived [SlotAUnlock] that will not be adopted. */812    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()813814    // ---- Duress slot B (MF-6) ---------------------------------------------815816    /**817     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key818     * and decoded decoy payload so it can be derived concurrently with slot A and819     * only committed if the duress passphrase was the one entered (MF-5/MF-6).820     */821    class DuressUnlock internal constructor(822        key: ByteArray,823        internal val payload: VaultPayload,824        internal val epoch: Long,825    ) {826        private var ownedKey: ByteArray? = key827        private var consumed = false828829        internal fun takeKey(): ByteArray? = synchronized(this) {830            if (consumed) return@synchronized null831            consumed = true832            ownedKey.also { ownedKey = null }833        }834835        internal fun discard() = synchronized(this) {836            if (consumed) return@synchronized837            consumed = true838            ownedKey?.let(VaultCrypto::zero)839            ownedKey = null840        }841    }842843    /**844     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the845     * session. Success means the entered password is the duress passphrase that keys846     * slot B — indistinguishable from the real slot A at the lock screen. Pure847     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].848     * Returns null when no duress slot exists or the password is not the duress one.849     */850    fun tryUnlockDuress(password: String): DuressUnlock? {851        val epoch = currentEpoch()852        val slot = store.loadDecoySlot() ?: return null853        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))854        return try {855            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)856            val payload = try {857                VaultCodec.decode(plaintext)858            } finally {859                VaultCrypto.zero(plaintext)860            }861            DuressUnlock(key, payload, epoch)862        } catch (_: Exception) {863            VaultCrypto.zero(key)864            null865        }866    }867868    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */869    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {870        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")871        if (currentEpoch() != unlock.epoch) {872            VaultCrypto.zero(key)873            return@withLock UnlockResult.Error("stale_unlock")874        }875        return try {876            val adopted = mutateIfCurrentEpoch(unlock.epoch) {877                derivedKey = key878                decoyMode = true879                entryList = unlock.payload.entries.toMutableList()880                duressConfigured = false881                recoveryWordHash = unlock.payload.recoveryWordHash882                recoveryWordsSet = unlock.payload.recoveryWordsSet883                sessionDataAvailable = true884                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)885                          else VaultCrypto.generateRootKey()886                _settings.value = store.loadSettings()887                publish()888                _isUnlocked.value = true889                sessionEpoch = nextSessionEpoch(sessionEpoch)890            }891            if (adopted == null) {892                VaultCrypto.zero(key)893                UnlockResult.Error("stale_unlock")894            } else {895                UnlockResult.Success896            }897        } catch (e: Exception) {898            // A payload/root/settings failure can occur after the candidate key899            // has been assigned to derivedKey. Tear down only this epoch so a900            // newer session is not disturbed, and scrub the token reference on901            // every error path.902            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }903            VaultCrypto.zero(key)904            UnlockResult.Error(e.message ?: "decoy_unlock_failed")905        }906    }907908    /** Zero a derived [DuressUnlock] that will not be adopted. */909    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()910911    /**912     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an913     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The914     * passphrase is never stored — only its ability to decrypt slot B proves it.915     * Requires the real vault to be unlocked and not itself a decoy session.916     */917    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {918        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().919        val expectedEpoch = mutateState {920            if (derivedKey != null && !decoyMode) sessionEpoch else null921        } ?: return false922        // Reject a duress passphrase that also opens the real vault: it would make the923        // lock screen ambiguous (both slots would decrypt for the same input).924        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }925        var key: ByteArray? = null926        var basePlaintext: ByteArray? = null927        return try {928            val salt = VaultCrypto.generateSalt()929            key = VaultCrypto.deriveKey(duressPassphrase, salt)930            // An empty decoy vault with its own fresh sync root key — nothing real leaks.931            val decoyRoot = VaultCrypto.generateRootKey()932            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }933            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)934            basePlaintext = VaultCodec.encode(decoyPayload)935            // H-04: pad the duress plaintext to slot A's ciphertext length so the936            // duress blob matches slot A's blob on disk. VaultCodec.decode strips937            // trailing NULs, so the extra padding survives encrypt but never938            // reaches the JSON parser.939            // ponytail: only re-pads at configuration time — if slot A grows past940            // its initial bucket (entries cross 4096/16384/etc.), the real decoy941            // stays at the smaller bucket. Re-padding on every persist requires942            // keeping the duress passphrase in memory, which we don't.943            val slotA = store.loadSlotA()944            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0945            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!946            val blob = try {947                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))948            } finally {949                VaultCrypto.zero(plaintext)950                // Padding creates a second array; scrub the original encoded951                // payload too rather than dropping its last reference untouched.952                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)953                basePlaintext = null954                key?.let(VaultCrypto::zero)955                key = null956            }957            beforeDurableCommit()958            val ok = withEpochCommit(expectedEpoch) {959                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))960            } ?: false961            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {962                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }963            } else if (ok) {964                return false965            }966            ok967        } catch (_: Exception) {968            false969        } finally {970            // Cover failures before the encrypt block is entered (for example a971            // codec/copy exception) as well as any early return after key derivation.972            key?.let(VaultCrypto::zero)973            basePlaintext?.let(VaultCrypto::zero)974        }975    }976977    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */978    suspend fun clearDuress(): Boolean = mutex.withLock {979        // H-04: presence-indistinguishable — replace the real decoy with a fake980        // blob of slot A's size rather than removing the duress record. The981        // record stays on disk at the same length, so an at-rest observer982        // cannot tell "duress cleared" from "no duress ever configured".983        val expectedEpoch = currentEpoch()984        val replaced = withEpochCommit(expectedEpoch) {985            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }986                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)987        } ?: false988        if (!replaced) return@withLock false989        // H-03: stateLock-fence the derivedKey/decoyMode read AND the990        // duressConfigured write so a concurrent lock() cannot land between them.991        if (!isSessionEpochCurrent(expectedEpoch)) return false992        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true993        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {994            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }995        }996        true997    }998999    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1000    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1001        var current: Throwable? = error1002        while (current != null) {1003            if (current is javax.crypto.AEADBadTagException) return true1004            // Some Android providers expose a GCM tag failure as the parent1005            // BadPaddingException rather than the AEAD-specific subtype.  A1006            // short ciphertext is an IllegalBlockSizeException and therefore1007            // remains an internal/malformed-payload Error below.1008            if (current is javax.crypto.BadPaddingException) return true1009            current = current.cause1010        }1011        return false1012    }10131014    /** Unlock with an already-derived AES-256 key (biometric path). */1015    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1016        val expectedEpoch = currentEpoch()1017        val slotA = try {1018            store.loadSlotA()1019        } catch (_: Exception) {1020            return@withLock UnlockResult.Error("unlock_key_storage_failed")1021        } ?: return@withLock UnlockResult.Error("no_vault")1022        // Keep the caller-owned key separate from the staged session key. The1023        // biometric caller always zeroes its input in a finally block; adoption1024        // must therefore own a copy and use the same all-or-nothing path as a1025        // password unlock rather than publishing a partially decoded session.1026        var stagedKey: ByteArray? = key.copyOf()1027        return try {1028            val encrypted = try {1029                unb64(slotA.blob)1030            } catch (_: Exception) {1031                return@withLock UnlockResult.Error("invalid_vault_blob")1032            }1033            // A GCM record must include nonce + authentication tag.  Providers1034            // differ on whether a shorter record is reported as BadPadding or1035            // AEADBadTag; reject it before decrypt so malformed storage cannot be1036            // misreported as a user-entered wrong key.1037            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1038                return@withLock UnlockResult.Error("invalid_vault_blob")1039            }1040            val plaintext = try {1041                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1042            } catch (e: Exception) {1043                if (isGcmAuthenticationFailure(e)) {1044                    return@withLock UnlockResult.WrongPassword1045                }1046                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1047            }1048            val payload = try {1049                VaultCodec.decode(plaintext)1050            } catch (_: Exception) {1051                return@withLock UnlockResult.Error("invalid_vault_payload")1052            } finally {1053                VaultCrypto.zero(plaintext)1054            }1055            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1056            val result = adoptSlotA(candidate, payload, expectedEpoch)1057            if (result is UnlockResult.Success) stagedKey = null1058            result1059        } finally {1060            stagedKey?.let(VaultCrypto::zero)1061        }1062    }10631064    fun lock() {1065        // H-03: must observe derivedKey/entryList atomically with any in-flight1066        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1067        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1068        // guards just the brief state reset. Advancing the epoch first invalidates1069        // every operation that captured this session before it reaches a later1070        // durable commit or publication.1071        stateLock.lock()1072        try {1073            sessionEpoch = nextSessionEpoch(sessionEpoch)1074            clearSessionStateLocked()1075        } finally {1076            stateLock.unlock()1077        }1078    }10791080    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L10811082    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1083    // is a stub off-device).1084    @VisibleForTesting1085    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }10861087    /**1088     * Call immediately before launching a system Activity (a SAF file1089     * picker/save dialog, a share sheet) that briefly takes the whole process1090     * out of the foreground as an expected part of an in-app flow — not a1091     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1092     * two apart on its own (it fires for both), and without this, exporting or1093     * importing a file silently locked and wiped the vault mid-flow the moment1094     * the picker took over, before its result ever reached the app.1095     *1096     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1097     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1098     */1099    fun expectTransientBackground() {1100        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1101    }11021103    /**1104     * Consumed once by the next background-lock check; see [expectTransientBackground].1105     * Returns false (and clears the flag) if the suppression has expired or was1106     * never set — a genuine background event must always lock.1107     */1108    fun consumeTransientBackgroundSuppression(): Boolean {1109        val until = suppressNextBackgroundLockUntil1110        val active = until != 0L && nowMs() < until1111        suppressNextBackgroundLockUntil = 0L1112        return active1113    }11141115    // ---- Sync identity ----------------------------------------------------11161117    /**1118     * The vault's stable sync master key (a copy). Caller must zero it. Backs1119     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1120     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1121     */1122    /**1123     * HOLE 5 — the duress vault never syncs.1124     *1125     * [commitDuressUnlock] gives the decoy session a root key so its own codex1126     * and recovery paths work, which means (unlike the browser, where the decoy1127     * path simply has no root key) the sync surface has to refuse explicitly.1128     * Without this the decoy vault would present a second, different vault_id1129     * to a paired device, and a coerced user opening it would make sync visibly1130     * fail on their other devices — a duress indicator, which is the one thing1131     * a duress vault must never be.1132     */1133    // M-04: the "null when locked" half of the contract was documented but never1134    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1135    // [unlockWithRecoveryWords], which installs a root key with no password key1136    // yet) and in a create that failed after staging. Sync must fail closed1137    // there, so the authoritative unlocked flag gates it, not just decoyMode.1138    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)11391140    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1141        if (decoyMode || !_isUnlocked.value) null1142        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1143    }11441145    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1146        mutateState { sessionEpoch == epoch }11471148    internal fun unlockedSessionEpoch(): Long? = mutateState {1149        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1150    }11511152    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key11531154    /**1155     * The current session's root key, decoy included. For local-only uses that1156     * must behave identically in a decoy session — Codex backup and recovery1157     * words — because a feature that errors under duress is itself a duress1158     * tell. In a decoy session this is the DECOY's root key (see1159     * [commitDuressUnlock]), never the real vault's, so neither surface can1160     * leak the real vault. Anything that leaves the device must use1161     * [syncMasterKey] instead.1162     */1163    fun sessionRootKey(): ByteArray? = mutateState {1164        if (!sessionDataAvailable) null else rootKey?.copyOf()1165    }11661167    /** Stable per-vault id two paired devices must share to sync. Null when1168     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1169    fun syncVaultId(): String? = mutateState {1170        if (decoyMode || !_isUnlocked.value) null1171        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1172    }11731174    /**1175     * Browser parity (MV-8): rotate the sync identity by replacing the stable1176     * root key. Entries remain under the master-password key, while sync1177     * credentials, vault_id, and recovery words all change.1178     */1179    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1180        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1181            ?: return false1182        installRootKey(generateRecoverableRootKey(), expectedEpoch)1183    }11841185    /**1186     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1187     * keeping every local entry.1188     *1189     * Sync identity (vault id, signing keys) derives from the root key, and1190     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1191     * created vaults can never reconcile. Taking the other side's root key is1192     * what makes them one vault; from the next sync on, both entry sets merge.1193     *1194     * Entries survive because they are sealed under the password-derived key,1195     * not the root key — the same property [rotateSyncIdentity] relies on.1196     *1197     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1198     * this device's previous recovery words stop working for good, and its new1199     * words are the source vault's words, because words are a deterministic1200     * encoding of the root key.1201     */1202    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1203        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1204            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1205        }1206        val expectedEpoch = mutateState {1207            when {1208                derivedKey == null -> null1209                decoyMode -> Long.MIN_VALUE1210                else -> sessionEpoch1211            }1212        }1213        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1214        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1215        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")12161217        val incoming = hexToRootKey(rootKeyHex)1218        if (incoming.size != VaultCrypto.KEY_SIZE) {1219            VaultCrypto.zero(incoming)1220            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1221        }1222        // Refuse before committing rather than leave this device holding a vault1223        // whose recovery words cannot be typed back. generateRecoverableRootKey1224        // screens its own keys for this; a key arriving from a peer has not been.1225        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1226        if (!roundTrips) {1227            VaultCrypto.zero(incoming)1228            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1229        }12301231        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1232        return if (installRootKey(incoming, expectedEpoch)) {1233            AdoptVaultResult.Success(vaultId)1234        } else {1235            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1236        }1237    }12381239    /**1240     * The recovery words [rootKeyHex] will produce once joined, or null if it is1241     * not a usable root key. Lets the join flow show the user the words that are1242     * about to replace theirs *before* anything is committed.1243     */1244    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1245        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1246        val bytes = hexToRootKey(rootKeyHex)1247        return try {1248            val words = rootKeyToWords(bytes)1249            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1250        } catch (_: Exception) {1251            null1252        } finally {1253            VaultCrypto.zero(bytes)1254        }1255    }12561257    /**1258     * Swap the vault's root key, keeping every entry, and re-escrow under the1259     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1260     * [mutex].1261     */1262    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1263        // H-03: snapshot the live fields under stateLock so the success and the1264        // rollback paths both see consistent prior/new state. Writing the new1265        // rootKey/recovery fields is inside its own mutateState at the end.1266        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1267        // — holding live references would let a zero-on-failure operation also1268        // wipe the session's still-live key arrays.1269        data class Snapshot(1270            val key: ByteArray,1271            val priorRootKey: ByteArray,1272            val priorRecoveryHash: String?,1273            val priorRecoverySet: Boolean,1274        )1275        val snap = mutateIfCurrentEpoch(expectedEpoch) {1276            val k = derivedKey ?: return@mutateIfCurrentEpoch null1277            if (decoyMode) return@mutateIfCurrentEpoch null1278            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1279            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1280        } ?: run {1281            VaultCrypto.zero(nextRootKey)1282            return false1283        }1284        val priorSlot = store.loadSlotA() ?: run {1285            VaultCrypto.zero(snap.key)1286            VaultCrypto.zero(snap.priorRootKey)1287            VaultCrypto.zero(nextRootKey)1288            return false1289        }1290        val nextWords = rootKeyToWords(nextRootKey)12911292        return try {1293            beforeDurableCommit()1294            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1295            if (mutateIfCurrentEpoch(expectedEpoch) {1296                rootKey = nextRootKey1297                recoveryWordHash = recoveryWordsHash(nextWords)1298                recoveryWordsSet = false1299            } == null) throw IllegalStateException("stale_root_rotation")13001301            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1302                ?: throw IllegalStateException("recovery_escrow_failed")1303            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1304                ?: throw IllegalStateException("root_escrow_failed")1305            val plaintext = VaultCodec.encode(currentPayload())1306            val blob = try {1307                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1308            } finally {1309                VaultCrypto.zero(plaintext)1310            }1311            val nextSlot = priorSlot.copy(1312                blob = blob,1313                recoveryEscrow = nextRecoveryEscrow,1314                rootKeyEscrow = nextRootKeyEscrow,1315            )1316            val committed = withEpochCommit(expectedEpoch) {1317                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1318                try {1319                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1320                    // H-04: a root-key rotation re-seals slot A, so the duress slot1321                    // (real or fake) must be re-padded to the new ciphertext length.1322                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1323                    store.stampLocalMutation()1324                } catch (_: Exception) {1325                    store.writeSlotA(priorSlot)1326                    return@withEpochCommit false1327                }1328                true1329            } ?: false1330            if (!committed) throw IllegalStateException("storage_write_failed")1331            mutateIfCurrentEpoch(expectedEpoch) { publish() }1332            true1333        } catch (_: Exception) {1334            val restored = mutateIfCurrentEpoch(expectedEpoch) {1335                rootKey?.let(VaultCrypto::zero)1336                rootKey = snap.priorRootKey.copyOf()1337                recoveryWordHash = snap.priorRecoveryHash1338                recoveryWordsSet = snap.priorRecoverySet1339            }1340            if (restored != null) {1341                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1342            }1343            false1344        } finally {1345            VaultCrypto.zero(snap.key)1346            VaultCrypto.zero(snap.priorRootKey)1347            // On success nextRootKey is owned by rootKey. On every other path1348            // it is not, including a stale epoch after lock(); scrub it while1349            // the local reference is still available.1350            val stillOwnedBySession = mutateState {1351                sessionEpoch == expectedEpoch && rootKey === nextRootKey1352            }1353            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1354        }1355    }13561357    /**1358     * A copy of the live Argon2id-derived vault key, for biometric escrow only1359     * (wrapped under a hardware KeyStore key on biometric enable). Null when1360     * locked. Caller must [VaultCrypto.zero] it after use.1361     */1362    fun currentVaultKey(): ByteArray? = mutateState {1363        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1364    }13651366    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1367        val expectedEpoch = currentEpoch()1368        val slotA = store.loadSlotA() ?: return false1369        var oldKey: ByteArray? = null1370        var newKey: ByteArray? = null1371        var plaintext: ByteArray? = null1372        return try {1373            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1374            // Keep ownership of the decrypted vault plaintext until decoding has1375            // finished.  Passing decrypt() inline made the byte array unreachable1376            // before the finally block, so a successful or failed password change1377            // could leave the full plaintext resident in the heap.1378            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1379            val payload = try {1380                VaultCodec.decode(decrypted)1381            } finally {1382                VaultCrypto.zero(decrypted)1383            }1384            val newSalt = VaultCrypto.generateSalt()1385            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1386            // Re-seal the existing payload under the new key/salt.1387            plaintext = VaultCodec.encode(payload)1388            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1389            // The escrow seals the slot-A PLAINTEXT, which a password change does not1390            // alter — only the wrapping changes — so carry it forward; rebuilding the1391            // StoredVault from scratch would silently drop it.1392            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1393            // content that a password change does not alter — only the slot-A1394            // wrapping rotates. The root-key escrow staying stable across password1395            // changes matches the browser (`vault.ts:363-364`).1396            val next = StoredVault(1397                version = slotA.version,1398                salt = b64(newSalt),1399                blob = blob,1400                recoveryEscrow = slotA.recoveryEscrow,1401                rootKeyEscrow = slotA.rootKeyEscrow,1402            )1403            // H-01: durably commit slot A + size-matched noise + version stamp1404            // before swapping the session key. A partial commit (new A on disk,1405            // old key in memory) would have the next save encrypt under the old1406            // key while matching disk on the new one.1407            val priorB = store.loadSlotB()1408            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1409            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1410            // inside the same stateLock critical section, so a concurrent lock()1411            // can't observe the in-between (zeroed but not yet reassigned).1412            if (mutateIfCurrentEpoch(expectedEpoch) {1413                oldKey?.let(VaultCrypto::zero); oldKey = null1414                derivedKey?.let(VaultCrypto::zero)1415                derivedKey = newKey; newKey = null1416            } == null) return false1417            true1418        } catch (_: Exception) {1419            false1420        } finally {1421            oldKey?.let(VaultCrypto::zero)1422            newKey?.let(VaultCrypto::zero)1423            plaintext?.let(VaultCrypto::zero)1424        }1425    }14261427    /**1428     * Irreversible wipe. Locks the session and clears every vault key. The Pro1429     * license is in a separate store and is preserved by construction.1430     */1431    suspend fun destroyVault() = mutex.withLock {1432        lock()1433        store.clearVault()1434        store.clearDecoySlot()1435    }14361437    // ---- Entry CRUD -------------------------------------------------------14381439    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1440        val epoch = requireUnlocked()1441        val stamp = VaultCrypto.monotonicEntryTimestamp()1442        val now = System.currentTimeMillis()1443        val entry = withDerivedLast4(draft.copy(1444            id = draft.id.ifBlank { UUID.randomUUID().toString() },1445            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1446            modifiedAt = stamp,1447            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1448        ))1449        // H-03: capture rollback AND write entryList under stateLock so a1450        // concurrent lock() cannot zero the session between the read and the write.1451        val rollback = mutateCurrentSession(epoch) {1452            val r = entryList.toMutableList()1453            entryList.add(entry)1454            r1455        } ?: throw IllegalStateException("vault_locked")1456        persistOrRollback(rollback, epoch)1457        entry1458    }14591460    /**1461     * Insert many entries with a single encrypt-and-write at the end.1462     *1463     * Importing a real vault through [addEntry] was quadratic: every entry took1464     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1465     * restore did 806 full-vault writes and hung the app long enough to ANR.1466     * Rolls back to the pre-import list if the one write fails, matching1467     * [addEntry]'s all-or-nothing behaviour.1468     */1469    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1470        val epoch = requireUnlocked()1471        if (drafts.isEmpty()) return 01472        val now = System.currentTimeMillis()1473        val ready = drafts.map { draft ->1474            val stamp = VaultCrypto.monotonicEntryTimestamp()1475            withDerivedLast4(1476                draft.copy(1477                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1478                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1479                    modifiedAt = stamp,1480                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1481                ),1482            )1483        }1484        // H-03: capture rollback AND append every entry under stateLock.1485        val rollback = mutateCurrentSession(epoch) {1486            val r = entryList.toMutableList()1487            entryList.addAll(ready)1488            r1489        } ?: throw IllegalStateException("vault_locked")1490        persistOrRollback(rollback, epoch)1491        return drafts.size1492    }14931494    /**1495     * M-01: replace the whole active set with [newEntries] in one write.1496     *1497     * Import's REPLACE mode used to soft-delete every current entry one durable1498     * write at a time (swallowing each failure) and then batch-add, so a failed1499     * add left the old entries stranded in Trash with nothing restored. Staging1500     * the entire next list in memory and committing it through1501     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1502     * pre-import list — active entries included — is restored.1503     *1504     * Existing tombstones are preserved so paired devices still see the1505     * deletions they already know about.1506     */1507    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1508        val epoch = requireUnlocked()1509        val now = System.currentTimeMillis()1510        val ready = newEntries.map { draft ->1511            val stamp = VaultCrypto.monotonicEntryTimestamp()1512            withDerivedLast4(1513                draft.copy(1514                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1515                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1516                    modifiedAt = stamp,1517                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1518                ),1519            )1520        }1521        // M-03: an incoming entry that shares an id with an existing live entry1522        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1523        // the old one and then appending the new one would leave the list with1524        // two entries of the same id — the tombstoned one would be returned by1525        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1526        // superseded entries from the tombstone set instead.1527        val incomingIds = ready.map { it.id }.toHashSet()1528        val rollback = mutateCurrentSession(epoch) {1529            val r = entryList.toMutableList()1530            val replaced = entryList.map { entry ->1531                if (entry.deletedAt != null) return@map entry1532                if (entry.id in incomingIds) return@map null1533                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1534                entry.copy(deletedAt = stamp, modifiedAt = stamp)1535            }.filterNotNull()1536            entryList = (replaced + ready).toMutableList()1537            r1538        } ?: throw IllegalStateException("vault_locked")1539        persistOrRollback(rollback, epoch)1540        return ready.size1541    }15421543    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1544        val epoch = requireUnlocked()1545        // H-03: read entryList, compute the patched entry, and write it under a1546        // single stateLock so a concurrent lock() cannot interleave between the1547        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1548        // list, or clobber the entry onto the wrong id after a relock).1549        val prepared = mutateCurrentSession(epoch) {1550            val idx = entryList.indexOfFirst { it.id == patch.id }1551            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1552            val current = entryList[idx]1553            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1554            // Only refresh passwordChangedAt when the password value actually changes.1555            val passwordChanged = patch.password != null && patch.password != current.password1556            // Capture the outgoing password (newest first) so the user can reveal/copy/1557            // restore it later. History is computed authoritatively from `current`, not1558            // the patch — updateEntry is a full-entry replace, so trusting patch's value1559            // would let a stale form clobber the real history. Record the age the old1560            // password carried, skip an empty prior, and skip an immediate exact dupe1561            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1562            val priorPassword = current.password1563            val shouldCapture = passwordChanged &&1564                !priorPassword.isNullOrEmpty() &&1565                current.passwordHistory?.firstOrNull()?.password != priorPassword1566            val nextHistory = if (shouldCapture) {1567                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1568                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1569            } else {1570                current.passwordHistory1571            }1572            val updated = withDerivedLast4(patch.copy(1573                createdAt = current.createdAt,1574                modifiedAt = stamp,1575                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1576                passwordHistory = nextHistory,1577            ))1578            val rollback = entryList.toMutableList()1579            entryList[idx] = updated1580            updated to rollback1581        } ?: throw IllegalStateException("vault_locked")1582        val (updated, rollback) = prepared1583        persistOrRollback(rollback, epoch)1584        updated1585    }15861587    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1588    suspend fun deleteEntry(id: String) = mutex.withLock {1589        val epoch = requireUnlocked()1590        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1591        // the list out from under the indexOfFirst.1592        val rollback = mutateCurrentSession(epoch) {1593            val idx = entryList.indexOfFirst { it.id == id }1594            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1595            val current = entryList[idx]1596            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1597            val prior = entryList.toMutableList()1598            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1599            prior1600        } ?: throw IllegalStateException("vault_locked")1601        persistOrRollback(rollback, epoch)1602    }16031604    suspend fun restoreEntry(id: String) = mutex.withLock {1605        val epoch = requireUnlocked()1606        val rollback = mutateCurrentSession(epoch) {1607            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1608            if (idx == -1) throw IllegalStateException("deleted entry not found")1609            val current = entryList[idx]1610            val prior = entryList.toMutableList()1611            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1612            prior1613        } ?: throw IllegalStateException("vault_locked")1614        persistOrRollback(rollback, epoch)1615    }16161617    /** Permanent delete — no tombstone, no recovery. */1618    suspend fun purgeEntry(id: String) = mutex.withLock {1619        val epoch = requireUnlocked()1620        val rollback = mutateCurrentSession(epoch) {1621            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1622            if (idx == -1) throw IllegalStateException("deleted entry not found")1623            val prior = entryList.toMutableList()1624            entryList.removeAt(idx)1625            prior1626        } ?: throw IllegalStateException("vault_locked")1627        persistOrRollback(rollback, epoch)1628    }16291630    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1631    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1632        val epoch = requireUnlocked()1633        // H-03: read+remove under stateLock so lock() can't reorder the remove.1634        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1635        val r = mutateCurrentSession(epoch) {1636            val rollback = entryList.toMutableList()1637            val before = entryList.size1638            entryList.removeAll { it.deletedAt != null }1639            R(rollback, before - entryList.size)1640        } ?: throw IllegalStateException("vault_locked")1641        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1642        r.removed1643    }16441645    /** Browser parity: number of active entries that dedupe would move to trash. */1646    suspend fun countDuplicateEntries(): Int = mutex.withLock {1647        val epoch = requireUnlocked()1648        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1649        // lock() can't race the read with state writes.1650        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1651            ?: throw IllegalStateException("vault_locked")1652    }16531654    /**1655     * Browser parity: soft-delete duplicate entries, keeping the most recently1656     * modified member of each group. Refuse in decoy mode so the duress vault is1657     * not reshaped into a forensically distinct state.1658     */1659    suspend fun deduplicateEntries(): Int = mutex.withLock {1660        val epoch = requireUnlocked()1661        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1662        val ready = mutateCurrentSession(epoch) {1663            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1664            val active = entryList.filter { it.deletedAt == null }1665            val keepIds = mutableSetOf<String>()1666            active.groupBy { it.duplicateKey() }.values.forEach { group ->1667                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1668            }1669            val rollback = entryList.toMutableList()1670            var removed = 01671            entryList = entryList.map { entry ->1672                if (entry.deletedAt == null && entry.id !in keepIds) {1673                    removed++1674                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1675                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1676                } else {1677                    entry1678                }1679            }.toMutableList()1680            R(rollback, removed)1681        } ?: throw IllegalStateException("vault_locked")1682        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1683        ready.removed1684    }16851686    fun getEntries(): List<VaultEntry> = mutateState {1687        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1688    }1689    fun getEntry(id: String): VaultEntry? = mutateState {1690        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1691    }1692    fun getDeletedEntries(): List<VaultEntry> = mutateState {1693        if (!sessionDataAvailable) emptyList()1694        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1695    }16961697    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1698    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1699        return mutateState {1700            if (!sessionDataAvailable) return@mutateState emptyList()1701            val active = entryList.filter { it.deletedAt == null }1702            if (tagFilter == null) return@mutateState active1703            val needle = tagFilter.trim().lowercase()1704            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1705        }1706    }17071708    /** Sorted, de-duplicated tags across all active entries. */1709    fun getDistinctTags(): List<String> = mutateState {1710        if (!sessionDataAvailable) return@mutateState emptyList()1711        entryList.asSequence()1712            .filter { it.deletedAt == null }1713            .flatMap { it.tags?.asSequence() ?: emptySequence() }1714            .map { it.trim().lowercase() }1715            .filter { it.isNotEmpty() }1716            .distinct()1717            .sorted()1718            .toList()1719    }17201721    /**1722     * Distinct non-blank values for an identity field across active entries,1723     * most-frequently-used first. Supports "username" and "email"; unknown field1724     * names return an empty list. Values keep original casing (deduped1725     * case-insensitively) so a suggestion matches what the user typed.1726     */1727    fun getDistinctFieldValues(field: String): List<String> {1728        val select: (VaultEntry) -> String? = when (field) {1729            "username" -> { e -> e.username }1730            "email" -> { e -> e.email }1731            else -> return emptyList()1732        }1733        return mutateState {1734            if (!sessionDataAvailable) return@mutateState emptyList()1735            entryList.asSequence()1736                .filter { it.deletedAt == null }1737                .mapNotNull { select(it)?.trim() }1738                .filter { it.isNotEmpty() }1739                .groupingBy { it.lowercase() }1740                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1741                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1742                }1743                .values1744                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1745                .map { it.first }1746        }1747    }17481749    fun getTombstones(): List<Tombstone> = mutateState {1750        if (!sessionDataAvailable) emptyList()1751        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1752    }1753    fun getVaultVersion(): Long = store.version()1754    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots17551756    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1757        val expectedEpoch = currentEpoch()1758        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1759        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1760        // H-04: snapshots now carry the duress slot too — restore preserves1761        // the at-rest triad (A/B/D), not just the A/B pair.1762        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1763        val priorA = store.loadSlotA()1764        val priorB = store.loadSlotB()1765        val priorD = store.loadDecoySlot()1766        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1767        // slot write — the old code took this snapshot, then stranded it on1768        // every failure, polluting history with a "restore" entry that never1769        // restored anything. The history below is rolled back together with1770        // the slots if the commit fails.1771        val priorHistory = store.loadVaultHistory()1772        // P1-4 (codex): the recovery snapshot must land before we commit the1773        // slot pair, otherwise a failed restore cannot be rolled back to the1774        // pre-restore history. If the history write itself fails, refuse to1775        // proceed — the alternative is a successful restore with no undo path.1776        beforeDurableCommit()1777        if (!takeVaultSnapshot("restore", expectedEpoch)) {1778            return false1779        }1780        // H-04: legacy snapshots without vault_d, or vault_d that fails to1781        // decode, fall back to a fake duress of slot A's size — presence-1782        // indistinguishable from any other freshly-restored vault.1783        val nextD: StoredVault = restoredD ?: run {1784            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161785            val fakeBlob = ByteArray(target).also(rng::nextBytes)1786            val fakeSalt = VaultCrypto.generateSalt()1787            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1788        }1789        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1790            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1791            return false1792        }1793        lock()1794        true1795    }17961797    /**1798     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1799     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1800     * (a restore preserves the duress-shaped blob from history), not a fresh1801     * noise record. Any failure rolls back so the at-rest pair never holds a1802     * restored slot A against a pre-restore slot B. Caller is responsible for1803     * restoring the pre-snapshot history on a false return.1804     *1805     * H-04: duress slot is now part of the atomic triad — restored last so1806     * a duress-write failure rolls the A/B pair back too.1807     */1808    private fun commitRestoreAtomic(1809        nextA: StoredVault,1810        nextB: String,1811        nextD: StoredVault,1812        priorA: StoredVault?,1813        priorB: String?,1814        priorD: StoredVault?,1815        expectedEpoch: Long,1816    ): Boolean {1817        return withEpochCommit(expectedEpoch) {1818            if (!store.writeSlotA(nextA)) return@withEpochCommit false1819            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1820            // must trigger rollback; previously a throw escaped without restoring1821            // the prior slot A and history.1822            try {1823                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1824                // H-04: duress slot is part of the restore triad. Failures here1825                // roll A+B back so the at-rest state stays consistent.1826                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1827                store.stampLocalMutation()1828            } catch (_: Exception) {1829                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1830                return@withEpochCommit false1831            }1832            true1833        } ?: false1834    }18351836    // ---- Sync apply (does NOT advance the version) ------------------------18371838    /**1839     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1840     * WITHOUT bumping the version counter, so a sync round cannot cascade into1841     * another outbound sync. Returns the number of locally-applied changes.1842     */1843    suspend fun applyMergedRemoteEntries(1844        remoteEntries: List<VaultEntry>,1845        remoteTombstones: List<Tombstone>,1846        expectedEpoch: Long? = null,1847    ): Int = mutex.withLock {1848        val epoch = expectedEpoch ?: requireUnlocked()1849        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01850        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1851        // BEFORE any comparison against a local one. Without this a peer can1852        // send a far-future modifiedAt and permanently pin its version of an1853        // entry against every honest local edit. The browser clamps in the1854        // matching place (vault.ts applyMergedRemoteEntries).1855        val mergeNow = System.currentTimeMillis()1856        val clamped = remoteEntries.map { entry ->1857            entry.copy(1858                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1859                deletedAt = entry.deletedAt?.let {1860                    SyncLimits.clampSyncTimestamp(it, mergeNow)1861                },1862            )1863        }1864        val tombMap = remoteTombstones.associate {1865            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1866        }1867        // H-03: build the new entryList under stateLock so a concurrent lock()1868        // cannot zero derivedKey/entryList between the byId read and the merge.1869        val applied = mutateCurrentSession(epoch) {1870            var n = 01871            val byId = entryList.associateBy { it.id }.toMutableMap()18721873            for (remote in clamped) {1874                val tomb = tombMap[remote.id]1875                if (tomb != null && tomb >= remote.modifiedAt) continue1876                val local = byId[remote.id]1877                if (local == null) {1878                    entryList.add(remote); byId[remote.id] = remote; n++1879                } else if (remote.modifiedAt > local.modifiedAt) {1880                    val i = entryList.indexOfFirst { it.id == local.id }1881                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1882                }1883            }18841885            for ((id, deletedAt) in tombMap) {1886                val local = byId[id] ?: continue1887                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1888                if (deletedAt < local.modifiedAt) continue1889                val i = entryList.indexOfFirst { it.id == id }1890                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1891            }1892            n1893        } ?: return@withLock 018941895        if (applied > 0) {1896            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)1897            mutateIfCurrentEpoch(epoch) { publish() }1898        }1899        applied1900    }19011902    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1903        // Require a fully live session before exposing a settings mutation.  The1904        // epoch is captured with the read so a lock/new-session boundary cannot1905        // let stale work publish into the replacement session.1906        val epoch = requireUnlocked()1907        val prior = mutateCurrentSession(epoch) {1908            val old = _settings.value1909            _settings.value = settings1910            old1911        } ?: throw IllegalStateException("vault_locked")19121913        val committed = try {1914            beforeDurableCommit()1915            // The durable write is inside the same epoch fence as the in-memory1916            // publication.  If lock() won first, this returns null and no stale1917            // settings reach storage or the public StateFlow.1918            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true1919        } catch (_: Exception) {1920            false1921        }1922        if (!committed) {1923            // A live-session storage failure rolls back the public value.  A1924            // stale epoch is already reset by lock()/the new session and must1925            // not be allowed to overwrite that safe state.1926            mutateIfCurrentEpoch(epoch) { _settings.value = prior }1927            throw IllegalStateException("settings_write_failed")1928        }1929    }19301931    // ---- Recovery words (vault metadata) ----------------------------------19321933    /** True once the user has confirmed their recovery words for this vault. */1934    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }19351936    /**1937     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1938     * Persisted without bumping the version — recovery metadata is local-only and1939     * is not an entry mutation paired devices should sync against.1940     */1941    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1942        val epoch = requireUnlocked()1943        // H-03: hash + escrow-related state fields set under stateLock so a1944        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.1945        // M-04: the flags have to be live before the escrows are built — both seal1946        // currentPayload() — so capture the prior pair and put it back if the write1947        // never lands. Otherwise the session reports words set against a slot A1948        // that still holds no escrow, and the recovery screen never offers a retry.1949        val prior = mutateCurrentSession(epoch) {1950            val priorPair = recoveryWordHash to recoveryWordsSet1951            recoveryWordHash = recoveryWordsHash(words)1952            recoveryWordsSet = true1953            priorPair1954        } ?: return@withLock false1955        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow1956        // (cross-platform recovery of the sync root key) and Android's whole-payload1957        // escrow (offline forgot-password restore of entries). Both derive their key1958        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.1959        val persisted = runCatching {1960            persistSlotAWithEscrows(1961                buildRecoveryEscrow(words),1962                buildRootKeyEscrow(words),1963                expectedEpoch = epoch,1964            )1965        }.getOrDefault(false)1966        if (!persisted) {1967            mutateIfCurrentEpoch(epoch) {1968                recoveryWordHash = prior.first1969                recoveryWordsSet = prior.second1970            }1971        }1972        persisted1973    }19741975    /** Verify entered words against the stored recovery hash. */1976    fun verifyRecoveryWords(words: List<String>): Boolean {1977        return mutateState {1978            val stored = recoveryWordHash ?: return@mutateState false1979            stored == recoveryWordsHash(words)1980        }1981    }19821983    // ---- Recovery escrow (forgot-password) --------------------------------19841985    /** True at rest if this vault has any recovery escrow the recovery flow can use. */1986    fun hasRecoveryEscrow(): Boolean =1987        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false19881989    /**1990     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of1991     * the master password. On success the slot-A plaintext sealed in the escrow is1992     * adopted as the live session (entries, rootKey, settings), but no Argon2id1993     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a1994     * new password and finish unlocking. Returns false when there is no escrow, the1995     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).1996     *1997     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no1998     * detached vault key to return here — slot A is wrapped under the rotating1999     * Argon2id key — so this opens the escrowed payload directly.2000     */2001    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2002        val expectedEpoch = currentEpoch()2003        val slotA = store.loadSlotA() ?: return false2004        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false20052006        // Prefer Android's whole-payload escrow: a full offline restore of entries,2007        // root key, and settings. Present for Android-created vaults.2008        val fullEscrow = slotA.recoveryEscrow2009        if (fullEscrow != null) {2010            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2011            if (material != null) {2012                var keyBytes: ByteArray? = null2013                var plaintext: ByteArray? = null2014                var candidateRoot: ByteArray? = null2015                var stateCommitStarted = false2016                try {2017                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2018                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2019                    val payload = VaultCodec.decode(plaintext)2020                    // Stage every fallible operation before touching live session2021                    // fields.  In particular, a settings/provider/decode failure2022                    // cannot leave entries, flags, or a root key resident while the2023                    // public session remains locked.2024                    val candidateSettings = store.loadSettings()2025                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2026                        val decodedRoot = unb64(payload.rootKeyB64)2027                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2028                            // Do not let a malformed authenticated payload abandon2029                            // this decoded root-key candidate before the outer2030                            // finally block owns it.2031                            VaultCrypto.zero(decodedRoot)2032                            throw IllegalArgumentException("invalid recovery root key")2033                        }2034                        decodedRoot2035                    } else {2036                        generateRecoverableRootKey()2037                    }2038                    stateCommitStarted = true2039                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2040                        clearSessionStateLocked()2041                        entryList = payload.entries.toMutableList()2042                        duressConfigured = payload.duressConfigured2043                        recoveryWordHash = payload.recoveryWordHash2044                        recoveryWordsSet = payload.recoveryWordsSet2045                        rootKey = candidateRoot2046                        candidateRoot = null2047                        _settings.value = candidateSettings2048                        publish()2049                        // This is the authenticated recovery half-unlock boundary:2050                        // only set it after every candidate field is installed.2051                        sessionDataAvailable = true2052                        _isUnlocked.value = false2053                        true2054                    }2055                    if (adopted == true) return@withLock true2056                } catch (_: Exception) {2057                    // Wrong words, malformed payloads, provider failures, and2058                    // settings failures all fall through to the browser-compatible2059                    // root-key escrow.  If the state commit had started, invalidate2060                    // that epoch so no partial candidate survives.2061                    if (stateCommitStarted) {2062                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2063                    }2064                } finally {2065                    plaintext?.let(VaultCrypto::zero)2066                    VaultCrypto.zero(material)2067                    keyBytes?.let(VaultCrypto::zero)2068                    candidateRoot?.let(VaultCrypto::zero)2069                }2070            }2071        }20722073        // Browser-format vault (only the root-key escrow): recover the 32-byte root2074        // key (sync identity) and reset the password; entries return via sync. This2075        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2076        val rootEscrow = slotA.rootKeyEscrow2077        if (rootEscrow != null) {2078            var candidateRoot: ByteArray? = null2079            var stateCommitStarted = false2080            try {2081                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2082                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2083                val candidateSettings = store.loadSettings()2084                stateCommitStarted = true2085                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2086                    clearSessionStateLocked()2087                    entryList = mutableListOf()2088                    duressConfigured = false2089                    recoveryWordHash = recoveryWordsHash(words)2090                    recoveryWordsSet = true2091                    rootKey = candidateRoot2092                    candidateRoot = null2093                    _settings.value = candidateSettings2094                    publish()2095                    sessionDataAvailable = true2096                    _isUnlocked.value = false2097                    true2098                }2099                if (adopted == true) return@withLock true2100            } catch (_: Exception) {2101                if (stateCommitStarted) {2102                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2103                }2104            } finally {2105                candidateRoot?.let(VaultCrypto::zero)2106            }2107        }2108        return false2109    }21102111    /**2112     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2113     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2114     * words, mark the session fully unlocked, and publish. Returns false if called2115     * without a prior successful recovery unlock (no in-memory root key).2116     *2117     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2118     * into one step, since there is no detached vault key here.2119     */2120    suspend fun resetPasswordWithRecoveryWords(2121        words: List<String>,2122        newPassword: String,2123    ): Boolean = mutex.withLock {2124        val expectedEpoch = currentEpoch()2125        // H-03: read rootKey + entryList under stateLock before computing the new2126        // plaintext, then re-publish + swap the derived key under stateLock so a2127        // concurrent lock() cannot straddle the two fences.2128        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2129        val priorA = store.loadSlotA() ?: return false2130        var newKey: ByteArray? = null2131        var plaintext: ByteArray? = null2132        return try {2133            val newSalt = VaultCrypto.generateSalt()2134            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2135            plaintext = VaultCodec.encode(currentPayload())2136            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2137            // Rebuild both escrows against the recovered session so their salts rotate2138            // with the password reset; fall back to the prior escrows if rebuild fails2139            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2140            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2141            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2142            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2143            val next = StoredVault(2144                version = priorA.version,2145                salt = b64(newSalt),2146                blob = blob,2147                recoveryEscrow = freshRecovery,2148                rootKeyEscrow = freshRootKey,2149            )2150            // H-01: see changePassword — both rotations share the same2151            // commit-or-keep-old-key contract so a partial write can't strand2152            // the session on a key that no longer matches disk.2153            val priorB = store.loadSlotB()2154            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2155            // H-03 (P1-2 codex): zero the old live key and install the new one2156            // inside the same stateLock critical section so a concurrent lock()2157            // can't observe the in-between.2158            if (mutateIfCurrentEpoch(expectedEpoch) {2159                derivedKey?.let(VaultCrypto::zero)2160                derivedKey = newKey; newKey = null2161                publish()2162                _isUnlocked.value = true2163            } == null) return false2164            true2165        } catch (_: Exception) {2166            false2167        } finally {2168            newKey?.let(VaultCrypto::zero)2169            plaintext?.let(VaultCrypto::zero)2170        }2171    }21722173    /**2174     * Seal the current slot-A plaintext under a key derived from the 12 BIP392175     * recovery [words] (Android offline-restore escrow). IKM is the browser's2176     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2177     * Returns null for a checksum-invalid phrase.2178     */2179    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2180        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2181        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2182        var keyBytes: ByteArray? = null2183        var plaintext: ByteArray? = null2184        return try {2185            val salt = VaultCrypto.generateSalt()2186            val recoveryKey = deriveRecoveryKey(material, salt)2187            keyBytes = recoveryKey.encoded2188            plaintext = VaultCodec.encode(currentPayload())2189            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2190            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2191        } finally {2192            VaultCrypto.zero(material)2193            keyBytes?.let(VaultCrypto::zero)2194            plaintext?.let(VaultCrypto::zero)2195        }2196    }21972198    /**2199     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2200     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2201     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2202     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2203     * the same root key on the browser. Returns null with no in-memory root key or2204     * a checksum-invalid phrase.2205     */2206    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2207        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2208        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2209        val rk = mutateState { rootKey?.copyOf() } ?: run {2210            VaultCrypto.zero(material)2211            return null2212        }2213        var keyBytes: ByteArray? = null2214        var plaintext: ByteArray? = null2215        return try {2216            val salt = VaultCrypto.generateSalt()2217            val recoveryKey = deriveRecoveryKey(material, salt)2218            keyBytes = recoveryKey.encoded2219            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2220            // combined = nonce(12) || ciphertext || tag(16)2221            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2222            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2223            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2224            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2225            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2226            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2227        } finally {2228            VaultCrypto.zero(rk)2229            VaultCrypto.zero(material)2230            keyBytes?.let(VaultCrypto::zero)2231            plaintext?.let(VaultCrypto::zero)2232        }2233    }22342235    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2236    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2237        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2238        var keyBytes: ByteArray? = null2239        var pt: ByteArray? = null2240        return try {2241            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2242            keyBytes = recoveryKey.encoded2243            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2244            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2245            hexToRootKey(String(pt, Charsets.US_ASCII))2246        } catch (_: Exception) {2247            null2248        } finally {2249            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2250            // IKM-equivalent of the vault identity. material+keyBytes were2251            // already zeroed; pt was leaking.2252            VaultCrypto.zero(material)2253            keyBytes?.let(VaultCrypto::zero)2254            pt?.let(VaultCrypto::zero)2255        }2256    }22572258    /**2259     * Re-seal slot A from the current in-memory state under the live key, carrying2260     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2261     * in decoy mode (recovery is never offered for the decoy) and persists without2262     * a version bump, like the recovery-words metadata write it replaces.2263     */2264    private fun persistSlotAWithEscrows(2265        recoveryEscrow: RecoveryEscrow?,2266        rootKeyEscrow: RootKeyEscrow?,2267        expectedEpoch: Long? = null,2268    ): Boolean {2269        val snapshot = snapshotForPersist(expectedEpoch)2270            ?: throw IllegalStateException("vault_locked_or_stale")2271        if (snapshot.decoy) {2272            VaultCrypto.zero(snapshot.key)2273            return false2274        }2275        try {2276            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2277            val plaintext = VaultCodec.encode(snapshot.payload)2278            val blob = try {2279                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2280            } finally {2281                // The escrow path used to drop this reference before zeroing it;2282                // keep it live through the fill on both success and failure.2283                VaultCrypto.zero(plaintext)2284            }2285            val next = prior.copy(2286                blob = blob,2287                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2288                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2289            )2290            beforeDurableCommit()2291            return withEpochCommit(snapshot.epoch) {2292                if (!store.writeSlotA(next)) return@withEpochCommit false2293                try {2294                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2295                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2296                    // the duress slot too. A real decoy is left alone; a fake is2297                    // re-sized to slot A's new ciphertext length.2298                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2299                } catch (e: Exception) {2300                    store.writeSlotA(prior)2301                    throw e2302                }2303                true2304            } ?: false2305        } finally {2306            VaultCrypto.zero(snapshot.key)2307        }2308    }23092310    // ---- Internals --------------------------------------------------------23112312    private fun requireUnlocked(): Long {2313        // H-03: read derivedKey atomically with [lock]'s reset; without it,2314        // [lock] from a background lifecycle event can run between this read2315        // and the caller's first state touch.2316        stateLock.lock()2317        try {2318            if (derivedKey == null) throw IllegalStateException("vault_locked")2319            return sessionEpoch2320        } finally {2321            stateLock.unlock()2322        }2323    }23242325    /** A locked or half-adopted session must not expose secret-bearing fields. */2326    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null23272328    private fun countDuplicateEntriesLocked(): Int {2329        val seen = mutableMapOf<String, VaultEntry>()2330        var duplicates = 02331        for (entry in entryList.filter { it.deletedAt == null }) {2332            val key = entry.duplicateKey()2333            val existing = seen[key]2334            if (existing == null) {2335                seen[key] = entry2336            } else {2337                duplicates++2338                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2339                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2340                if (entryStamp > existingStamp) seen[key] = entry2341            }2342        }2343        return duplicates2344    }23452346    private fun VaultEntry.duplicateKey(): String {2347        if (type == "note") return "note|${title.lowercase()}"2348        val passkeyData = passkey2349        if (passkeyData != null) {2350            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2351        }2352        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2353    }23542355    /**2356     * Capture the current slot pair (and metadata) into the history list.2357     * Returns true when the history write landed, false when it didn't — callers2358     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2359     * must check the result and roll back if it does not.2360     */2361    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2362        val epoch = expectedEpoch ?: currentEpoch()2363        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2364        val slotA = store.loadSlotA() ?: return false2365        val vaultA = VaultCodec.encodeSlot(slotA)2366        val vaultB = store.loadSlotB() ?: slotA.blob2367        // H-04: capture the duress slot so a restore preserves the at-rest2368        // triad, not just the A/B pair. The duress slot is always present2369        // (real or fake), so loadDecoySlot() returning null is a logic error2370        // — fall through to a fake duress in that case rather than silently2371        // losing the record from the snapshot.2372        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2373            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162374            val fakeBlob = ByteArray(target).also(rng::nextBytes)2375            val fakeSalt = VaultCrypto.generateSalt()2376            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2377        }2378        val history = store.loadVaultHistory()2379        val latest = history.snapshots.firstOrNull()2380        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2381        val entryCount = mutateIfCurrentEpoch(epoch) {2382            if (derivedKey != null && !decoyMode) {2383                bucketEntryCount(entryList.count { it.deletedAt == null })2384            } else {2385                null2386            }2387        } ?: return false2388        return try {2389            val snapshot = VaultSnapshot(2390                id = UUID.randomUUID().toString(),2391                savedAt = System.currentTimeMillis(),2392                reason = reason,2393                vault_a = vaultA,2394                vault_b = vaultB,2395                vault_d = vaultD,2396                entryCount = entryCount,2397            )2398            // P1-4 (codex): return the history-write boolean to callers so a2399            // false return no longer leaves the snapshot out of history while2400            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2401            withEpochCommit(epoch) {2402                store.writeVaultHistory(2403                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2404                )2405            } ?: false2406        } catch (_: Exception) {2407            false2408        }2409    }24102411    private fun bucketEntryCount(n: Int): Int = when {2412        n <= 0 -> 02413        n <= 10 -> 102414        n <= 25 -> 252415        n <= 50 -> 502416        n <= 100 -> 1002417        n <= 250 -> 2502418        n <= 500 -> 5002419        else -> ((n + 499) / 500) * 5002420    }24212422    /**2423     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2424     * so it is stored explicitly and survives the sync wire format rather than being2425     * re-derived at render time. Non-card entries pass through unchanged.2426     *2427     * M-03: never overwrite a stored last4 with null. A card kept only as last42428     * (e.g. after a PeachScript import that had no PAN) must keep that value;2429     * re-deriving from an absent cardNumber would silently zero the field.2430     */2431    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2432        if (!entry.isCard()) return entry2433        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2434        val derived = if (digits.length >= 4) digits.takeLast(4) else null2435        return entry.copy(last4 = derived ?: entry.last4)2436    }24372438    /**2439     * On unlock, hard-delete trashed entries older than the retention window.2440     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2441     * (the app's "Never" option), so nothing is purged. Persists without a2442     * version bump and without touching the publish state (caller publishes).2443     */2444    private fun autoPurgeExpiredTrash() {2445        val days = _settings.value.trashRetentionDays ?: return2446        if (days <= 0) return2447        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2448        val before = entryList.size2449        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2450        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2451    }24522453    /** The slot-A plaintext envelope for the current in-memory session. */2454    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }24552456    private fun currentPayloadLocked(): VaultPayload =2457        VaultPayload(2458            entries = entryList.toList(),2459            duressConfigured = duressConfigured,2460            rootKeyB64 = rootKey?.let(::b64) ?: "",2461            recoveryWordHash = recoveryWordHash,2462            recoveryWordsSet = recoveryWordsSet,2463        )24642465    /**2466     * Adopt the payload's root key, lazily minting one for legacy payloads.2467     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2468     * a root key is not a mutation paired devices should sync against.2469     */2470    private fun ensureRootKey(payload: VaultPayload) {2471        if (payload.rootKeyB64.isNotBlank()) {2472            rootKey = unb64(payload.rootKeyB64)2473            return2474        }2475        rootKey = generateRecoverableRootKey()2476        runCatching { persistSlotA(bumpVersion = false) }2477    }24782479    private fun publish() {2480        _entries.value = entryList.filter { it.deletedAt == null }2481    }24822483    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2484        var plaintext = VaultCodec.encode(currentPayload())2485        return try {2486            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2487            StoredVault(version = 1, salt = b64(salt), blob = blob)2488        } finally {2489            // M-05: full vault plaintext is allocated here once per createVault.2490            // Zeroing in finally keeps the transient copy off the heap after the2491            // caller either succeeds or rolls back via lock().2492            VaultCrypto.zero(plaintext)2493        }2494    }24952496    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2497        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2498        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2499        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2500        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2501        val noise = ByteArray(approxBytes).also(rng::nextBytes)2502        return store.writeSlotB(b64(noise))2503    }25042505    /** H-04: the ciphertext length slot A is using right now, so the duress2506     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2507     *  length equals ciphertext length, so this also matches slot A's plaintext. */2508    private fun matchingCiphertextLength(slotA: StoredVault): Int =2509        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)25102511    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2512     *  bytes. Replaces a real decoy when the user clears duress, and is the2513     *  initial duress slot when no decoy is configured — so the record's2514     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2515     *  matches slot A's blob length. */2516    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2517        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162518        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2519        val fakeSalt = VaultCrypto.generateSalt()2520        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2521    }25222523    /** H-04: keep the duress record presence- and size-indistinguishable from2524     *  slot A on every persist. A real decoy is left alone (it was padded at2525     *  setDuressPassphrase time, and we don't have the duress key here to2526     *  re-pad on entry growth — that's an accepted residual tell, see the2527     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2528     *  A's size. */2529    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2530        val isConfigured = mutateState { duressConfigured }2531        return if (isConfigured) true else writeFakeDuressSlot(slotA)2532    }25332534    /** Re-seal slot A from the current in-memory state under the live key. */2535    private fun persistSlotA(2536        bumpVersion: Boolean,2537        snapshotReason: String? = "local_save",2538        expectedEpoch: Long? = null,2539    ): Boolean {2540        val snapshot = snapshotForPersist(expectedEpoch)2541            ?: throw IllegalStateException("vault_locked_or_stale")2542        return try {2543            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2544            if (snapshot.decoy) {2545                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2546                val plaintext = VaultCodec.encode(snapshot.payload)2547                val blob = try {2548                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2549                } finally {2550                    // Keep the last reference alive until the zero, including the2551                    // successful encrypt path (M-05).2552                    VaultCrypto.zero(plaintext)2553                }2554                beforeDurableCommit()2555                return withEpochCommit(snapshot.epoch) {2556                    store.writeDecoySlot(prior.copy(blob = blob))2557                } ?: false2558            }2559            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2560            // H-01: captured BEFORE the noise write so a stamp failure can restore2561            // the prior slot B alongside the prior slot A. Slot B is always2562            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2563            // leaves (new A, new B, old version) on disk — partial commit.2564            val priorB = store.loadSlotB()2565            beforeDurableCommit()2566            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2567            val plaintext = VaultCodec.encode(snapshot.payload)2568            val blob = try {2569                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2570            } finally {2571                // Do not drop [plaintext] before fill(0): the serialized vault2572                // contains every entry password and is the primary M-05 target.2573                VaultCrypto.zero(plaintext)2574            }2575            val next = prior.copy(blob = blob)2576            // The epoch check and the entire A/B/D/version commit share one brief2577            // stateLock critical section. lock() either wins before this block or2578            // waits until the already-committed write is complete; it can never2579            // clear the session between the check and the durable write.2580            withEpochCommit(snapshot.epoch) {2581                if (!store.writeSlotA(next)) return@withEpochCommit false2582                try {2583                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2584                    // H-04: duress slot stays present (real one untouched, fake one2585                    // re-padded to slot A's current size on every persist).2586                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2587                    if (bumpVersion) store.stampLocalMutation()2588                } catch (e: Exception) {2589                    store.writeSlotA(prior)2590                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2591                    throw e2592                }2593                true2594            } ?: false2595        } finally {2596            // Snapshot keys are copies, so lock() may zero the live key while this2597            // operation is still deriving/encrypting; always scrub our copy too.2598            VaultCrypto.zero(snapshot.key)2599        }2600    }26012602    /**2603     * H-01: durably commit a slot-A rotation by writing slot A, its2604     * size-matched noise slot B, and the local-mutation stamp in order. Any2605     * failure rolls the previously-committed steps back so the at-rest pair2606     * matches what was there before the call. Returns true only when every2607     * step succeeded — callers must NOT swap their session key on false.2608     */2609    private fun commitVaultRotation(2610        priorA: StoredVault,2611        priorB: String?,2612        nextA: StoredVault,2613        expectedEpoch: Long,2614    ): Boolean {2615        beforeDurableCommit()2616        return withEpochCommit(expectedEpoch) {2617            if (!store.writeSlotA(nextA)) return@withEpochCommit false2618            // P1-1 (codex): the noise write's boolean AND any thrown exception from2619            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2620            // escaped the helper and left slot A committed.2621            try {2622                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2623                // H-04: a password rotation changes slot A's plaintext size (new2624                // salt → re-encrypted blob), so the duress slot must be re-padded2625                // alongside. writeCompanionDuressSlot leaves a real decoy alone2626                // and re-emits a fake of slot A's size otherwise.2627                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2628                store.stampLocalMutation()2629            } catch (_: Exception) {2630                rollbackRotation(priorA, priorB)2631                return@withEpochCommit false2632            }2633            true2634        } ?: false2635    }26362637    /**2638     * Best-effort undo of the writes [commitVaultRotation] may have committed.2639     * Each step runs independently — a failed rollback must not mask the2640     * original commit failure. Log is a throwing stub off-device.2641     */2642    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2643        val failures = mutableListOf<String>()2644        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2645            .onFailure { failures += "writeSlotA: $it" }2646        if (priorB != null) {2647            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2648                .onFailure { failures += "writeSlotB: $it" }2649        }2650        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2651    }26522653    /** Persist a local mutation, rolling the in-memory list back on failure. */2654    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2655        try {2656            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2657                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2658                throw IllegalStateException("storage_write_failed")2659            }2660        } catch (e: Exception) {2661            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2662            throw e2663        }2664        // A lock can win in the small interval after the durable commit and2665        // before publication. The epoch check prevents pre-lock entries from2666        // being republished into a newly locked (or newly unlocked) session.2667        mutateIfCurrentEpoch(expectedEpoch) { publish() }2668    }2669}