VaultRepository.kt

100.0 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.util.Log12import com.peachpasswords.BuildConfig13import com.peachpasswords.sync.SyncLimits14import kotlinx.serialization.json.JsonElement15import kotlinx.serialization.json.JsonNull16import kotlinx.serialization.json.JsonObject17import kotlinx.serialization.json.JsonPrimitive18import java.util.Base6419import java.util.UUID20import java.util.concurrent.locks.ReentrantLock21import javax.inject.Inject22import javax.inject.Singleton2324/** Lenient parser for the Codex vault-blob wrapper only. */25private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2627private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)28private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)2930/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */31private const val PASSWORD_HISTORY_MAX = 5032private const val VAULT_HISTORY_MAX = 53334sealed interface UnlockResult {35    data object Success : UnlockResult36    data object WrongPassword : UnlockResult37    data class Error(val reason: String) : UnlockResult38}3940sealed interface CreateVaultResult {41    data object Success : CreateVaultResult42    data class Error(val reason: String) : CreateVaultResult43}4445sealed interface AdoptVaultResult {46    data class Success(val vaultId: String) : AdoptVaultResult47    data class Error(val reason: String) : AdoptVaultResult48}4950/**51 * In-memory vault session backed by [VaultStore]. Faithful port of the browser52 * `vault.ts` semantics that matter for correctness:53 *54 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched55 *    noise blob so a fresh install shows two similarly-shaped records.56 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a57 *    deletion always wins last-write-wins against an in-flight edit.58 *  - Local writes bump the version counter; sync-applied writes do NOT (so a59 *    sync round never cascades into another outbound sync).60 *  - add/update/delete roll back in-memory state if the disk write fails.61 *  - destroyVault never touches the Pro license (separate store).62 */63@Singleton64class VaultRepository @Inject constructor(65    private val store: VaultStorage,66) {67    private val mutex = Mutex()68    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend69    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync70    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so71    // lock() can serialize against an in-flight suspend op without blocking on72    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and73    // take [stateLock] only around the state-write portions. Acquisition order74    // is always mutex → stateLock.75    private val stateLock = ReentrantLock()76    private val rng = SecureRandom()7778    /**79     * Run [block] with [stateLock] held. Used to fence the small state-write80     * portions of suspend operations against [lock] and [requireUnlocked]81     * racing from another thread.82     */83    private inline fun <T> mutateState(block: () -> T): T {84        stateLock.lock()85        return try { block() } finally { stateLock.unlock() }86    }8788    private var derivedKey: ByteArray? = null89    private var rootKey: ByteArray? = null90    private var entryList: MutableList<VaultEntry> = mutableListOf()91    private var duressConfigured: Boolean = false92    private var recoveryWordHash: String? = null93    private var recoveryWordsSet: Boolean = false94    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */95    private var decoyMode: Boolean = false9697    private val _isUnlocked = MutableStateFlow(false)98    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()99100    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())101    /** Active (non-deleted) entryList. */102    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()103104    private val _settings = MutableStateFlow(VaultSettings())105    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()106107    fun hasVault(): Boolean = store.hasVault()108109    // ---- Codex (printed paper backup) -------------------------------------110111    /**112     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the113     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's114     * A/B naming where slot B is the duress decoy); the noise slot is local-only115     * and never crosses devices. Restorable on any device that has the master116     * password. Null when no vault exists.117     */118    fun codexVaultBlobBase64(): String? {119        val slotA = store.loadSlotA() ?: return null120        // H-04: duress slot is always present after createVault (real or fake),121        // so the wrapper can rely on it being loadable.122        val slotD = store.loadDecoySlot() ?: return null123        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""124        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))125    }126127    /**128     * Replace the local vault from a Codex blob, then unlock with [password].129     * Writes slot A (and a size-matched noise slot B) before unlocking so the130     * restored vault is at rest exactly as a freshly-created one would be.131     */132    /** A slot is stored either as a JSON string (browser) or inline object (Android). */133    private fun JsonElement?.asSlotJson(): String? = when {134        this == null || this is JsonNull -> null135        this is JsonPrimitive && isString -> content136        else -> toString()137    }138139    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {140        var candidateKey: ByteArray? = null141        var plaintext: ByteArray? = null142        return try {143            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)144            // Parsed as JSON, not matched with a regex. The wrapper is145            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which146            // generates most real Codexes — writes each slot as a JSON147            // *string*, with "b" present. A regex looking for an object148            // literal under "a" at the very end of the wrapper matches149            // neither of those, so every browser-generated Codex failed here150            // with "invalid_codex_blob" after decoding perfectly.151            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject152                ?: return UnlockResult.Error("invalid_codex_blob")153            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")154            // A Codex printed from the browser carries a browser-shaped slot,155            // which is the common case — most Codexes are printed there. It is156            // translated to Android's shape (see BrowserSlot) rather than157            // rejected. A translation failure is a wrong password, not a bad158            // blob: the codes decoded fine, the password just didn't open them.159            val slotA = VaultCodec.decodeSlot(slotJson)160                ?: BrowserSlot.parse(slotJson)?.let {161                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword162                }163                ?: return UnlockResult.Error("invalid_codex_blob")164            // C-01: authenticate the candidate ENTIRELY in memory before touching165            // storage. VaultCodec.decodeSlot is structural — an Android-shaped166            // Codex parses under ANY password — so writing slot A first and167            // unlocking afterwards destroyed the device's existing vault every168            // time the user mistyped the password.169            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))170            plaintext = decryptSlotABlob(slotA, candidateKey)171            // LV-1: a Codex printed from a vault sealed before NFC normalization172            // opens only under the raw password bytes. unlockLocked used to supply173            // this fallback; authenticating up front here has to keep it, or those174            // Codexes become unrestorable.175            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {176                VaultCrypto.zero(candidateKey)177                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))178                plaintext = decryptSlotABlob(slotA, candidateKey)179            }180            if (plaintext == null) return UnlockResult.WrongPassword181            val payload = try {182                VaultCodec.decode(plaintext)183            } catch (_: Exception) {184                return UnlockResult.Error("invalid_codex_blob")185            }186187            val priorA = store.loadSlotA()188            val priorB = store.loadSlotB()189            val priorD = store.loadDecoySlot()190            // Carry the real duress slot across when the Codex has one — the191            // browser writes "b" as its duress-shaped slot, and dropping it192            // here would silently destroy a configured decoy.193            // H-04: write browser B into the duress slot (not the noise slot),194            // translating browser-shaped slots to Android's StoredVault shape.195            val codexDuressJson = wrapper["b"].asSlotJson()196            val codexDuress: StoredVault? = codexDuressJson?.let {197                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->198                    // Wrong-password on the duress slot only (browser decoys can199                    // use a different passphrase). Fall through to a fake duress200                    // — the slot-A password was already authenticated.201                    BrowserSlot.toAndroidSlot(b, password)202                }203            }204205            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")206            val committed = runCatching {207                // Noise slot is always size-matched to slot A — local-only,208                // never written from a Codex.209                if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")210                // H-04: duress slot always present; real one from the Codex or211                // a fake the size of slot A.212                val duressOk = if (codexDuress != null) {213                    store.writeDecoySlot(codexDuress)214                } else {215                    writeFakeDuressSlot(slotA)216                }217                if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")218                store.stampLocalMutation()219            }.isSuccess220            if (!committed) {221                rollbackRestore(priorA, priorB, priorD)222                return UnlockResult.Error("storage_write_failed")223            }224225            val ownedKey = candidateKey!!226            candidateKey = null // the session owns the key from here on227            adoptSlotA(ownedKey, payload)228        } catch (e: Exception) {229            // A raw kotlinx parse error ("Unexpected JSON token at offset N")230            // reaching the user says nothing about WHICH json failed — the231            // wrapper, or the payload decrypted out of it. Codex screens are232            // FLAG_SECURE so this can't be screenshotted either. Debug builds233            // log the structure around the failure; alphanumerics are masked so234            // no ciphertext or vault content lands in a log.235            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))236            UnlockResult.Error(e.message ?: "restore_failed")237        } finally {238            plaintext?.let(VaultCrypto::zero)239            candidateKey?.let(VaultCrypto::zero)240        }241    }242243    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */244    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =245        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()246247    /**248     * Undo a partially-committed Codex restore. Every step is attempted249     * independently so one failure cannot strand the others. With no prior vault250     * the accurate inverse is having none at all, rather than a half-written251     * slot A that [hasVault] would report as a real vault.252     */253    private fun rollbackRestore(priorA: StoredVault?, priorB: String?, priorD: StoredVault? = null) {254        val failures = mutableListOf<String>()255        if (priorA == null) {256            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }257        } else {258            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }259                .onFailure { failures += "writeSlotA: $it" }260            if (priorB != null) {261                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }262                    .onFailure { failures += "writeSlotB: $it" }263            }264            // H-04: restore the prior duress slot — a half-committed restore that265            // left the duress record on the Codex side and slot A on the prior266            // side would put the at-rest pair in a state that never existed.267            if (priorD != null) {268                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }269                    .onFailure { failures += "writeDecoySlot: $it" }270            } else {271                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }272            }273        }274        // Log is a throwing stub off-device, and a failed log must never mask275        // the rollback result the caller is about to report.276        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }277    }278279    /** Debug-only: structure of a failed Codex restore, with content masked. */280    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {281        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)282        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()283        val window = offset?.let {284            val from = (it - 40).coerceAtLeast(0)285            val to = (it + 40).coerceAtMost(wrapperJson.length)286            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")287        }288        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()289        val slot = wrapper?.get("a")290        buildString {291            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)292            append(" | blobBase64=").append(vaultBlobBase64.length)293            append(" wrapperChars=").append(wrapperJson.length)294            append(" wrapperParses=").append(wrapper != null)295            append(" keys=").append(wrapper?.keys)296            append(" slotAKind=").append(297                when {298                    slot == null -> "absent"299                    slot is JsonPrimitive && slot.isString -> "string"300                    slot is JsonObject -> "object:" + slot.keys301                    else -> slot::class.simpleName302                },303            )304            if (window != null) append(" masked@").append(offset).append("=").append(window)305        }306    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }307308    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {309        return try {310            val salt = VaultCrypto.generateSalt()311            val key = VaultCrypto.deriveKey(password, salt)312            mutateState {313                entryList = mutableListOf()314                duressConfigured = false315                recoveryWordHash = null316                recoveryWordsSet = false317                decoyMode = false318                rootKey = generateRecoverableRootKey()319            }320            // M-04: the fields above have to be live before sealSlotA, since it321            // seals currentPayload(). That stages a root key the session would322            // otherwise keep after a failed create — [lock] discards the whole323            // staged session on every non-success exit below.324            val slotA = sealSlotA(key, salt)325            if (!store.writeSlotA(slotA)) {326                lock()327                return CreateVaultResult.Error("storage_write_failed")328            }329            // P1-3 (codex): every later step must roll the slot pair back if it330            // fails — leaving a half-built vault is worse than refusing to make it.331            val created = try {332                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")333                // H-04: duress slot must be present from the start so its334                // existence on disk is independent of duressConfigured.335                if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")336                store.stampLocalMutation()337                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")338                true339            } catch (_: Exception) {340                // Half-built vault on disk is worse than no vault — refuse to341                // create if any step past slot A fails.342                store.clearVault()343                false344            }345            if (!created) {346                lock()347                return CreateVaultResult.Error("storage_write_failed")348            }349            mutateState {350                derivedKey = key351                publish()352                _isUnlocked.value = true353            }354            CreateVaultResult.Success355        } catch (e: Exception) {356            lock()357            CreateVaultResult.Error(e.message ?: "create_failed")358        }359    }360361    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {362        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")363        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")364        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {365            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")366        }367        var adoptedRootKey: ByteArray? = null368        var nextDerivedKey: ByteArray? = null369        var sessionRootKey: ByteArray? = null370        return try {371            adoptedRootKey = hexToRootKey(rootKeyHex)372            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {373                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")374            }375            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)376            val salt = VaultCrypto.generateSalt()377            nextDerivedKey = VaultCrypto.deriveKey(password, salt)378            sessionRootKey = adoptedRootKey.copyOf()379            val payload = VaultPayload(380                entries = emptyList(),381                duressConfigured = false,382                rootKeyB64 = b64(sessionRootKey),383                recoveryWordHash = null,384                recoveryWordsSet = false,385            )386            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))387            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)388            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")389            // P1-3 (codex): same partial-commit guard as createVault — any failure390            // past slot A wipes the half-built vault and refuses adoption.391            val created = try {392                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")393                // H-04: duress slot must be present from the start so its394                // existence on disk is independent of duressConfigured.395                if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")396                store.stampLocalMutation()397                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")398                true399            } catch (_: Exception) {400                store.clearVault()401                false402            }403            if (!created) return AdoptVaultResult.Error("storage_write_failed")404405            val previousDerivedKey = derivedKey406            val previousRootKey = rootKey407            mutateState {408                derivedKey = nextDerivedKey409                rootKey = sessionRootKey410                nextDerivedKey = null411                sessionRootKey = null412                entryList = mutableListOf()413                duressConfigured = false414                recoveryWordHash = null415                recoveryWordsSet = false416                decoyMode = false417                publish()418                _isUnlocked.value = true419            }420            previousDerivedKey?.let(VaultCrypto::zero)421            previousRootKey?.let(VaultCrypto::zero)422            AdoptVaultResult.Success(vaultId)423        } catch (e: Exception) {424            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")425        } finally {426            adoptedRootKey?.let(VaultCrypto::zero)427            nextDerivedKey?.let(VaultCrypto::zero)428            sessionRootKey?.let(VaultCrypto::zero)429        }430    }431432    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }433434    /** Unlock body without acquiring the mutex — callers must already hold it. */435    private fun unlockLocked(password: String): UnlockResult {436        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")437        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))438        val payload = try {439            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))440        } catch (_: Exception) {441            VaultCrypto.zero(key)442            // LV-1: a vault sealed before NFC normalization with a non-NFC443            // password derives a different key now. If the supplied password is444            // not already in NFC form, retry with the raw (un-normalized) bytes.445            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {446                val legacy = tryLegacyRawUnlock(password, slotA)447                if (legacy != null) return legacy448            }449            return UnlockResult.WrongPassword450        }451        return adoptSlotA(key, payload)452    }453454    /**455     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw456     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed457     * under the NFC-normalized key — so every future unlock takes the normal458     * path. Returns null when the raw key also fails (genuinely wrong password)459     * OR when any step of the heal commits fails (the original slot A is restored460     * so disk and session stay aligned).461     */462    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {463        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))464        val payload = try {465            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))466        } catch (_: Exception) {467            VaultCrypto.zero(rawKey)468            return null469        }470        // Heal: re-seal slot A under the NFC key (mirrors changePassword's471        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.472        val newSalt = VaultCrypto.generateSalt()473        val newKey = VaultCrypto.deriveKey(password, newSalt)474        val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))475        val next = StoredVault(476            version = slotA.version,477            salt = b64(newSalt),478            blob = blob,479            recoveryEscrow = slotA.recoveryEscrow,480            rootKeyEscrow = slotA.rootKeyEscrow,481        )482        // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,483        // or stampLocalMutation must restore the prior slot A AND slot B so disk484        // and the eventual session key never disagree on the wrapping.485        val priorB = store.loadSlotB()486        val healed = if (!store.writeSlotA(next)) {487            false488        } else try {489            if (!writeMatchingNoiseSlotB(next)) {490                runCatching { store.writeSlotA(slotA) }491                if (priorB != null) runCatching { store.writeSlotB(priorB) }492                false493            } else if (!writeCompanionDuressSlot(next)) {494                runCatching { store.writeSlotA(slotA) }495                if (priorB != null) runCatching { store.writeSlotB(priorB) }496                false497            } else {498                store.stampLocalMutation()499                true500            }501        } catch (_: Exception) {502            runCatching { store.writeSlotA(slotA) }503            if (priorB != null) runCatching { store.writeSlotB(priorB) }504            false505        }506        return if (healed) {507            VaultCrypto.zero(rawKey)508            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot509        } else {510            VaultCrypto.zero(newKey)511            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key512        }513    }514515    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */516    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {517        return try {518            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they519            // can throw after the fields above are live. There is no prior session520            // worth restoring — adoption has already replaced it — so a failed521            // adoption discards everything rather than leaving entries and keys522            // resident behind _isUnlocked=false.523            mutateState {524                derivedKey = key525                decoyMode = false526                entryList = payload.entries.toMutableList()527                duressConfigured = payload.duressConfigured528                recoveryWordHash = payload.recoveryWordHash529                recoveryWordsSet = payload.recoveryWordsSet530                _settings.value = store.loadSettings()531                ensureRootKey(payload)532                autoPurgeExpiredTrash()533                publish()534                _isUnlocked.value = true535            }536            UnlockResult.Success537        } catch (e: Exception) {538            lock()539            UnlockResult.Error(e.message ?: "unlock_failed")540        }541    }542543    // ---- Constant-time unlock (MF-5) --------------------------------------544545    /**546     * A slot-A unlock that has been derived but not yet adopted. Holds the live547     * Argon2id key and decoded payload so the caller can derive several candidates548     * concurrently and commit only the winner. The key is owned by this token until549     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.550     */551    class SlotAUnlock internal constructor(552        internal val key: ByteArray,553        internal val payload: VaultPayload,554    )555556    /**557     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.558     * Pure computation (no mutex), so it can run concurrently with other candidate559     * derivations before the caller decides which to commit (MF-5). Returns null on560     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).561     */562    fun tryUnlockSlotA(password: String): SlotAUnlock? {563        val slotA = store.loadSlotA() ?: return null564        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))565        return try {566            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))567            SlotAUnlock(key, payload)568        } catch (_: Exception) {569            VaultCrypto.zero(key)570            null571        }572    }573574    /** Adopt a previously-derived [SlotAUnlock] as the live session. */575    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {576        adoptSlotA(unlock.key, unlock.payload)577    }578579    /** Zero a derived [SlotAUnlock] that will not be adopted. */580    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)581582    // ---- Duress slot B (MF-6) ---------------------------------------------583584    /**585     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key586     * and decoded decoy payload so it can be derived concurrently with slot A and587     * only committed if the duress passphrase was the one entered (MF-5/MF-6).588     */589    class DuressUnlock internal constructor(590        internal val key: ByteArray,591        internal val payload: VaultPayload,592    )593594    /**595     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the596     * session. Success means the entered password is the duress passphrase that keys597     * slot B — indistinguishable from the real slot A at the lock screen. Pure598     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].599     * Returns null when no duress slot exists or the password is not the duress one.600     */601    fun tryUnlockDuress(password: String): DuressUnlock? {602        val slot = store.loadDecoySlot() ?: return null603        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))604        return try {605            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))606            DuressUnlock(key, payload)607        } catch (_: Exception) {608            VaultCrypto.zero(key)609            null610        }611    }612613    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */614    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {615        return try {616            mutateState {617                derivedKey = unlock.key618                decoyMode = true619                entryList = unlock.payload.entries.toMutableList()620                duressConfigured = false621                recoveryWordHash = unlock.payload.recoveryWordHash622                recoveryWordsSet = unlock.payload.recoveryWordsSet623                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)624                          else VaultCrypto.generateRootKey()625                _settings.value = store.loadSettings()626                publish()627                _isUnlocked.value = true628            }629            UnlockResult.Success630        } catch (e: Exception) {631            UnlockResult.Error(e.message ?: "decoy_unlock_failed")632        }633    }634635    /** Zero a derived [DuressUnlock] that will not be adopted. */636    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)637638    /**639     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an640     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The641     * passphrase is never stored — only its ability to decrypt slot B proves it.642     * Requires the real vault to be unlocked and not itself a decoy session.643     */644    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {645        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().646        val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }647        if (!unlockedAndReal) return false648        // Reject a duress passphrase that also opens the real vault: it would make the649        // lock screen ambiguous (both slots would decrypt for the same input).650        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }651        return try {652            val salt = VaultCrypto.generateSalt()653            val key = VaultCrypto.deriveKey(duressPassphrase, salt)654            // An empty decoy vault with its own fresh sync root key — nothing real leaks.655            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))656            val basePlaintext = VaultCodec.encode(decoyPayload)657            // H-04: pad the duress plaintext to slot A's ciphertext length so the658            // duress blob matches slot A's blob on disk. VaultCodec.decode strips659            // trailing NULs, so the extra padding survives encrypt but never660            // reaches the JSON parser.661            // ponytail: only re-pads at configuration time — if slot A grows past662            // its initial bucket (entries cross 4096/16384/etc.), the real decoy663            // stays at the smaller bucket. Re-padding on every persist requires664            // keeping the duress passphrase in memory, which we don't.665            val slotA = store.loadSlotA()666            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0667            val plaintext = if (targetSize > basePlaintext.size) basePlaintext.copyOf(targetSize) else basePlaintext668            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))669            VaultCrypto.zero(key)670            VaultCrypto.zero(plaintext)671            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))672            if (ok) {673                mutateState { duressConfigured = true }674                runCatching { persistSlotA(bumpVersion = false) }675            }676            ok677        } catch (_: Exception) {678            false679        }680    }681682    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */683    suspend fun clearDuress(): Boolean = mutex.withLock {684        // H-04: presence-indistinguishable — replace the real decoy with a fake685        // blob of slot A's size rather than removing the duress record. The686        // record stays on disk at the same length, so an at-rest observer687        // cannot tell "duress cleared" from "no duress ever configured".688        store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) } }689            ?: store.clearDecoySlot()690        // H-03: stateLock-fence the derivedKey/decoyMode read AND the691        // duressConfigured write so a concurrent lock() cannot land between them.692        val shouldClear = mutateState { derivedKey != null && !decoyMode }693        if (shouldClear) {694            mutateState { duressConfigured = false }695            runCatching { persistSlotA(bumpVersion = false) }696        }697        true698    }699700    /** Unlock with an already-derived AES-256 key (biometric path). */701    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {702        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")703        return try {704            val payload = VaultCodec.decode(705                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),706            )707            mutateState {708                derivedKey = key.copyOf()709                decoyMode = false710                entryList = payload.entries.toMutableList()711                duressConfigured = payload.duressConfigured712                recoveryWordHash = payload.recoveryWordHash713                recoveryWordsSet = payload.recoveryWordsSet714                _settings.value = store.loadSettings()715                ensureRootKey(payload)716                autoPurgeExpiredTrash()717                publish()718                _isUnlocked.value = true719            }720            UnlockResult.Success721        } catch (_: Exception) {722            UnlockResult.WrongPassword723        }724    }725726    fun lock() {727        // H-03: must observe derivedKey/entryList atomically with any in-flight728        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,729        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock730        // guards just the brief state reset.731        stateLock.lock()732        try {733            derivedKey?.let(VaultCrypto::zero)734            rootKey?.let(VaultCrypto::zero)735            derivedKey = null736            rootKey = null737            entryList = mutableListOf()738            duressConfigured = false739            recoveryWordHash = null740            recoveryWordsSet = false741            decoyMode = false742            _entries.value = emptyList()743            _isUnlocked.value = false744        } finally {745            stateLock.unlock()746        }747    }748749    @Volatile private var suppressNextBackgroundLock = false750751    /**752     * Call immediately before launching a system Activity (a SAF file753     * picker/save dialog, a share sheet) that briefly takes the whole process754     * out of the foreground as an expected part of an in-app flow — not a755     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the756     * two apart on its own (it fires for both), and without this, exporting or757     * importing a file silently locked and wiped the vault mid-flow the moment758     * the picker took over, before its result ever reached the app.759     */760    fun expectTransientBackground() {761        suppressNextBackgroundLock = true762    }763764    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */765    fun consumeTransientBackgroundSuppression(): Boolean {766        val suppressed = suppressNextBackgroundLock767        suppressNextBackgroundLock = false768        return suppressed769    }770771    // ---- Sync identity ----------------------------------------------------772773    /**774     * The vault's stable sync master key (a copy). Caller must zero it. Backs775     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry776     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].777     */778    /**779     * HOLE 5 — the duress vault never syncs.780     *781     * [commitDuressUnlock] gives the decoy session a root key so its own codex782     * and recovery paths work, which means (unlike the browser, where the decoy783     * path simply has no root key) the sync surface has to refuse explicitly.784     * Without this the decoy vault would present a second, different vault_id785     * to a paired device, and a coerced user opening it would make sync visibly786     * fail on their other devices — a duress indicator, which is the one thing787     * a duress vault must never be.788     */789    // M-04: the "null when locked" half of the contract was documented but never790    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see791    // [unlockWithRecoveryWords], which installs a root key with no password key792    // yet) and in a create that failed after staging. Sync must fail closed793    // there, so the authoritative unlocked flag gates it, not just decoyMode.794    fun syncMasterKey(): ByteArray? =795        if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()796797    /**798     * The current session's root key, decoy included. For local-only uses that799     * must behave identically in a decoy session — Codex backup and recovery800     * words — because a feature that errors under duress is itself a duress801     * tell. In a decoy session this is the DECOY's root key (see802     * [commitDuressUnlock]), never the real vault's, so neither surface can803     * leak the real vault. Anything that leaves the device must use804     * [syncMasterKey] instead.805     */806    fun sessionRootKey(): ByteArray? = rootKey?.copyOf()807808    /** Stable per-vault id two paired devices must share to sync. Null when809     *  locked, and never present for the decoy vault (see [syncMasterKey]). */810    fun syncVaultId(): String? =811        if (decoyMode || !_isUnlocked.value) null812        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }813814    /**815     * Browser parity (MV-8): rotate the sync identity by replacing the stable816     * root key. Entries remain under the master-password key, while sync817     * credentials, vault_id, and recovery words all change.818     */819    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {820        installRootKey(generateRecoverableRootKey())821    }822823    /**824     * Join the vault behind [rootKeyHex]: adopt that vault's identity while825     * keeping every local entry.826     *827     * Sync identity (vault id, signing keys) derives from the root key, and828     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently829     * created vaults can never reconcile. Taking the other side's root key is830     * what makes them one vault; from the next sync on, both entry sets merge.831     *832     * Entries survive because they are sealed under the password-derived key,833     * not the root key — the same property [rotateSyncIdentity] relies on.834     *835     * CONSEQUENCE, which the caller must have disclosed and had confirmed:836     * this device's previous recovery words stop working for good, and its new837     * words are the source vault's words, because words are a deterministic838     * encoding of the root key.839     */840    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {841        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {842            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")843        }844        if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")845        if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")846        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")847848        val incoming = hexToRootKey(rootKeyHex)849        if (incoming.size != VaultCrypto.KEY_SIZE) {850            VaultCrypto.zero(incoming)851            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")852        }853        // Refuse before committing rather than leave this device holding a vault854        // whose recovery words cannot be typed back. generateRecoverableRootKey855        // screens its own keys for this; a key arriving from a peer has not been.856        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess857        if (!roundTrips) {858            VaultCrypto.zero(incoming)859            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")860        }861862        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)863        return if (installRootKey(incoming)) {864            AdoptVaultResult.Success(vaultId)865        } else {866            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")867        }868    }869870    /**871     * The recovery words [rootKeyHex] will produce once joined, or null if it is872     * not a usable root key. Lets the join flow show the user the words that are873     * about to replace theirs *before* anything is committed.874     */875    fun previewRecoveryWords(rootKeyHex: String): List<String>? {876        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null877        val bytes = hexToRootKey(rootKeyHex)878        return try {879            val words = rootKeyToWords(bytes)880            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null881        } catch (_: Exception) {882            null883        } finally {884            VaultCrypto.zero(bytes)885        }886    }887888    /**889     * Swap the vault's root key, keeping every entry, and re-escrow under the890     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds891     * [mutex].892     */893    private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {894        // H-03: snapshot the live fields under stateLock so the success and the895        // rollback paths both see consistent prior/new state. Writing the new896        // rootKey/recovery fields is inside its own mutateState at the end.897        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays898        // — holding live references would let a zero-on-failure operation also899        // wipe the session's still-live key arrays.900        data class Snapshot(901            val key: ByteArray,902            val priorRootKey: ByteArray,903            val priorRecoveryHash: String?,904            val priorRecoverySet: Boolean,905        )906        val snap = mutateState {907            val k = derivedKey ?: return false908            if (decoyMode) return false909            val priorRoot = rootKey ?: return false910            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)911        } as Snapshot? ?: return false912        val priorSlot = store.loadSlotA() ?: run {913            VaultCrypto.zero(snap.priorRootKey)914            return false915        }916        val nextWords = rootKeyToWords(nextRootKey)917918        return try {919            takeVaultSnapshot("local_save")920            mutateState {921                rootKey = nextRootKey922                recoveryWordHash = recoveryWordsHash(nextWords)923                recoveryWordsSet = false924            }925926            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)927                ?: throw IllegalStateException("recovery_escrow_failed")928            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)929                ?: throw IllegalStateException("root_escrow_failed")930            val plaintext = VaultCodec.encode(currentPayload())931            val blob = try {932                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))933            } finally {934                VaultCrypto.zero(plaintext)935            }936            val nextSlot = priorSlot.copy(937                blob = blob,938                recoveryEscrow = nextRecoveryEscrow,939                rootKeyEscrow = nextRootKeyEscrow,940            )941            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")942            try {943                writeMatchingNoiseSlotB(nextSlot)944                // H-04: a root-key rotation re-seals slot A, so the duress slot945                // (real or fake) must be re-padded to the new ciphertext length.946                if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")947                store.stampLocalMutation()948            } catch (e: Exception) {949                store.writeSlotA(priorSlot)950                throw e951            }952            VaultCrypto.zero(snap.priorRootKey)953            mutateState { publish() }954            true955        } catch (_: Exception) {956            mutateState {957                rootKey = snap.priorRootKey958                recoveryWordHash = snap.priorRecoveryHash959                recoveryWordsSet = snap.priorRecoverySet960            }961            VaultCrypto.zero(nextRootKey)962            store.writeSlotA(priorSlot)963            false964        }965    }966967    /**968     * A copy of the live Argon2id-derived vault key, for biometric escrow only969     * (wrapped under a hardware KeyStore key on biometric enable). Null when970     * locked. Caller must [VaultCrypto.zero] it after use.971     */972    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()973974    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {975        val slotA = store.loadSlotA() ?: return false976        var oldKey: ByteArray? = null977        var newKey: ByteArray? = null978        var plaintext: ByteArray? = null979        return try {980            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))981            val payload = VaultCodec.decode(982                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),983            )984            val newSalt = VaultCrypto.generateSalt()985            newKey = VaultCrypto.deriveKey(newPassword, newSalt)986            // Re-seal the existing payload under the new key/salt.987            plaintext = VaultCodec.encode(payload)988            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))989            // The escrow seals the slot-A PLAINTEXT, which a password change does not990            // alter — only the wrapping changes — so carry it forward; rebuilding the991            // StoredVault from scratch would silently drop it.992            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key993            // content that a password change does not alter — only the slot-A994            // wrapping rotates. The root-key escrow staying stable across password995            // changes matches the browser (`vault.ts:363-364`).996            val next = StoredVault(997                version = slotA.version,998                salt = b64(newSalt),999                blob = blob,1000                recoveryEscrow = slotA.recoveryEscrow,1001                rootKeyEscrow = slotA.rootKeyEscrow,1002            )1003            // H-01: durably commit slot A + size-matched noise + version stamp1004            // before swapping the session key. A partial commit (new A on disk,1005            // old key in memory) would have the next save encrypt under the old1006            // key while matching disk on the new one.1007            val priorB = store.loadSlotB()1008            if (!commitVaultRotation(slotA, priorB, next)) return false1009            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1010            // inside the same stateLock critical section, so a concurrent lock()1011            // can't observe the in-between (zeroed but not yet reassigned).1012            mutateState {1013                oldKey?.let(VaultCrypto::zero); oldKey = null1014                derivedKey?.let(VaultCrypto::zero)1015                derivedKey = newKey; newKey = null1016            }1017            true1018        } catch (_: Exception) {1019            false1020        } finally {1021            oldKey?.let(VaultCrypto::zero)1022            newKey?.let(VaultCrypto::zero)1023            plaintext?.let(VaultCrypto::zero)1024        }1025    }10261027    /**1028     * Irreversible wipe. Locks the session and clears every vault key. The Pro1029     * license is in a separate store and is preserved by construction.1030     */1031    suspend fun destroyVault() = mutex.withLock {1032        lock()1033        store.clearVault()1034        store.clearDecoySlot()1035    }10361037    // ---- Entry CRUD -------------------------------------------------------10381039    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1040        requireUnlocked()1041        val stamp = VaultCrypto.monotonicEntryTimestamp()1042        val now = System.currentTimeMillis()1043        val entry = withDerivedLast4(draft.copy(1044            id = draft.id.ifBlank { UUID.randomUUID().toString() },1045            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1046            modifiedAt = stamp,1047            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1048        ))1049        // H-03: capture rollback AND write entryList under stateLock so a1050        // concurrent lock() cannot zero the session between the read and the write.1051        val rollback = mutateState {1052            val r = entryList.toMutableList()1053            entryList.add(entry)1054            r1055        }1056        persistOrRollback(rollback)1057        entry1058    }10591060    /**1061     * Insert many entries with a single encrypt-and-write at the end.1062     *1063     * Importing a real vault through [addEntry] was quadratic: every entry took1064     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1065     * restore did 806 full-vault writes and hung the app long enough to ANR.1066     * Rolls back to the pre-import list if the one write fails, matching1067     * [addEntry]'s all-or-nothing behaviour.1068     */1069    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1070        requireUnlocked()1071        if (drafts.isEmpty()) return 01072        val now = System.currentTimeMillis()1073        val ready = drafts.map { draft ->1074            val stamp = VaultCrypto.monotonicEntryTimestamp()1075            withDerivedLast4(1076                draft.copy(1077                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1078                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1079                    modifiedAt = stamp,1080                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1081                ),1082            )1083        }1084        // H-03: capture rollback AND append every entry under stateLock.1085        val rollback = mutateState {1086            val r = entryList.toMutableList()1087            entryList.addAll(ready)1088            r1089        }1090        persistOrRollback(rollback)1091        return drafts.size1092    }10931094    /**1095     * M-01: replace the whole active set with [newEntries] in one write.1096     *1097     * Import's REPLACE mode used to soft-delete every current entry one durable1098     * write at a time (swallowing each failure) and then batch-add, so a failed1099     * add left the old entries stranded in Trash with nothing restored. Staging1100     * the entire next list in memory and committing it through1101     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1102     * pre-import list — active entries included — is restored.1103     *1104     * Existing tombstones are preserved so paired devices still see the1105     * deletions they already know about.1106     */1107    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1108        requireUnlocked()1109        val now = System.currentTimeMillis()1110        val ready = newEntries.map { draft ->1111            val stamp = VaultCrypto.monotonicEntryTimestamp()1112            withDerivedLast4(1113                draft.copy(1114                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1115                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1116                    modifiedAt = stamp,1117                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1118                ),1119            )1120        }1121        // M-03: an incoming entry that shares an id with an existing live entry1122        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1123        // the old one and then appending the new one would leave the list with1124        // two entries of the same id — the tombstoned one would be returned by1125        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1126        // superseded entries from the tombstone set instead.1127        val incomingIds = ready.map { it.id }.toHashSet()1128        val rollback = mutateState {1129            val r = entryList.toMutableList()1130            val replaced = entryList.map { entry ->1131                if (entry.deletedAt != null) return@map entry1132                if (entry.id in incomingIds) return@map null1133                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1134                entry.copy(deletedAt = stamp, modifiedAt = stamp)1135            }.filterNotNull()1136            entryList = (replaced + ready).toMutableList()1137            r1138        }1139        persistOrRollback(rollback)1140        return ready.size1141    }11421143    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1144        requireUnlocked()1145        // H-03: read entryList, compute the patched entry, and write it under a1146        // single stateLock so a concurrent lock() cannot interleave between the1147        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1148        // list, or clobber the entry onto the wrong id after a relock).1149        var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1150        mutateState {1151            val idx = entryList.indexOfFirst { it.id == patch.id }1152            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1153            val current = entryList[idx]1154            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1155            // Only refresh passwordChangedAt when the password value actually changes.1156            val passwordChanged = patch.password != null && patch.password != current.password1157            // Capture the outgoing password (newest first) so the user can reveal/copy/1158            // restore it later. History is computed authoritatively from `current`, not1159            // the patch — updateEntry is a full-entry replace, so trusting patch's value1160            // would let a stale form clobber the real history. Record the age the old1161            // password carried, skip an empty prior, and skip an immediate exact dupe1162            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1163            val priorPassword = current.password1164            val shouldCapture = passwordChanged &&1165                !priorPassword.isNullOrEmpty() &&1166                current.passwordHistory?.firstOrNull()?.password != priorPassword1167            val nextHistory = if (shouldCapture) {1168                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1169                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1170            } else {1171                current.passwordHistory1172            }1173            val updated = withDerivedLast4(patch.copy(1174                createdAt = current.createdAt,1175                modifiedAt = stamp,1176                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1177                passwordHistory = nextHistory,1178            ))1179            val rollback = entryList.toMutableList()1180            entryList[idx] = updated1181            prepared = updated to rollback1182        }1183        val (updated, rollback) = prepared!!1184        persistOrRollback(rollback)1185        updated1186    }11871188    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1189    suspend fun deleteEntry(id: String) = mutex.withLock {1190        requireUnlocked()1191        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1192        // the list out from under the indexOfFirst.1193        var rollback: MutableList<VaultEntry>? = null1194        mutateState {1195            val idx = entryList.indexOfFirst { it.id == id }1196            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1197            val current = entryList[idx]1198            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1199            rollback = entryList.toMutableList()1200            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1201        }1202        persistOrRollback(rollback!!)1203    }12041205    suspend fun restoreEntry(id: String) = mutex.withLock {1206        requireUnlocked()1207        var rollback: MutableList<VaultEntry>? = null1208        mutateState {1209            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1210            if (idx == -1) throw IllegalStateException("deleted entry not found")1211            val current = entryList[idx]1212            rollback = entryList.toMutableList()1213            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1214        }1215        persistOrRollback(rollback!!)1216    }12171218    /** Permanent delete — no tombstone, no recovery. */1219    suspend fun purgeEntry(id: String) = mutex.withLock {1220        requireUnlocked()1221        var rollback: MutableList<VaultEntry>? = null1222        mutateState {1223            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1224            if (idx == -1) throw IllegalStateException("deleted entry not found")1225            rollback = entryList.toMutableList()1226            entryList.removeAt(idx)1227        }1228        persistOrRollback(rollback!!)1229    }12301231    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1232    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1233        requireUnlocked()1234        // H-03: read+remove under stateLock so lock() can't reorder the remove.1235        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1236        val r = mutateState {1237            val rollback = entryList.toMutableList()1238            val before = entryList.size1239            entryList.removeAll { it.deletedAt != null }1240            R(rollback, before - entryList.size)1241        }1242        if (r.removed > 0) persistOrRollback(r.rollback)1243        r.removed1244    }12451246    /** Browser parity: number of active entries that dedupe would move to trash. */1247    suspend fun countDuplicateEntries(): Int = mutex.withLock {1248        requireUnlocked()1249        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1250        // lock() can't race the read with state writes.1251        mutateState { countDuplicateEntriesLocked() }1252    }12531254    /**1255     * Browser parity: soft-delete duplicate entries, keeping the most recently1256     * modified member of each group. Refuse in decoy mode so the duress vault is1257     * not reshaped into a forensically distinct state.1258     */1259    suspend fun deduplicateEntries(): Int = mutex.withLock {1260        requireUnlocked()1261        // H-03: read the decoyMode flag and the entry list, then reassign1262        // entryList, all under one stateLock.1263        val decoy = mutateState { decoyMode }1264        if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1265        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1266        val ready = mutateState {1267            val active = entryList.filter { it.deletedAt == null }1268            val keepIds = mutableSetOf<String>()1269            active.groupBy { it.duplicateKey() }.values.forEach { group ->1270                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1271            }1272            val rollback = entryList.toMutableList()1273            var removed = 01274            entryList = entryList.map { entry ->1275                if (entry.deletedAt == null && entry.id !in keepIds) {1276                    removed++1277                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1278                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1279                } else {1280                    entry1281                }1282            }.toMutableList()1283            R(rollback, removed)1284        }1285        if (ready.removed > 0) persistOrRollback(ready.rollback)1286        ready.removed1287    }12881289    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1290    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1291    fun getDeletedEntries(): List<VaultEntry> =1292        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }12931294    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1295    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1296        val active = entryList.filter { it.deletedAt == null }1297        if (tagFilter == null) return active1298        val needle = tagFilter.trim().lowercase()1299        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1300    }13011302    /** Sorted, de-duplicated tags across all active entries. */1303    fun getDistinctTags(): List<String> =1304        entryList.asSequence()1305            .filter { it.deletedAt == null }1306            .flatMap { it.tags?.asSequence() ?: emptySequence() }1307            .map { it.trim().lowercase() }1308            .filter { it.isNotEmpty() }1309            .distinct()1310            .sorted()1311            .toList()13121313    /**1314     * Distinct non-blank values for an identity field across active entries,1315     * most-frequently-used first. Supports "username" and "email"; unknown field1316     * names return an empty list. Values keep original casing (deduped1317     * case-insensitively) so a suggestion matches what the user typed.1318     */1319    fun getDistinctFieldValues(field: String): List<String> {1320        val select: (VaultEntry) -> String? = when (field) {1321            "username" -> { e -> e.username }1322            "email" -> { e -> e.email }1323            else -> return emptyList()1324        }1325        return entryList.asSequence()1326            .filter { it.deletedAt == null }1327            .mapNotNull { select(it)?.trim() }1328            .filter { it.isNotEmpty() }1329            .groupingBy { it.lowercase() }1330            .aggregate { _, acc: Pair<String, Int>?, value, _ ->1331                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1332            }1333            .values1334            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1335            .map { it.first }1336    }13371338    fun getTombstones(): List<Tombstone> =1339        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1340    fun getVaultVersion(): Long = store.version()1341    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots13421343    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1344        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1345        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1346        // H-04: snapshots now carry the duress slot too — restore preserves1347        // the at-rest triad (A/B/D), not just the A/B pair.1348        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1349        val priorA = store.loadSlotA()1350        val priorB = store.loadSlotB()1351        val priorD = store.loadDecoySlot()1352        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1353        // slot write — the old code took this snapshot, then stranded it on1354        // every failure, polluting history with a "restore" entry that never1355        // restored anything. The history below is rolled back together with1356        // the slots if the commit fails.1357        val priorHistory = store.loadVaultHistory()1358        // P1-4 (codex): the recovery snapshot must land before we commit the1359        // slot pair, otherwise a failed restore cannot be rolled back to the1360        // pre-restore history. If the history write itself fails, refuse to1361        // proceed — the alternative is a successful restore with no undo path.1362        if (!takeVaultSnapshot("restore")) {1363            return false1364        }1365        // H-04: legacy snapshots without vault_d, or vault_d that fails to1366        // decode, fall back to a fake duress of slot A's size — presence-1367        // indistinguishable from any other freshly-restored vault.1368        val nextD: StoredVault = restoredD ?: run {1369            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161370            val fakeBlob = ByteArray(target).also(rng::nextBytes)1371            val fakeSalt = VaultCrypto.generateSalt()1372            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1373        }1374        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD)) {1375            store.writeVaultHistory(priorHistory)1376            return false1377        }1378        lock()1379        true1380    }13811382    /**1383     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1384     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1385     * (a restore preserves the duress-shaped blob from history), not a fresh1386     * noise record. Any failure rolls back so the at-rest pair never holds a1387     * restored slot A against a pre-restore slot B. Caller is responsible for1388     * restoring the pre-snapshot history on a false return.1389     *1390     * H-04: duress slot is now part of the atomic triad — restored last so1391     * a duress-write failure rolls the A/B pair back too.1392     */1393    private fun commitRestoreAtomic(1394        nextA: StoredVault,1395        nextB: String,1396        nextD: StoredVault,1397        priorA: StoredVault?,1398        priorB: String?,1399        priorD: StoredVault?,1400    ): Boolean {1401        if (!store.writeSlotA(nextA)) return false1402        // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1403        // must trigger rollback; previously a throw escaped without restoring1404        // the prior slot A and history.1405        try {1406            if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1407            // H-04: duress slot is part of the restore triad. Failures here1408            // roll A+B back so the at-rest state stays consistent.1409            if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1410            store.stampLocalMutation()1411        } catch (_: Exception) {1412            rollbackRestore(priorA, priorB, priorD)1413            return false1414        }1415        return true1416    }14171418    // ---- Sync apply (does NOT advance the version) ------------------------14191420    /**1421     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1422     * WITHOUT bumping the version counter, so a sync round cannot cascade into1423     * another outbound sync. Returns the number of locally-applied changes.1424     */1425    suspend fun applyMergedRemoteEntries(1426        remoteEntries: List<VaultEntry>,1427        remoteTombstones: List<Tombstone>,1428    ): Int = mutex.withLock {1429        requireUnlocked()1430        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1431        // BEFORE any comparison against a local one. Without this a peer can1432        // send a far-future modifiedAt and permanently pin its version of an1433        // entry against every honest local edit. The browser clamps in the1434        // matching place (vault.ts applyMergedRemoteEntries).1435        val mergeNow = System.currentTimeMillis()1436        val clamped = remoteEntries.map { entry ->1437            entry.copy(1438                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1439                deletedAt = entry.deletedAt?.let {1440                    SyncLimits.clampSyncTimestamp(it, mergeNow)1441                },1442            )1443        }1444        val tombMap = remoteTombstones.associate {1445            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1446        }1447        // H-03: build the new entryList under stateLock so a concurrent lock()1448        // cannot zero derivedKey/entryList between the byId read and the merge.1449        val applied = mutateState {1450            var n = 01451            val byId = entryList.associateBy { it.id }.toMutableMap()14521453            for (remote in clamped) {1454                val tomb = tombMap[remote.id]1455                if (tomb != null && tomb >= remote.modifiedAt) continue1456                val local = byId[remote.id]1457                if (local == null) {1458                    entryList.add(remote); byId[remote.id] = remote; n++1459                } else if (remote.modifiedAt > local.modifiedAt) {1460                    val i = entryList.indexOfFirst { it.id == local.id }1461                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1462                }1463            }14641465            for ((id, deletedAt) in tombMap) {1466                val local = byId[id] ?: continue1467                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1468                if (deletedAt < local.modifiedAt) continue1469                val i = entryList.indexOfFirst { it.id == id }1470                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1471            }1472            n1473        }14741475        if (applied > 0) {1476            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1477            mutateState { publish() }1478        }1479        applied1480    }14811482    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1483        // _settings is a StateFlow (atomic write), but publish under stateLock so1484        // any concurrent lock() observes either the old or the new value — not1485        // ordering with the in-memory entries flip.1486        mutateState { _settings.value = settings }1487        store.writeSettings(settings)1488    }14891490    // ---- Recovery words (vault metadata) ----------------------------------14911492    /** True once the user has confirmed their recovery words for this vault. */1493    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet14941495    /**1496     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1497     * Persisted without bumping the version — recovery metadata is local-only and1498     * is not an entry mutation paired devices should sync against.1499     */1500    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1501        requireUnlocked()1502        // H-03: hash + escrow-related state fields set under stateLock so a1503        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.1504        // M-04: the flags have to be live before the escrows are built — both seal1505        // currentPayload() — so capture the prior pair and put it back if the write1506        // never lands. Otherwise the session reports words set against a slot A1507        // that still holds no escrow, and the recovery screen never offers a retry.1508        val prior = mutateState {1509            val priorPair = recoveryWordHash to recoveryWordsSet1510            recoveryWordHash = recoveryWordsHash(words)1511            recoveryWordsSet = true1512            priorPair1513        }1514        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow1515        // (cross-platform recovery of the sync root key) and Android's whole-payload1516        // escrow (offline forgot-password restore of entries). Both derive their key1517        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.1518        val persisted = runCatching {1519            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1520        }.getOrDefault(false)1521        if (!persisted) {1522            mutateState {1523                recoveryWordHash = prior.first1524                recoveryWordsSet = prior.second1525            }1526        }1527        persisted1528    }15291530    /** Verify entered words against the stored recovery hash. */1531    fun verifyRecoveryWords(words: List<String>): Boolean {1532        val stored = recoveryWordHash ?: return false1533        return stored == recoveryWordsHash(words)1534    }15351536    // ---- Recovery escrow (forgot-password) --------------------------------15371538    /** True at rest if this vault has any recovery escrow the recovery flow can use. */1539    fun hasRecoveryEscrow(): Boolean =1540        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false15411542    /**1543     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of1544     * the master password. On success the slot-A plaintext sealed in the escrow is1545     * adopted as the live session (entries, rootKey, settings), but no Argon2id1546     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a1547     * new password and finish unlocking. Returns false when there is no escrow, the1548     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).1549     *1550     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no1551     * detached vault key to return here — slot A is wrapped under the rotating1552     * Argon2id key — so this opens the escrowed payload directly.1553     */1554    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1555        val slotA = store.loadSlotA() ?: return false1556        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false15571558        // Prefer Android's whole-payload escrow: a full offline restore of entries,1559        // root key, and settings. Present for Android-created vaults.1560        slotA.recoveryEscrow?.let { escrow ->1561            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1562            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1563            val keyBytes = recoveryKey.encoded1564            try {1565                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1566                val payload = VaultCodec.decode(plaintext)1567                // H-03: adoption is one atomic state-write — lock() cannot interleave.1568                mutateState {1569                    decoyMode = false1570                    entryList = payload.entries.toMutableList()1571                    duressConfigured = payload.duressConfigured1572                    recoveryWordHash = payload.recoveryWordHash1573                    recoveryWordsSet = payload.recoveryWordsSet1574                    rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1575                              else generateRecoverableRootKey()1576                    _settings.value = store.loadSettings()1577                }1578                VaultCrypto.zero(plaintext)1579                return true1580            } catch (_: Exception) {1581                // Wrong words against this escrow — fall through to the root-key escrow.1582            } finally {1583                VaultCrypto.zero(material)1584                VaultCrypto.zero(keyBytes)1585            }1586        }15871588        // Browser-format vault (only the root-key escrow): recover the 32-byte root1589        // key (sync identity) and reset the password; entries return via sync. This1590        // is exactly the browser's recovery semantics (`messages.ts:312-347`).1591        slotA.rootKeyEscrow?.let { escrow ->1592            val rk = unsealRootKeyEscrow(words, escrow) ?: return false1593            mutateState {1594                decoyMode = false1595                entryList = mutableListOf()1596                duressConfigured = false1597                recoveryWordHash = recoveryWordsHash(words)1598                recoveryWordsSet = true1599                rootKey = rk1600                _settings.value = store.loadSettings()1601            }1602            return true1603        }1604        return false1605    }16061607    /**1608     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1609     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1610     * words, mark the session fully unlocked, and publish. Returns false if called1611     * without a prior successful recovery unlock (no in-memory root key).1612     *1613     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1614     * into one step, since there is no detached vault key here.1615     */1616    suspend fun resetPasswordWithRecoveryWords(1617        words: List<String>,1618        newPassword: String,1619    ): Boolean = mutex.withLock {1620        // H-03: read rootKey + entryList under stateLock before computing the new1621        // plaintext, then re-publish + swap the derived key under stateLock so a1622        // concurrent lock() cannot straddle the two fences.1623        if (!mutateState { rootKey != null }) return false1624        val priorA = store.loadSlotA() ?: return false1625        var newKey: ByteArray? = null1626        var plaintext: ByteArray? = null1627        return try {1628            val newSalt = VaultCrypto.generateSalt()1629            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1630            plaintext = VaultCodec.encode(currentPayload())1631            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1632            // Rebuild both escrows against the recovered session so their salts rotate1633            // with the password reset; fall back to the prior escrows if rebuild fails1634            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1635            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1636            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1637            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1638            val next = StoredVault(1639                version = priorA.version,1640                salt = b64(newSalt),1641                blob = blob,1642                recoveryEscrow = freshRecovery,1643                rootKeyEscrow = freshRootKey,1644            )1645            // H-01: see changePassword — both rotations share the same1646            // commit-or-keep-old-key contract so a partial write can't strand1647            // the session on a key that no longer matches disk.1648            val priorB = store.loadSlotB()1649            if (!commitVaultRotation(priorA, priorB, next)) return false1650            // H-03 (P1-2 codex): zero the old live key and install the new one1651            // inside the same stateLock critical section so a concurrent lock()1652            // can't observe the in-between.1653            mutateState {1654                derivedKey?.let(VaultCrypto::zero)1655                derivedKey = newKey; newKey = null1656                publish()1657                _isUnlocked.value = true1658            }1659            true1660        } catch (_: Exception) {1661            false1662        } finally {1663            newKey?.let(VaultCrypto::zero)1664            plaintext?.let(VaultCrypto::zero)1665        }1666    }16671668    /**1669     * Seal the current slot-A plaintext under a key derived from the 12 BIP391670     * recovery [words] (Android offline-restore escrow). IKM is the browser's1671     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1672     * Returns null for a checksum-invalid phrase.1673     */1674    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1675        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1676        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1677        val salt = VaultCrypto.generateSalt()1678        val recoveryKey = deriveRecoveryKey(material, salt)1679        val keyBytes = recoveryKey.encoded1680        val plaintext = VaultCodec.encode(currentPayload())1681        return try {1682            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1683            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1684        } finally {1685            VaultCrypto.zero(material)1686            VaultCrypto.zero(keyBytes)1687            VaultCrypto.zero(plaintext)1688        }1689    }16901691    /**1692     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1693     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1694     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1695     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1696     * the same root key on the browser. Returns null with no in-memory root key or1697     * a checksum-invalid phrase.1698     */1699    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1700        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1701        val rk = rootKey ?: return null1702        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1703        val salt = VaultCrypto.generateSalt()1704        val recoveryKey = deriveRecoveryKey(material, salt)1705        val keyBytes = recoveryKey.encoded1706        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1707        return try {1708            // combined = nonce(12) || ciphertext || tag(16)1709            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1710            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1711            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1712            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1713            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1714            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1715        } finally {1716            VaultCrypto.zero(material)1717            VaultCrypto.zero(keyBytes)1718            VaultCrypto.zero(plaintext)1719        }1720    }17211722    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1723    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1724        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1725        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1726        val keyBytes = recoveryKey.encoded1727        var pt: ByteArray? = null1728        return try {1729            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1730            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1731            hexToRootKey(String(pt, Charsets.US_ASCII))1732        } catch (_: Exception) {1733            null1734        } finally {1735            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the1736            // IKM-equivalent of the vault identity. material+keyBytes were1737            // already zeroed; pt was leaking.1738            VaultCrypto.zero(material)1739            VaultCrypto.zero(keyBytes)1740            pt?.let(VaultCrypto::zero)1741        }1742    }17431744    /**1745     * Re-seal slot A from the current in-memory state under the live key, carrying1746     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1747     * in decoy mode (recovery is never offered for the decoy) and persists without1748     * a version bump, like the recovery-words metadata write it replaces.1749     */1750    private fun persistSlotAWithEscrows(1751        recoveryEscrow: RecoveryEscrow?,1752        rootKeyEscrow: RootKeyEscrow?,1753    ): Boolean {1754        val key = derivedKey ?: throw IllegalStateException("vault_locked")1755        if (decoyMode) return false1756        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1757        val plaintext = VaultCodec.encode(currentPayload())1758        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1759        val next = prior.copy(1760            blob = blob,1761            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1762            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1763        )1764        if (!store.writeSlotA(next)) return false1765        try {1766            writeMatchingNoiseSlotB(next)1767            // H-04: escrow rebuild changes slot A's plaintext size, so re-pad1768            // the duress slot too. A real decoy is left alone; a fake is1769            // re-sized to slot A's new ciphertext length.1770            if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")1771        } catch (e: Exception) {1772            store.writeSlotA(prior)1773            throw e1774        }1775        return true1776    }17771778    // ---- Internals --------------------------------------------------------17791780    private fun requireUnlocked() {1781        // H-03: read derivedKey atomically with [lock]'s reset; without it,1782        // [lock] from a background lifecycle event can run between this read1783        // and the caller's first state touch.1784        stateLock.lock()1785        try {1786            if (derivedKey == null) throw IllegalStateException("vault_locked")1787        } finally {1788            stateLock.unlock()1789        }1790    }17911792    private fun countDuplicateEntriesLocked(): Int {1793        val seen = mutableMapOf<String, VaultEntry>()1794        var duplicates = 01795        for (entry in entryList.filter { it.deletedAt == null }) {1796            val key = entry.duplicateKey()1797            val existing = seen[key]1798            if (existing == null) {1799                seen[key] = entry1800            } else {1801                duplicates++1802                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1803                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1804                if (entryStamp > existingStamp) seen[key] = entry1805            }1806        }1807        return duplicates1808    }18091810    private fun VaultEntry.duplicateKey(): String {1811        if (type == "note") return "note|${title.lowercase()}"1812        val passkeyData = passkey1813        if (passkeyData != null) {1814            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1815        }1816        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1817    }18181819    /**1820     * Capture the current slot pair (and metadata) into the history list.1821     * Returns true when the history write landed, false when it didn't — callers1822     * that depend on the snapshot existing (notably [restoreVaultSnapshot])1823     * must check the result and roll back if it does not.1824     */1825    private fun takeVaultSnapshot(reason: String): Boolean {1826        val slotA = store.loadSlotA() ?: return false1827        val vaultA = VaultCodec.encodeSlot(slotA)1828        val vaultB = store.loadSlotB() ?: slotA.blob1829        // H-04: capture the duress slot so a restore preserves the at-rest1830        // triad, not just the A/B pair. The duress slot is always present1831        // (real or fake), so loadDecoySlot() returning null is a logic error1832        // — fall through to a fake duress in that case rather than silently1833        // losing the record from the snapshot.1834        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {1835            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161836            val fakeBlob = ByteArray(target).also(rng::nextBytes)1837            val fakeSalt = VaultCrypto.generateSalt()1838            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1839        }1840        val history = store.loadVaultHistory()1841        val latest = history.snapshots.firstOrNull()1842        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true1843        val entryCount = if (derivedKey != null && !decoyMode) {1844            bucketEntryCount(entryList.count { it.deletedAt == null })1845        } else {1846            null1847        }1848        return try {1849            val snapshot = VaultSnapshot(1850                id = UUID.randomUUID().toString(),1851                savedAt = System.currentTimeMillis(),1852                reason = reason,1853                vault_a = vaultA,1854                vault_b = vaultB,1855                vault_d = vaultD,1856                entryCount = entryCount,1857            )1858            // P1-4 (codex): return the history-write boolean to callers so a1859            // false return no longer leaves the snapshot out of history while1860            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.1861            store.writeVaultHistory(1862                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1863            )1864        } catch (_: Exception) {1865            false1866        }1867    }18681869    private fun bucketEntryCount(n: Int): Int = when {1870        n <= 0 -> 01871        n <= 10 -> 101872        n <= 25 -> 251873        n <= 50 -> 501874        n <= 100 -> 1001875        n <= 250 -> 2501876        n <= 500 -> 5001877        else -> ((n + 499) / 500) * 5001878    }18791880    /**1881     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1882     * so it is stored explicitly and survives the sync wire format rather than being1883     * re-derived at render time. Non-card entries pass through unchanged.1884     *1885     * M-03: never overwrite a stored last4 with null. A card kept only as last41886     * (e.g. after a PeachScript import that had no PAN) must keep that value;1887     * re-deriving from an absent cardNumber would silently zero the field.1888     */1889    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1890        if (!entry.isCard()) return entry1891        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1892        val derived = if (digits.length >= 4) digits.takeLast(4) else null1893        return entry.copy(last4 = derived ?: entry.last4)1894    }18951896    /**1897     * On unlock, hard-delete trashed entries older than the retention window.1898     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1899     * (the app's "Never" option), so nothing is purged. Persists without a1900     * version bump and without touching the publish state (caller publishes).1901     */1902    private fun autoPurgeExpiredTrash() {1903        val days = _settings.value.trashRetentionDays ?: return1904        if (days <= 0) return1905        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1906        val before = entryList.size1907        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1908        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1909    }19101911    /** The slot-A plaintext envelope for the current in-memory session. */1912    private fun currentPayload(): VaultPayload =1913        VaultPayload(1914            entries = entryList.toList(),1915            duressConfigured = duressConfigured,1916            rootKeyB64 = rootKey?.let(::b64) ?: "",1917            recoveryWordHash = recoveryWordHash,1918            recoveryWordsSet = recoveryWordsSet,1919        )19201921    /**1922     * Adopt the payload's root key, lazily minting one for legacy payloads.1923     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1924     * a root key is not a mutation paired devices should sync against.1925     */1926    private fun ensureRootKey(payload: VaultPayload) {1927        if (payload.rootKeyB64.isNotBlank()) {1928            rootKey = unb64(payload.rootKeyB64)1929            return1930        }1931        rootKey = generateRecoverableRootKey()1932        runCatching { persistSlotA(bumpVersion = false) }1933    }19341935    private fun publish() {1936        _entries.value = entryList.filter { it.deletedAt == null }1937    }19381939    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1940        var plaintext = VaultCodec.encode(currentPayload())1941        return try {1942            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1943            StoredVault(version = 1, salt = b64(salt), blob = blob)1944        } finally {1945            // M-05: full vault plaintext is allocated here once per createVault.1946            // Zeroing in finally keeps the transient copy off the heap after the1947            // caller either succeeds or rolls back via lock().1948            VaultCrypto.zero(plaintext)1949        }1950    }19511952    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1953        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1954        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1955        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1956        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1957        val noise = ByteArray(approxBytes).also(rng::nextBytes)1958        return store.writeSlotB(b64(noise))1959    }19601961    /** H-04: the ciphertext length slot A is using right now, so the duress1962     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext1963     *  length equals ciphertext length, so this also matches slot A's plaintext. */1964    private fun matchingCiphertextLength(slotA: StoredVault): Int =1965        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)19661967    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]1968     *  bytes. Replaces a real decoy when the user clears duress, and is the1969     *  initial duress slot when no decoy is configured — so the record's1970     *  PRESENCE on disk is independent of duressConfigured, and its SIZE1971     *  matches slot A's blob length. */1972    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {1973        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161974        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)1975        val fakeSalt = VaultCrypto.generateSalt()1976        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1977    }19781979    /** H-04: keep the duress record presence- and size-indistinguishable from1980     *  slot A on every persist. A real decoy is left alone (it was padded at1981     *  setDuressPassphrase time, and we don't have the duress key here to1982     *  re-pad on entry growth — that's an accepted residual tell, see the1983     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot1984     *  A's size. */1985    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {1986        val isConfigured = mutateState { duressConfigured }1987        return if (isConfigured) true else writeFakeDuressSlot(slotA)1988    }19891990    /** Re-seal slot A from the current in-memory state under the live key. */1991    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1992        val key = derivedKey ?: throw IllegalStateException("vault_locked")1993        var plaintext: ByteArray? = null1994        return try {1995            // Decoy sessions persist to the decoy slot only — never touch the real slot A.1996            if (decoyMode) {1997                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1998                plaintext = VaultCodec.encode(currentPayload())1999                val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2000                plaintext = null2001                return store.writeDecoySlot(prior.copy(blob = blob))2002            }2003            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2004            // H-01: captured BEFORE the noise write so a stamp failure can restore2005            // the prior slot B alongside the prior slot A. Slot B is always2006            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2007            // leaves (new A, new B, old version) on disk — partial commit.2008            val priorB = store.loadSlotB()2009            if (snapshotReason != null) takeVaultSnapshot(snapshotReason)2010            plaintext = VaultCodec.encode(currentPayload())2011            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2012            plaintext = null2013            val next = prior.copy(blob = blob)2014            if (!store.writeSlotA(next)) return false2015            // Slot A committed — keep slot B size-matched, then advance the version2016            // for local writes only. Restore both A and B if a later step fails.2017            // P1-1 (codex): the noise write's boolean AND any thrown exception from2018            // it must trigger rollback; previously the call sat outside the try.2019            try {2020                if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2021                // H-04: duress slot stays present (real one untouched, fake one2022                // re-padded to slot A's current size on every persist).2023                if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2024                if (bumpVersion) store.stampLocalMutation()2025            } catch (e: Exception) {2026                store.writeSlotA(prior)2027                if (priorB != null) runCatching { store.writeSlotB(priorB) }2028                throw e2029            }2030            true2031        } finally {2032            // M-05: the full vault plaintext crosses this helper on every save.2033            // Zeroing in finally covers both the success and the rollback/throw2034            // paths so a partial commit can't leave a copy in GC.2035            plaintext?.let(VaultCrypto::zero)2036        }2037    }20382039    /**2040     * H-01: durably commit a slot-A rotation by writing slot A, its2041     * size-matched noise slot B, and the local-mutation stamp in order. Any2042     * failure rolls the previously-committed steps back so the at-rest pair2043     * matches what was there before the call. Returns true only when every2044     * step succeeded — callers must NOT swap their session key on false.2045     */2046    private fun commitVaultRotation(2047        priorA: StoredVault,2048        priorB: String?,2049        nextA: StoredVault,2050    ): Boolean {2051        if (!store.writeSlotA(nextA)) return false2052        // P1-1 (codex): the noise write's boolean AND any thrown exception from2053        // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2054        // escaped the helper and left slot A committed.2055        try {2056            if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2057            // H-04: a password rotation changes slot A's plaintext size (new2058            // salt → re-encrypted blob), so the duress slot must be re-padded2059            // alongside. writeCompanionDuressSlot leaves a real decoy alone2060            // and re-emits a fake of slot A's size otherwise.2061            if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2062            store.stampLocalMutation()2063        } catch (_: Exception) {2064            rollbackRotation(priorA, priorB)2065            return false2066        }2067        return true2068    }20692070    /**2071     * Best-effort undo of the writes [commitVaultRotation] may have committed.2072     * Each step runs independently — a failed rollback must not mask the2073     * original commit failure. Log is a throwing stub off-device.2074     */2075    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2076        val failures = mutableListOf<String>()2077        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2078            .onFailure { failures += "writeSlotA: $it" }2079        if (priorB != null) {2080            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2081                .onFailure { failures += "writeSlotB: $it" }2082        }2083        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2084    }20852086    /** Persist a local mutation, rolling the in-memory list back on failure. */2087    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {2088        try {2089            if (!persistSlotA(bumpVersion = true)) {2090                mutateState { entryList = rollback }2091                throw IllegalStateException("storage_write_failed")2092            }2093        } catch (e: Exception) {2094            mutateState { entryList = rollback }2095            throw e2096        }2097        mutateState { publish() }2098    }2099}