VaultRepository.kt

91.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-A envelope as base64 JSON `{"a": <StoredVault>}` — the113     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable114     * on any device that has the master password. Null when no vault exists.115     */116    fun codexVaultBlobBase64(): String? {117        val slotA = store.loadSlotA() ?: return null118        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""119        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))120    }121122    /**123     * Replace the local vault from a Codex blob, then unlock with [password].124     * Writes slot A (and a size-matched noise slot B) before unlocking so the125     * restored vault is at rest exactly as a freshly-created one would be.126     */127    /** A slot is stored either as a JSON string (browser) or inline object (Android). */128    private fun JsonElement?.asSlotJson(): String? = when {129        this == null || this is JsonNull -> null130        this is JsonPrimitive && isString -> content131        else -> toString()132    }133134    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {135        var candidateKey: ByteArray? = null136        var plaintext: ByteArray? = null137        return try {138            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)139            // Parsed as JSON, not matched with a regex. The wrapper is140            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which141            // generates most real Codexes — writes each slot as a JSON142            // *string*, with "b" present. A regex looking for an object143            // literal under "a" at the very end of the wrapper matches144            // neither of those, so every browser-generated Codex failed here145            // with "invalid_codex_blob" after decoding perfectly.146            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject147                ?: return UnlockResult.Error("invalid_codex_blob")148            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")149            // A Codex printed from the browser carries a browser-shaped slot,150            // which is the common case — most Codexes are printed there. It is151            // translated to Android's shape (see BrowserSlot) rather than152            // rejected. A translation failure is a wrong password, not a bad153            // blob: the codes decoded fine, the password just didn't open them.154            val slotA = VaultCodec.decodeSlot(slotJson)155                ?: BrowserSlot.parse(slotJson)?.let {156                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword157                }158                ?: return UnlockResult.Error("invalid_codex_blob")159            // C-01: authenticate the candidate ENTIRELY in memory before touching160            // storage. VaultCodec.decodeSlot is structural — an Android-shaped161            // Codex parses under ANY password — so writing slot A first and162            // unlocking afterwards destroyed the device's existing vault every163            // time the user mistyped the password.164            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))165            plaintext = decryptSlotABlob(slotA, candidateKey)166            // LV-1: a Codex printed from a vault sealed before NFC normalization167            // opens only under the raw password bytes. unlockLocked used to supply168            // this fallback; authenticating up front here has to keep it, or those169            // Codexes become unrestorable.170            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {171                VaultCrypto.zero(candidateKey)172                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))173                plaintext = decryptSlotABlob(slotA, candidateKey)174            }175            if (plaintext == null) return UnlockResult.WrongPassword176            val payload = try {177                VaultCodec.decode(plaintext)178            } catch (_: Exception) {179                return UnlockResult.Error("invalid_codex_blob")180            }181182            val priorA = store.loadSlotA()183            val priorB = store.loadSlotB()184            // Carry the real slot B across when the Codex has one — it holds a185            // duress vault, which noise would silently destroy.186            val codexSlotB = wrapper["b"].asSlotJson()187188            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")189            val committed = runCatching {190                val wroteB = if (codexSlotB != null) {191                    store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))192                } else {193                    writeMatchingNoiseSlotB(slotA)194                }195                if (!wroteB) throw IllegalStateException("storage_write_failed")196                store.stampLocalMutation()197            }.isSuccess198            if (!committed) {199                rollbackRestore(priorA, priorB)200                return UnlockResult.Error("storage_write_failed")201            }202203            val ownedKey = candidateKey!!204            candidateKey = null // the session owns the key from here on205            adoptSlotA(ownedKey, payload)206        } catch (e: Exception) {207            // A raw kotlinx parse error ("Unexpected JSON token at offset N")208            // reaching the user says nothing about WHICH json failed — the209            // wrapper, or the payload decrypted out of it. Codex screens are210            // FLAG_SECURE so this can't be screenshotted either. Debug builds211            // log the structure around the failure; alphanumerics are masked so212            // no ciphertext or vault content lands in a log.213            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))214            UnlockResult.Error(e.message ?: "restore_failed")215        } finally {216            plaintext?.let(VaultCrypto::zero)217            candidateKey?.let(VaultCrypto::zero)218        }219    }220221    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */222    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =223        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()224225    /**226     * Undo a partially-committed Codex restore. Every step is attempted227     * independently so one failure cannot strand the others. With no prior vault228     * the accurate inverse is having none at all, rather than a half-written229     * slot A that [hasVault] would report as a real vault.230     */231    private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {232        val failures = mutableListOf<String>()233        if (priorA == null) {234            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }235        } else {236            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }237                .onFailure { failures += "writeSlotA: $it" }238            if (priorB != null) {239                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }240                    .onFailure { failures += "writeSlotB: $it" }241            }242        }243        // Log is a throwing stub off-device, and a failed log must never mask244        // the rollback result the caller is about to report.245        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }246    }247248    /** Debug-only: structure of a failed Codex restore, with content masked. */249    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {250        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)251        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()252        val window = offset?.let {253            val from = (it - 40).coerceAtLeast(0)254            val to = (it + 40).coerceAtMost(wrapperJson.length)255            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")256        }257        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()258        val slot = wrapper?.get("a")259        buildString {260            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)261            append(" | blobBase64=").append(vaultBlobBase64.length)262            append(" wrapperChars=").append(wrapperJson.length)263            append(" wrapperParses=").append(wrapper != null)264            append(" keys=").append(wrapper?.keys)265            append(" slotAKind=").append(266                when {267                    slot == null -> "absent"268                    slot is JsonPrimitive && slot.isString -> "string"269                    slot is JsonObject -> "object:" + slot.keys270                    else -> slot::class.simpleName271                },272            )273            if (window != null) append(" masked@").append(offset).append("=").append(window)274        }275    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }276277    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {278        return try {279            val salt = VaultCrypto.generateSalt()280            val key = VaultCrypto.deriveKey(password, salt)281            mutateState {282                entryList = mutableListOf()283                duressConfigured = false284                recoveryWordHash = null285                recoveryWordsSet = false286                decoyMode = false287                rootKey = generateRecoverableRootKey()288            }289            // M-04: the fields above have to be live before sealSlotA, since it290            // seals currentPayload(). That stages a root key the session would291            // otherwise keep after a failed create — [lock] discards the whole292            // staged session on every non-success exit below.293            val slotA = sealSlotA(key, salt)294            if (!store.writeSlotA(slotA)) {295                lock()296                return CreateVaultResult.Error("storage_write_failed")297            }298            // P1-3 (codex): every later step must roll the slot pair back if it299            // fails — leaving a half-built vault is worse than refusing to make it.300            val created = try {301                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")302                store.stampLocalMutation()303                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")304                true305            } catch (_: Exception) {306                // Half-built vault on disk is worse than no vault — refuse to307                // create if any step past slot A fails.308                store.clearVault()309                false310            }311            if (!created) {312                lock()313                return CreateVaultResult.Error("storage_write_failed")314            }315            mutateState {316                derivedKey = key317                publish()318                _isUnlocked.value = true319            }320            CreateVaultResult.Success321        } catch (e: Exception) {322            lock()323            CreateVaultResult.Error(e.message ?: "create_failed")324        }325    }326327    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {328        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")329        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")330        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {331            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")332        }333        var adoptedRootKey: ByteArray? = null334        var nextDerivedKey: ByteArray? = null335        var sessionRootKey: ByteArray? = null336        return try {337            adoptedRootKey = hexToRootKey(rootKeyHex)338            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {339                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")340            }341            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)342            val salt = VaultCrypto.generateSalt()343            nextDerivedKey = VaultCrypto.deriveKey(password, salt)344            sessionRootKey = adoptedRootKey.copyOf()345            val payload = VaultPayload(346                entries = emptyList(),347                duressConfigured = false,348                rootKeyB64 = b64(sessionRootKey),349                recoveryWordHash = null,350                recoveryWordsSet = false,351            )352            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))353            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)354            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")355            // P1-3 (codex): same partial-commit guard as createVault — any failure356            // past slot A wipes the half-built vault and refuses adoption.357            val created = try {358                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")359                store.stampLocalMutation()360                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")361                true362            } catch (_: Exception) {363                store.clearVault()364                false365            }366            if (!created) return AdoptVaultResult.Error("storage_write_failed")367368            val previousDerivedKey = derivedKey369            val previousRootKey = rootKey370            mutateState {371                derivedKey = nextDerivedKey372                rootKey = sessionRootKey373                nextDerivedKey = null374                sessionRootKey = null375                entryList = mutableListOf()376                duressConfigured = false377                recoveryWordHash = null378                recoveryWordsSet = false379                decoyMode = false380                publish()381                _isUnlocked.value = true382            }383            previousDerivedKey?.let(VaultCrypto::zero)384            previousRootKey?.let(VaultCrypto::zero)385            AdoptVaultResult.Success(vaultId)386        } catch (e: Exception) {387            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")388        } finally {389            adoptedRootKey?.let(VaultCrypto::zero)390            nextDerivedKey?.let(VaultCrypto::zero)391            sessionRootKey?.let(VaultCrypto::zero)392        }393    }394395    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }396397    /** Unlock body without acquiring the mutex — callers must already hold it. */398    private fun unlockLocked(password: String): UnlockResult {399        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")400        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))401        val payload = try {402            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))403        } catch (_: Exception) {404            VaultCrypto.zero(key)405            // LV-1: a vault sealed before NFC normalization with a non-NFC406            // password derives a different key now. If the supplied password is407            // not already in NFC form, retry with the raw (un-normalized) bytes.408            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {409                val legacy = tryLegacyRawUnlock(password, slotA)410                if (legacy != null) return legacy411            }412            return UnlockResult.WrongPassword413        }414        return adoptSlotA(key, payload)415    }416417    /**418     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw419     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed420     * under the NFC-normalized key — so every future unlock takes the normal421     * path. Returns null when the raw key also fails (genuinely wrong password)422     * OR when any step of the heal commits fails (the original slot A is restored423     * so disk and session stay aligned).424     */425    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {426        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))427        val payload = try {428            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))429        } catch (_: Exception) {430            VaultCrypto.zero(rawKey)431            return null432        }433        // Heal: re-seal slot A under the NFC key (mirrors changePassword's434        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.435        val newSalt = VaultCrypto.generateSalt()436        val newKey = VaultCrypto.deriveKey(password, newSalt)437        val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))438        val next = StoredVault(439            version = slotA.version,440            salt = b64(newSalt),441            blob = blob,442            recoveryEscrow = slotA.recoveryEscrow,443            rootKeyEscrow = slotA.rootKeyEscrow,444        )445        // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,446        // or stampLocalMutation must restore the prior slot A AND slot B so disk447        // and the eventual session key never disagree on the wrapping.448        val priorB = store.loadSlotB()449        val healed = if (!store.writeSlotA(next)) {450            false451        } else try {452            if (!writeMatchingNoiseSlotB(next)) {453                runCatching { store.writeSlotA(slotA) }454                if (priorB != null) runCatching { store.writeSlotB(priorB) }455                false456            } else {457                store.stampLocalMutation()458                true459            }460        } catch (_: Exception) {461            runCatching { store.writeSlotA(slotA) }462            if (priorB != null) runCatching { store.writeSlotB(priorB) }463            false464        }465        return if (healed) {466            VaultCrypto.zero(rawKey)467            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot468        } else {469            VaultCrypto.zero(newKey)470            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key471        }472    }473474    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */475    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {476        return try {477            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they478            // can throw after the fields above are live. There is no prior session479            // worth restoring — adoption has already replaced it — so a failed480            // adoption discards everything rather than leaving entries and keys481            // resident behind _isUnlocked=false.482            mutateState {483                derivedKey = key484                decoyMode = false485                entryList = payload.entries.toMutableList()486                duressConfigured = payload.duressConfigured487                recoveryWordHash = payload.recoveryWordHash488                recoveryWordsSet = payload.recoveryWordsSet489                _settings.value = store.loadSettings()490                ensureRootKey(payload)491                autoPurgeExpiredTrash()492                publish()493                _isUnlocked.value = true494            }495            UnlockResult.Success496        } catch (e: Exception) {497            lock()498            UnlockResult.Error(e.message ?: "unlock_failed")499        }500    }501502    // ---- Constant-time unlock (MF-5) --------------------------------------503504    /**505     * A slot-A unlock that has been derived but not yet adopted. Holds the live506     * Argon2id key and decoded payload so the caller can derive several candidates507     * concurrently and commit only the winner. The key is owned by this token until508     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.509     */510    class SlotAUnlock internal constructor(511        internal val key: ByteArray,512        internal val payload: VaultPayload,513    )514515    /**516     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.517     * Pure computation (no mutex), so it can run concurrently with other candidate518     * derivations before the caller decides which to commit (MF-5). Returns null on519     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).520     */521    fun tryUnlockSlotA(password: String): SlotAUnlock? {522        val slotA = store.loadSlotA() ?: return null523        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))524        return try {525            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))526            SlotAUnlock(key, payload)527        } catch (_: Exception) {528            VaultCrypto.zero(key)529            null530        }531    }532533    /** Adopt a previously-derived [SlotAUnlock] as the live session. */534    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {535        adoptSlotA(unlock.key, unlock.payload)536    }537538    /** Zero a derived [SlotAUnlock] that will not be adopted. */539    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)540541    // ---- Duress slot B (MF-6) ---------------------------------------------542543    /**544     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key545     * and decoded decoy payload so it can be derived concurrently with slot A and546     * only committed if the duress passphrase was the one entered (MF-5/MF-6).547     */548    class DuressUnlock internal constructor(549        internal val key: ByteArray,550        internal val payload: VaultPayload,551    )552553    /**554     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the555     * session. Success means the entered password is the duress passphrase that keys556     * slot B — indistinguishable from the real slot A at the lock screen. Pure557     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].558     * Returns null when no duress slot exists or the password is not the duress one.559     */560    fun tryUnlockDuress(password: String): DuressUnlock? {561        val slot = store.loadDecoySlot() ?: return null562        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))563        return try {564            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))565            DuressUnlock(key, payload)566        } catch (_: Exception) {567            VaultCrypto.zero(key)568            null569        }570    }571572    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */573    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {574        return try {575            mutateState {576                derivedKey = unlock.key577                decoyMode = true578                entryList = unlock.payload.entries.toMutableList()579                duressConfigured = false580                recoveryWordHash = unlock.payload.recoveryWordHash581                recoveryWordsSet = unlock.payload.recoveryWordsSet582                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)583                          else VaultCrypto.generateRootKey()584                _settings.value = store.loadSettings()585                publish()586                _isUnlocked.value = true587            }588            UnlockResult.Success589        } catch (e: Exception) {590            UnlockResult.Error(e.message ?: "decoy_unlock_failed")591        }592    }593594    /** Zero a derived [DuressUnlock] that will not be adopted. */595    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)596597    /**598     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an599     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The600     * passphrase is never stored — only its ability to decrypt slot B proves it.601     * Requires the real vault to be unlocked and not itself a decoy session.602     */603    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {604        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().605        val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }606        if (!unlockedAndReal) return false607        // Reject a duress passphrase that also opens the real vault: it would make the608        // lock screen ambiguous (both slots would decrypt for the same input).609        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }610        return try {611            val salt = VaultCrypto.generateSalt()612            val key = VaultCrypto.deriveKey(duressPassphrase, salt)613            // An empty decoy vault with its own fresh sync root key — nothing real leaks.614            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))615            val plaintext = VaultCodec.encode(decoyPayload)616            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))617            VaultCrypto.zero(key)618            VaultCrypto.zero(plaintext)619            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))620            if (ok) {621                mutateState { duressConfigured = true }622                runCatching { persistSlotA(bumpVersion = false) }623            }624            ok625        } catch (_: Exception) {626            false627        }628    }629630    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */631    suspend fun clearDuress(): Boolean = mutex.withLock {632        store.clearDecoySlot()633        // H-03: stateLock-fence the derivedKey/decoyMode read AND the634        // duressConfigured write so a concurrent lock() cannot land between them.635        val shouldClear = mutateState { derivedKey != null && !decoyMode }636        if (shouldClear) {637            mutateState { duressConfigured = false }638            runCatching { persistSlotA(bumpVersion = false) }639        }640        true641    }642643    /** Unlock with an already-derived AES-256 key (biometric path). */644    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {645        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")646        return try {647            val payload = VaultCodec.decode(648                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),649            )650            mutateState {651                derivedKey = key.copyOf()652                decoyMode = false653                entryList = payload.entries.toMutableList()654                duressConfigured = payload.duressConfigured655                recoveryWordHash = payload.recoveryWordHash656                recoveryWordsSet = payload.recoveryWordsSet657                _settings.value = store.loadSettings()658                ensureRootKey(payload)659                autoPurgeExpiredTrash()660                publish()661                _isUnlocked.value = true662            }663            UnlockResult.Success664        } catch (_: Exception) {665            UnlockResult.WrongPassword666        }667    }668669    fun lock() {670        // H-03: must observe derivedKey/entryList atomically with any in-flight671        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,672        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock673        // guards just the brief state reset.674        stateLock.lock()675        try {676            derivedKey?.let(VaultCrypto::zero)677            rootKey?.let(VaultCrypto::zero)678            derivedKey = null679            rootKey = null680            entryList = mutableListOf()681            duressConfigured = false682            recoveryWordHash = null683            recoveryWordsSet = false684            decoyMode = false685            _entries.value = emptyList()686            _isUnlocked.value = false687        } finally {688            stateLock.unlock()689        }690    }691692    @Volatile private var suppressNextBackgroundLock = false693694    /**695     * Call immediately before launching a system Activity (a SAF file696     * picker/save dialog, a share sheet) that briefly takes the whole process697     * out of the foreground as an expected part of an in-app flow — not a698     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the699     * two apart on its own (it fires for both), and without this, exporting or700     * importing a file silently locked and wiped the vault mid-flow the moment701     * the picker took over, before its result ever reached the app.702     */703    fun expectTransientBackground() {704        suppressNextBackgroundLock = true705    }706707    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */708    fun consumeTransientBackgroundSuppression(): Boolean {709        val suppressed = suppressNextBackgroundLock710        suppressNextBackgroundLock = false711        return suppressed712    }713714    // ---- Sync identity ----------------------------------------------------715716    /**717     * The vault's stable sync master key (a copy). Caller must zero it. Backs718     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry719     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].720     */721    /**722     * HOLE 5 — the duress vault never syncs.723     *724     * [commitDuressUnlock] gives the decoy session a root key so its own codex725     * and recovery paths work, which means (unlike the browser, where the decoy726     * path simply has no root key) the sync surface has to refuse explicitly.727     * Without this the decoy vault would present a second, different vault_id728     * to a paired device, and a coerced user opening it would make sync visibly729     * fail on their other devices — a duress indicator, which is the one thing730     * a duress vault must never be.731     */732    // M-04: the "null when locked" half of the contract was documented but never733    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see734    // [unlockWithRecoveryWords], which installs a root key with no password key735    // yet) and in a create that failed after staging. Sync must fail closed736    // there, so the authoritative unlocked flag gates it, not just decoyMode.737    fun syncMasterKey(): ByteArray? =738        if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()739740    /**741     * The current session's root key, decoy included. For local-only uses that742     * must behave identically in a decoy session — Codex backup and recovery743     * words — because a feature that errors under duress is itself a duress744     * tell. In a decoy session this is the DECOY's root key (see745     * [commitDuressUnlock]), never the real vault's, so neither surface can746     * leak the real vault. Anything that leaves the device must use747     * [syncMasterKey] instead.748     */749    fun sessionRootKey(): ByteArray? = rootKey?.copyOf()750751    /** Stable per-vault id two paired devices must share to sync. Null when752     *  locked, and never present for the decoy vault (see [syncMasterKey]). */753    fun syncVaultId(): String? =754        if (decoyMode || !_isUnlocked.value) null755        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }756757    /**758     * Browser parity (MV-8): rotate the sync identity by replacing the stable759     * root key. Entries remain under the master-password key, while sync760     * credentials, vault_id, and recovery words all change.761     */762    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {763        installRootKey(generateRecoverableRootKey())764    }765766    /**767     * Join the vault behind [rootKeyHex]: adopt that vault's identity while768     * keeping every local entry.769     *770     * Sync identity (vault id, signing keys) derives from the root key, and771     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently772     * created vaults can never reconcile. Taking the other side's root key is773     * what makes them one vault; from the next sync on, both entry sets merge.774     *775     * Entries survive because they are sealed under the password-derived key,776     * not the root key — the same property [rotateSyncIdentity] relies on.777     *778     * CONSEQUENCE, which the caller must have disclosed and had confirmed:779     * this device's previous recovery words stop working for good, and its new780     * words are the source vault's words, because words are a deterministic781     * encoding of the root key.782     */783    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {784        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {785            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")786        }787        if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")788        if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")789        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")790791        val incoming = hexToRootKey(rootKeyHex)792        if (incoming.size != VaultCrypto.KEY_SIZE) {793            VaultCrypto.zero(incoming)794            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")795        }796        // Refuse before committing rather than leave this device holding a vault797        // whose recovery words cannot be typed back. generateRecoverableRootKey798        // screens its own keys for this; a key arriving from a peer has not been.799        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess800        if (!roundTrips) {801            VaultCrypto.zero(incoming)802            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")803        }804805        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)806        return if (installRootKey(incoming)) {807            AdoptVaultResult.Success(vaultId)808        } else {809            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")810        }811    }812813    /**814     * The recovery words [rootKeyHex] will produce once joined, or null if it is815     * not a usable root key. Lets the join flow show the user the words that are816     * about to replace theirs *before* anything is committed.817     */818    fun previewRecoveryWords(rootKeyHex: String): List<String>? {819        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null820        val bytes = hexToRootKey(rootKeyHex)821        return try {822            val words = rootKeyToWords(bytes)823            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null824        } catch (_: Exception) {825            null826        } finally {827            VaultCrypto.zero(bytes)828        }829    }830831    /**832     * Swap the vault's root key, keeping every entry, and re-escrow under the833     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds834     * [mutex].835     */836    private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {837        // H-03: snapshot the live fields under stateLock so the success and the838        // rollback paths both see consistent prior/new state. Writing the new839        // rootKey/recovery fields is inside its own mutateState at the end.840        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays841        // — holding live references would let a zero-on-failure operation also842        // wipe the session's still-live key arrays.843        data class Snapshot(844            val key: ByteArray,845            val priorRootKey: ByteArray,846            val priorRecoveryHash: String?,847            val priorRecoverySet: Boolean,848        )849        val snap = mutateState {850            val k = derivedKey ?: return false851            if (decoyMode) return false852            val priorRoot = rootKey ?: return false853            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)854        } as Snapshot? ?: return false855        val priorSlot = store.loadSlotA() ?: run {856            VaultCrypto.zero(snap.priorRootKey)857            return false858        }859        val nextWords = rootKeyToWords(nextRootKey)860861        return try {862            takeVaultSnapshot("local_save")863            mutateState {864                rootKey = nextRootKey865                recoveryWordHash = recoveryWordsHash(nextWords)866                recoveryWordsSet = false867            }868869            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)870                ?: throw IllegalStateException("recovery_escrow_failed")871            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)872                ?: throw IllegalStateException("root_escrow_failed")873            val plaintext = VaultCodec.encode(currentPayload())874            val blob = try {875                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))876            } finally {877                VaultCrypto.zero(plaintext)878            }879            val nextSlot = priorSlot.copy(880                blob = blob,881                recoveryEscrow = nextRecoveryEscrow,882                rootKeyEscrow = nextRootKeyEscrow,883            )884            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")885            try {886                writeMatchingNoiseSlotB(nextSlot)887                store.stampLocalMutation()888            } catch (e: Exception) {889                store.writeSlotA(priorSlot)890                throw e891            }892            VaultCrypto.zero(snap.priorRootKey)893            mutateState { publish() }894            true895        } catch (_: Exception) {896            mutateState {897                rootKey = snap.priorRootKey898                recoveryWordHash = snap.priorRecoveryHash899                recoveryWordsSet = snap.priorRecoverySet900            }901            VaultCrypto.zero(nextRootKey)902            store.writeSlotA(priorSlot)903            false904        }905    }906907    /**908     * A copy of the live Argon2id-derived vault key, for biometric escrow only909     * (wrapped under a hardware KeyStore key on biometric enable). Null when910     * locked. Caller must [VaultCrypto.zero] it after use.911     */912    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()913914    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {915        val slotA = store.loadSlotA() ?: return false916        var oldKey: ByteArray? = null917        var newKey: ByteArray? = null918        var plaintext: ByteArray? = null919        return try {920            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))921            val payload = VaultCodec.decode(922                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),923            )924            val newSalt = VaultCrypto.generateSalt()925            newKey = VaultCrypto.deriveKey(newPassword, newSalt)926            // Re-seal the existing payload under the new key/salt.927            plaintext = VaultCodec.encode(payload)928            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))929            // The escrow seals the slot-A PLAINTEXT, which a password change does not930            // alter — only the wrapping changes — so carry it forward; rebuilding the931            // StoredVault from scratch would silently drop it.932            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key933            // content that a password change does not alter — only the slot-A934            // wrapping rotates. The root-key escrow staying stable across password935            // changes matches the browser (`vault.ts:363-364`).936            val next = StoredVault(937                version = slotA.version,938                salt = b64(newSalt),939                blob = blob,940                recoveryEscrow = slotA.recoveryEscrow,941                rootKeyEscrow = slotA.rootKeyEscrow,942            )943            // H-01: durably commit slot A + size-matched noise + version stamp944            // before swapping the session key. A partial commit (new A on disk,945            // old key in memory) would have the next save encrypt under the old946            // key while matching disk on the new one.947            val priorB = store.loadSlotB()948            if (!commitVaultRotation(slotA, priorB, next)) return false949            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one950            // inside the same stateLock critical section, so a concurrent lock()951            // can't observe the in-between (zeroed but not yet reassigned).952            mutateState {953                oldKey?.let(VaultCrypto::zero); oldKey = null954                derivedKey?.let(VaultCrypto::zero)955                derivedKey = newKey; newKey = null956            }957            true958        } catch (_: Exception) {959            false960        } finally {961            oldKey?.let(VaultCrypto::zero)962            newKey?.let(VaultCrypto::zero)963            plaintext?.let(VaultCrypto::zero)964        }965    }966967    /**968     * Irreversible wipe. Locks the session and clears every vault key. The Pro969     * license is in a separate store and is preserved by construction.970     */971    suspend fun destroyVault() = mutex.withLock {972        lock()973        store.clearVault()974        store.clearDecoySlot()975    }976977    // ---- Entry CRUD -------------------------------------------------------978979    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {980        requireUnlocked()981        val stamp = VaultCrypto.monotonicEntryTimestamp()982        val now = System.currentTimeMillis()983        val entry = withDerivedLast4(draft.copy(984            id = draft.id.ifBlank { UUID.randomUUID().toString() },985            createdAt = if (draft.createdAt > 0) draft.createdAt else now,986            modifiedAt = stamp,987            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,988        ))989        // H-03: capture rollback AND write entryList under stateLock so a990        // concurrent lock() cannot zero the session between the read and the write.991        val rollback = mutateState {992            val r = entryList.toMutableList()993            entryList.add(entry)994            r995        }996        persistOrRollback(rollback)997        entry998    }9991000    /**1001     * Insert many entries with a single encrypt-and-write at the end.1002     *1003     * Importing a real vault through [addEntry] was quadratic: every entry took1004     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1005     * restore did 806 full-vault writes and hung the app long enough to ANR.1006     * Rolls back to the pre-import list if the one write fails, matching1007     * [addEntry]'s all-or-nothing behaviour.1008     */1009    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1010        requireUnlocked()1011        if (drafts.isEmpty()) return 01012        val now = System.currentTimeMillis()1013        val ready = drafts.map { draft ->1014            val stamp = VaultCrypto.monotonicEntryTimestamp()1015            withDerivedLast4(1016                draft.copy(1017                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1018                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1019                    modifiedAt = stamp,1020                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1021                ),1022            )1023        }1024        // H-03: capture rollback AND append every entry under stateLock.1025        val rollback = mutateState {1026            val r = entryList.toMutableList()1027            entryList.addAll(ready)1028            r1029        }1030        persistOrRollback(rollback)1031        return drafts.size1032    }10331034    /**1035     * M-01: replace the whole active set with [newEntries] in one write.1036     *1037     * Import's REPLACE mode used to soft-delete every current entry one durable1038     * write at a time (swallowing each failure) and then batch-add, so a failed1039     * add left the old entries stranded in Trash with nothing restored. Staging1040     * the entire next list in memory and committing it through1041     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1042     * pre-import list — active entries included — is restored.1043     *1044     * Existing tombstones are preserved so paired devices still see the1045     * deletions they already know about.1046     */1047    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1048        requireUnlocked()1049        val now = System.currentTimeMillis()1050        val ready = newEntries.map { draft ->1051            val stamp = VaultCrypto.monotonicEntryTimestamp()1052            withDerivedLast4(1053                draft.copy(1054                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1055                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1056                    modifiedAt = stamp,1057                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1058                ),1059            )1060        }1061        // M-03: an incoming entry that shares an id with an existing live entry1062        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1063        // the old one and then appending the new one would leave the list with1064        // two entries of the same id — the tombstoned one would be returned by1065        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1066        // superseded entries from the tombstone set instead.1067        val incomingIds = ready.map { it.id }.toHashSet()1068        val rollback = mutateState {1069            val r = entryList.toMutableList()1070            val replaced = entryList.map { entry ->1071                if (entry.deletedAt != null) return@map entry1072                if (entry.id in incomingIds) return@map null1073                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1074                entry.copy(deletedAt = stamp, modifiedAt = stamp)1075            }.filterNotNull()1076            entryList = (replaced + ready).toMutableList()1077            r1078        }1079        persistOrRollback(rollback)1080        return ready.size1081    }10821083    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1084        requireUnlocked()1085        // H-03: read entryList, compute the patched entry, and write it under a1086        // single stateLock so a concurrent lock() cannot interleave between the1087        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1088        // list, or clobber the entry onto the wrong id after a relock).1089        var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1090        mutateState {1091            val idx = entryList.indexOfFirst { it.id == patch.id }1092            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1093            val current = entryList[idx]1094            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1095            // Only refresh passwordChangedAt when the password value actually changes.1096            val passwordChanged = patch.password != null && patch.password != current.password1097            // Capture the outgoing password (newest first) so the user can reveal/copy/1098            // restore it later. History is computed authoritatively from `current`, not1099            // the patch — updateEntry is a full-entry replace, so trusting patch's value1100            // would let a stale form clobber the real history. Record the age the old1101            // password carried, skip an empty prior, and skip an immediate exact dupe1102            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1103            val priorPassword = current.password1104            val shouldCapture = passwordChanged &&1105                !priorPassword.isNullOrEmpty() &&1106                current.passwordHistory?.firstOrNull()?.password != priorPassword1107            val nextHistory = if (shouldCapture) {1108                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1109                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1110            } else {1111                current.passwordHistory1112            }1113            val updated = withDerivedLast4(patch.copy(1114                createdAt = current.createdAt,1115                modifiedAt = stamp,1116                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1117                passwordHistory = nextHistory,1118            ))1119            val rollback = entryList.toMutableList()1120            entryList[idx] = updated1121            prepared = updated to rollback1122        }1123        val (updated, rollback) = prepared!!1124        persistOrRollback(rollback)1125        updated1126    }11271128    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1129    suspend fun deleteEntry(id: String) = mutex.withLock {1130        requireUnlocked()1131        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1132        // the list out from under the indexOfFirst.1133        var rollback: MutableList<VaultEntry>? = null1134        mutateState {1135            val idx = entryList.indexOfFirst { it.id == id }1136            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1137            val current = entryList[idx]1138            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1139            rollback = entryList.toMutableList()1140            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1141        }1142        persistOrRollback(rollback!!)1143    }11441145    suspend fun restoreEntry(id: String) = mutex.withLock {1146        requireUnlocked()1147        var rollback: MutableList<VaultEntry>? = null1148        mutateState {1149            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1150            if (idx == -1) throw IllegalStateException("deleted entry not found")1151            val current = entryList[idx]1152            rollback = entryList.toMutableList()1153            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1154        }1155        persistOrRollback(rollback!!)1156    }11571158    /** Permanent delete — no tombstone, no recovery. */1159    suspend fun purgeEntry(id: String) = mutex.withLock {1160        requireUnlocked()1161        var rollback: MutableList<VaultEntry>? = null1162        mutateState {1163            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1164            if (idx == -1) throw IllegalStateException("deleted entry not found")1165            rollback = entryList.toMutableList()1166            entryList.removeAt(idx)1167        }1168        persistOrRollback(rollback!!)1169    }11701171    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1172    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1173        requireUnlocked()1174        // H-03: read+remove under stateLock so lock() can't reorder the remove.1175        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1176        val r = mutateState {1177            val rollback = entryList.toMutableList()1178            val before = entryList.size1179            entryList.removeAll { it.deletedAt != null }1180            R(rollback, before - entryList.size)1181        }1182        if (r.removed > 0) persistOrRollback(r.rollback)1183        r.removed1184    }11851186    /** Browser parity: number of active entries that dedupe would move to trash. */1187    suspend fun countDuplicateEntries(): Int = mutex.withLock {1188        requireUnlocked()1189        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1190        // lock() can't race the read with state writes.1191        mutateState { countDuplicateEntriesLocked() }1192    }11931194    /**1195     * Browser parity: soft-delete duplicate entries, keeping the most recently1196     * modified member of each group. Refuse in decoy mode so the duress vault is1197     * not reshaped into a forensically distinct state.1198     */1199    suspend fun deduplicateEntries(): Int = mutex.withLock {1200        requireUnlocked()1201        // H-03: read the decoyMode flag and the entry list, then reassign1202        // entryList, all under one stateLock.1203        val decoy = mutateState { decoyMode }1204        if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1205        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1206        val ready = mutateState {1207            val active = entryList.filter { it.deletedAt == null }1208            val keepIds = mutableSetOf<String>()1209            active.groupBy { it.duplicateKey() }.values.forEach { group ->1210                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1211            }1212            val rollback = entryList.toMutableList()1213            var removed = 01214            entryList = entryList.map { entry ->1215                if (entry.deletedAt == null && entry.id !in keepIds) {1216                    removed++1217                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1218                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1219                } else {1220                    entry1221                }1222            }.toMutableList()1223            R(rollback, removed)1224        }1225        if (ready.removed > 0) persistOrRollback(ready.rollback)1226        ready.removed1227    }12281229    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1230    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1231    fun getDeletedEntries(): List<VaultEntry> =1232        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }12331234    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1235    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1236        val active = entryList.filter { it.deletedAt == null }1237        if (tagFilter == null) return active1238        val needle = tagFilter.trim().lowercase()1239        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1240    }12411242    /** Sorted, de-duplicated tags across all active entries. */1243    fun getDistinctTags(): List<String> =1244        entryList.asSequence()1245            .filter { it.deletedAt == null }1246            .flatMap { it.tags?.asSequence() ?: emptySequence() }1247            .map { it.trim().lowercase() }1248            .filter { it.isNotEmpty() }1249            .distinct()1250            .sorted()1251            .toList()12521253    /**1254     * Distinct non-blank values for an identity field across active entries,1255     * most-frequently-used first. Supports "username" and "email"; unknown field1256     * names return an empty list. Values keep original casing (deduped1257     * case-insensitively) so a suggestion matches what the user typed.1258     */1259    fun getDistinctFieldValues(field: String): List<String> {1260        val select: (VaultEntry) -> String? = when (field) {1261            "username" -> { e -> e.username }1262            "email" -> { e -> e.email }1263            else -> return emptyList()1264        }1265        return entryList.asSequence()1266            .filter { it.deletedAt == null }1267            .mapNotNull { select(it)?.trim() }1268            .filter { it.isNotEmpty() }1269            .groupingBy { it.lowercase() }1270            .aggregate { _, acc: Pair<String, Int>?, value, _ ->1271                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1272            }1273            .values1274            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1275            .map { it.first }1276    }12771278    fun getTombstones(): List<Tombstone> =1279        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1280    fun getVaultVersion(): Long = store.version()1281    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots12821283    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1284        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1285        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1286        val priorA = store.loadSlotA()1287        val priorB = store.loadSlotB()1288        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1289        // slot write — the old code took this snapshot, then stranded it on1290        // every failure, polluting history with a "restore" entry that never1291        // restored anything. The history below is rolled back together with1292        // the slots if the commit fails.1293        val priorHistory = store.loadVaultHistory()1294        // P1-4 (codex): the recovery snapshot must land before we commit the1295        // slot pair, otherwise a failed restore cannot be rolled back to the1296        // pre-restore history. If the history write itself fails, refuse to1297        // proceed — the alternative is a successful restore with no undo path.1298        if (!takeVaultSnapshot("restore")) {1299            return false1300        }1301        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) {1302            store.writeVaultHistory(priorHistory)1303            return false1304        }1305        lock()1306        true1307    }13081309    /**1310     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1311     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1312     * (a restore preserves the duress-shaped blob from history), not a fresh1313     * noise record. Any failure rolls back so the at-rest pair never holds a1314     * restored slot A against a pre-restore slot B. Caller is responsible for1315     * restoring the pre-snapshot history on a false return.1316     */1317    private fun commitRestoreAtomic(1318        nextA: StoredVault,1319        nextB: String,1320        priorA: StoredVault?,1321        priorB: String?,1322    ): Boolean {1323        if (!store.writeSlotA(nextA)) return false1324        // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1325        // must trigger rollback; previously a throw escaped without restoring1326        // the prior slot A and history.1327        try {1328            if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1329            store.stampLocalMutation()1330        } catch (_: Exception) {1331            rollbackRestore(priorA, priorB)1332            return false1333        }1334        return true1335    }13361337    // ---- Sync apply (does NOT advance the version) ------------------------13381339    /**1340     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1341     * WITHOUT bumping the version counter, so a sync round cannot cascade into1342     * another outbound sync. Returns the number of locally-applied changes.1343     */1344    suspend fun applyMergedRemoteEntries(1345        remoteEntries: List<VaultEntry>,1346        remoteTombstones: List<Tombstone>,1347    ): Int = mutex.withLock {1348        requireUnlocked()1349        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1350        // BEFORE any comparison against a local one. Without this a peer can1351        // send a far-future modifiedAt and permanently pin its version of an1352        // entry against every honest local edit. The browser clamps in the1353        // matching place (vault.ts applyMergedRemoteEntries).1354        val mergeNow = System.currentTimeMillis()1355        val clamped = remoteEntries.map { entry ->1356            entry.copy(1357                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1358                deletedAt = entry.deletedAt?.let {1359                    SyncLimits.clampSyncTimestamp(it, mergeNow)1360                },1361            )1362        }1363        val tombMap = remoteTombstones.associate {1364            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1365        }1366        // H-03: build the new entryList under stateLock so a concurrent lock()1367        // cannot zero derivedKey/entryList between the byId read and the merge.1368        val applied = mutateState {1369            var n = 01370            val byId = entryList.associateBy { it.id }.toMutableMap()13711372            for (remote in clamped) {1373                val tomb = tombMap[remote.id]1374                if (tomb != null && tomb >= remote.modifiedAt) continue1375                val local = byId[remote.id]1376                if (local == null) {1377                    entryList.add(remote); byId[remote.id] = remote; n++1378                } else if (remote.modifiedAt > local.modifiedAt) {1379                    val i = entryList.indexOfFirst { it.id == local.id }1380                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1381                }1382            }13831384            for ((id, deletedAt) in tombMap) {1385                val local = byId[id] ?: continue1386                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1387                if (deletedAt < local.modifiedAt) continue1388                val i = entryList.indexOfFirst { it.id == id }1389                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1390            }1391            n1392        }13931394        if (applied > 0) {1395            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1396            mutateState { publish() }1397        }1398        applied1399    }14001401    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1402        // _settings is a StateFlow (atomic write), but publish under stateLock so1403        // any concurrent lock() observes either the old or the new value — not1404        // ordering with the in-memory entries flip.1405        mutateState { _settings.value = settings }1406        store.writeSettings(settings)1407    }14081409    // ---- Recovery words (vault metadata) ----------------------------------14101411    /** True once the user has confirmed their recovery words for this vault. */1412    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet14131414    /**1415     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1416     * Persisted without bumping the version — recovery metadata is local-only and1417     * is not an entry mutation paired devices should sync against.1418     */1419    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1420        requireUnlocked()1421        // H-03: hash + escrow-related state fields set under stateLock so a1422        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.1423        // M-04: the flags have to be live before the escrows are built — both seal1424        // currentPayload() — so capture the prior pair and put it back if the write1425        // never lands. Otherwise the session reports words set against a slot A1426        // that still holds no escrow, and the recovery screen never offers a retry.1427        val prior = mutateState {1428            val priorPair = recoveryWordHash to recoveryWordsSet1429            recoveryWordHash = recoveryWordsHash(words)1430            recoveryWordsSet = true1431            priorPair1432        }1433        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow1434        // (cross-platform recovery of the sync root key) and Android's whole-payload1435        // escrow (offline forgot-password restore of entries). Both derive their key1436        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.1437        val persisted = runCatching {1438            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1439        }.getOrDefault(false)1440        if (!persisted) {1441            mutateState {1442                recoveryWordHash = prior.first1443                recoveryWordsSet = prior.second1444            }1445        }1446        persisted1447    }14481449    /** Verify entered words against the stored recovery hash. */1450    fun verifyRecoveryWords(words: List<String>): Boolean {1451        val stored = recoveryWordHash ?: return false1452        return stored == recoveryWordsHash(words)1453    }14541455    // ---- Recovery escrow (forgot-password) --------------------------------14561457    /** True at rest if this vault has any recovery escrow the recovery flow can use. */1458    fun hasRecoveryEscrow(): Boolean =1459        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false14601461    /**1462     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of1463     * the master password. On success the slot-A plaintext sealed in the escrow is1464     * adopted as the live session (entries, rootKey, settings), but no Argon2id1465     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a1466     * new password and finish unlocking. Returns false when there is no escrow, the1467     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).1468     *1469     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no1470     * detached vault key to return here — slot A is wrapped under the rotating1471     * Argon2id key — so this opens the escrowed payload directly.1472     */1473    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1474        val slotA = store.loadSlotA() ?: return false1475        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false14761477        // Prefer Android's whole-payload escrow: a full offline restore of entries,1478        // root key, and settings. Present for Android-created vaults.1479        slotA.recoveryEscrow?.let { escrow ->1480            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1481            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1482            val keyBytes = recoveryKey.encoded1483            try {1484                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1485                val payload = VaultCodec.decode(plaintext)1486                // H-03: adoption is one atomic state-write — lock() cannot interleave.1487                mutateState {1488                    decoyMode = false1489                    entryList = payload.entries.toMutableList()1490                    duressConfigured = payload.duressConfigured1491                    recoveryWordHash = payload.recoveryWordHash1492                    recoveryWordsSet = payload.recoveryWordsSet1493                    rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1494                              else generateRecoverableRootKey()1495                    _settings.value = store.loadSettings()1496                }1497                VaultCrypto.zero(plaintext)1498                return true1499            } catch (_: Exception) {1500                // Wrong words against this escrow — fall through to the root-key escrow.1501            } finally {1502                VaultCrypto.zero(material)1503                VaultCrypto.zero(keyBytes)1504            }1505        }15061507        // Browser-format vault (only the root-key escrow): recover the 32-byte root1508        // key (sync identity) and reset the password; entries return via sync. This1509        // is exactly the browser's recovery semantics (`messages.ts:312-347`).1510        slotA.rootKeyEscrow?.let { escrow ->1511            val rk = unsealRootKeyEscrow(words, escrow) ?: return false1512            mutateState {1513                decoyMode = false1514                entryList = mutableListOf()1515                duressConfigured = false1516                recoveryWordHash = recoveryWordsHash(words)1517                recoveryWordsSet = true1518                rootKey = rk1519                _settings.value = store.loadSettings()1520            }1521            return true1522        }1523        return false1524    }15251526    /**1527     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1528     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1529     * words, mark the session fully unlocked, and publish. Returns false if called1530     * without a prior successful recovery unlock (no in-memory root key).1531     *1532     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1533     * into one step, since there is no detached vault key here.1534     */1535    suspend fun resetPasswordWithRecoveryWords(1536        words: List<String>,1537        newPassword: String,1538    ): Boolean = mutex.withLock {1539        // H-03: read rootKey + entryList under stateLock before computing the new1540        // plaintext, then re-publish + swap the derived key under stateLock so a1541        // concurrent lock() cannot straddle the two fences.1542        if (!mutateState { rootKey != null }) return false1543        val priorA = store.loadSlotA() ?: return false1544        var newKey: ByteArray? = null1545        var plaintext: ByteArray? = null1546        return try {1547            val newSalt = VaultCrypto.generateSalt()1548            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1549            plaintext = VaultCodec.encode(currentPayload())1550            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1551            // Rebuild both escrows against the recovered session so their salts rotate1552            // with the password reset; fall back to the prior escrows if rebuild fails1553            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1554            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1555            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1556            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1557            val next = StoredVault(1558                version = priorA.version,1559                salt = b64(newSalt),1560                blob = blob,1561                recoveryEscrow = freshRecovery,1562                rootKeyEscrow = freshRootKey,1563            )1564            // H-01: see changePassword — both rotations share the same1565            // commit-or-keep-old-key contract so a partial write can't strand1566            // the session on a key that no longer matches disk.1567            val priorB = store.loadSlotB()1568            if (!commitVaultRotation(priorA, priorB, next)) return false1569            // H-03 (P1-2 codex): zero the old live key and install the new one1570            // inside the same stateLock critical section so a concurrent lock()1571            // can't observe the in-between.1572            mutateState {1573                derivedKey?.let(VaultCrypto::zero)1574                derivedKey = newKey; newKey = null1575                publish()1576                _isUnlocked.value = true1577            }1578            true1579        } catch (_: Exception) {1580            false1581        } finally {1582            newKey?.let(VaultCrypto::zero)1583            plaintext?.let(VaultCrypto::zero)1584        }1585    }15861587    /**1588     * Seal the current slot-A plaintext under a key derived from the 12 BIP391589     * recovery [words] (Android offline-restore escrow). IKM is the browser's1590     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1591     * Returns null for a checksum-invalid phrase.1592     */1593    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1594        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1595        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1596        val salt = VaultCrypto.generateSalt()1597        val recoveryKey = deriveRecoveryKey(material, salt)1598        val keyBytes = recoveryKey.encoded1599        val plaintext = VaultCodec.encode(currentPayload())1600        return try {1601            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1602            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1603        } finally {1604            VaultCrypto.zero(material)1605            VaultCrypto.zero(keyBytes)1606            VaultCrypto.zero(plaintext)1607        }1608    }16091610    /**1611     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1612     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1613     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1614     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1615     * the same root key on the browser. Returns null with no in-memory root key or1616     * a checksum-invalid phrase.1617     */1618    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1619        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1620        val rk = rootKey ?: return null1621        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1622        val salt = VaultCrypto.generateSalt()1623        val recoveryKey = deriveRecoveryKey(material, salt)1624        val keyBytes = recoveryKey.encoded1625        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1626        return try {1627            // combined = nonce(12) || ciphertext || tag(16)1628            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1629            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1630            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1631            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1632            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1633            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1634        } finally {1635            VaultCrypto.zero(material)1636            VaultCrypto.zero(keyBytes)1637            VaultCrypto.zero(plaintext)1638        }1639    }16401641    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1642    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1643        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1644        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1645        val keyBytes = recoveryKey.encoded1646        var pt: ByteArray? = null1647        return try {1648            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1649            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1650            hexToRootKey(String(pt, Charsets.US_ASCII))1651        } catch (_: Exception) {1652            null1653        } finally {1654            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the1655            // IKM-equivalent of the vault identity. material+keyBytes were1656            // already zeroed; pt was leaking.1657            VaultCrypto.zero(material)1658            VaultCrypto.zero(keyBytes)1659            pt?.let(VaultCrypto::zero)1660        }1661    }16621663    /**1664     * Re-seal slot A from the current in-memory state under the live key, carrying1665     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1666     * in decoy mode (recovery is never offered for the decoy) and persists without1667     * a version bump, like the recovery-words metadata write it replaces.1668     */1669    private fun persistSlotAWithEscrows(1670        recoveryEscrow: RecoveryEscrow?,1671        rootKeyEscrow: RootKeyEscrow?,1672    ): Boolean {1673        val key = derivedKey ?: throw IllegalStateException("vault_locked")1674        if (decoyMode) return false1675        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1676        val plaintext = VaultCodec.encode(currentPayload())1677        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1678        val next = prior.copy(1679            blob = blob,1680            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1681            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1682        )1683        if (!store.writeSlotA(next)) return false1684        try {1685            writeMatchingNoiseSlotB(next)1686        } catch (e: Exception) {1687            store.writeSlotA(prior)1688            throw e1689        }1690        return true1691    }16921693    // ---- Internals --------------------------------------------------------16941695    private fun requireUnlocked() {1696        // H-03: read derivedKey atomically with [lock]'s reset; without it,1697        // [lock] from a background lifecycle event can run between this read1698        // and the caller's first state touch.1699        stateLock.lock()1700        try {1701            if (derivedKey == null) throw IllegalStateException("vault_locked")1702        } finally {1703            stateLock.unlock()1704        }1705    }17061707    private fun countDuplicateEntriesLocked(): Int {1708        val seen = mutableMapOf<String, VaultEntry>()1709        var duplicates = 01710        for (entry in entryList.filter { it.deletedAt == null }) {1711            val key = entry.duplicateKey()1712            val existing = seen[key]1713            if (existing == null) {1714                seen[key] = entry1715            } else {1716                duplicates++1717                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1718                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1719                if (entryStamp > existingStamp) seen[key] = entry1720            }1721        }1722        return duplicates1723    }17241725    private fun VaultEntry.duplicateKey(): String {1726        if (type == "note") return "note|${title.lowercase()}"1727        val passkeyData = passkey1728        if (passkeyData != null) {1729            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1730        }1731        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1732    }17331734    /**1735     * Capture the current slot pair (and metadata) into the history list.1736     * Returns true when the history write landed, false when it didn't — callers1737     * that depend on the snapshot existing (notably [restoreVaultSnapshot])1738     * must check the result and roll back if it does not.1739     */1740    private fun takeVaultSnapshot(reason: String): Boolean {1741        val slotA = store.loadSlotA() ?: return false1742        val vaultA = VaultCodec.encodeSlot(slotA)1743        val vaultB = store.loadSlotB() ?: slotA.blob1744        val history = store.loadVaultHistory()1745        val latest = history.snapshots.firstOrNull()1746        if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true1747        val entryCount = if (derivedKey != null && !decoyMode) {1748            bucketEntryCount(entryList.count { it.deletedAt == null })1749        } else {1750            null1751        }1752        return try {1753            val snapshot = VaultSnapshot(1754                id = UUID.randomUUID().toString(),1755                savedAt = System.currentTimeMillis(),1756                reason = reason,1757                vault_a = vaultA,1758                vault_b = vaultB,1759                entryCount = entryCount,1760            )1761            // P1-4 (codex): return the history-write boolean to callers so a1762            // false return no longer leaves the snapshot out of history while1763            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.1764            store.writeVaultHistory(1765                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1766            )1767        } catch (_: Exception) {1768            false1769        }1770    }17711772    private fun bucketEntryCount(n: Int): Int = when {1773        n <= 0 -> 01774        n <= 10 -> 101775        n <= 25 -> 251776        n <= 50 -> 501777        n <= 100 -> 1001778        n <= 250 -> 2501779        n <= 500 -> 5001780        else -> ((n + 499) / 500) * 5001781    }17821783    /**1784     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1785     * so it is stored explicitly and survives the sync wire format rather than being1786     * re-derived at render time. Non-card entries pass through unchanged.1787     *1788     * M-03: never overwrite a stored last4 with null. A card kept only as last41789     * (e.g. after a PeachScript import that had no PAN) must keep that value;1790     * re-deriving from an absent cardNumber would silently zero the field.1791     */1792    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1793        if (!entry.isCard()) return entry1794        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1795        val derived = if (digits.length >= 4) digits.takeLast(4) else null1796        return entry.copy(last4 = derived ?: entry.last4)1797    }17981799    /**1800     * On unlock, hard-delete trashed entries older than the retention window.1801     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1802     * (the app's "Never" option), so nothing is purged. Persists without a1803     * version bump and without touching the publish state (caller publishes).1804     */1805    private fun autoPurgeExpiredTrash() {1806        val days = _settings.value.trashRetentionDays ?: return1807        if (days <= 0) return1808        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1809        val before = entryList.size1810        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1811        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1812    }18131814    /** The slot-A plaintext envelope for the current in-memory session. */1815    private fun currentPayload(): VaultPayload =1816        VaultPayload(1817            entries = entryList.toList(),1818            duressConfigured = duressConfigured,1819            rootKeyB64 = rootKey?.let(::b64) ?: "",1820            recoveryWordHash = recoveryWordHash,1821            recoveryWordsSet = recoveryWordsSet,1822        )18231824    /**1825     * Adopt the payload's root key, lazily minting one for legacy payloads.1826     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1827     * a root key is not a mutation paired devices should sync against.1828     */1829    private fun ensureRootKey(payload: VaultPayload) {1830        if (payload.rootKeyB64.isNotBlank()) {1831            rootKey = unb64(payload.rootKeyB64)1832            return1833        }1834        rootKey = generateRecoverableRootKey()1835        runCatching { persistSlotA(bumpVersion = false) }1836    }18371838    private fun publish() {1839        _entries.value = entryList.filter { it.deletedAt == null }1840    }18411842    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1843        var plaintext = VaultCodec.encode(currentPayload())1844        return try {1845            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1846            StoredVault(version = 1, salt = b64(salt), blob = blob)1847        } finally {1848            // M-05: full vault plaintext is allocated here once per createVault.1849            // Zeroing in finally keeps the transient copy off the heap after the1850            // caller either succeeds or rolls back via lock().1851            VaultCrypto.zero(plaintext)1852        }1853    }18541855    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1856        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1857        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1858        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1859        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1860        val noise = ByteArray(approxBytes).also(rng::nextBytes)1861        return store.writeSlotB(b64(noise))1862    }18631864    /** Re-seal slot A from the current in-memory state under the live key. */1865    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1866        val key = derivedKey ?: throw IllegalStateException("vault_locked")1867        var plaintext: ByteArray? = null1868        return try {1869            // Decoy sessions persist to the decoy slot only — never touch the real slot A.1870            if (decoyMode) {1871                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1872                plaintext = VaultCodec.encode(currentPayload())1873                val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1874                plaintext = null1875                return store.writeDecoySlot(prior.copy(blob = blob))1876            }1877            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1878            // H-01: captured BEFORE the noise write so a stamp failure can restore1879            // the prior slot B alongside the prior slot A. Slot B is always1880            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure1881            // leaves (new A, new B, old version) on disk — partial commit.1882            val priorB = store.loadSlotB()1883            if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1884            plaintext = VaultCodec.encode(currentPayload())1885            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1886            plaintext = null1887            val next = prior.copy(blob = blob)1888            if (!store.writeSlotA(next)) return false1889            // Slot A committed — keep slot B size-matched, then advance the version1890            // for local writes only. Restore both A and B if a later step fails.1891            // P1-1 (codex): the noise write's boolean AND any thrown exception from1892            // it must trigger rollback; previously the call sat outside the try.1893            try {1894                if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")1895                if (bumpVersion) store.stampLocalMutation()1896            } catch (e: Exception) {1897                store.writeSlotA(prior)1898                if (priorB != null) runCatching { store.writeSlotB(priorB) }1899                throw e1900            }1901            true1902        } finally {1903            // M-05: the full vault plaintext crosses this helper on every save.1904            // Zeroing in finally covers both the success and the rollback/throw1905            // paths so a partial commit can't leave a copy in GC.1906            plaintext?.let(VaultCrypto::zero)1907        }1908    }19091910    /**1911     * H-01: durably commit a slot-A rotation by writing slot A, its1912     * size-matched noise slot B, and the local-mutation stamp in order. Any1913     * failure rolls the previously-committed steps back so the at-rest pair1914     * matches what was there before the call. Returns true only when every1915     * step succeeded — callers must NOT swap their session key on false.1916     */1917    private fun commitVaultRotation(1918        priorA: StoredVault,1919        priorB: String?,1920        nextA: StoredVault,1921    ): Boolean {1922        if (!store.writeSlotA(nextA)) return false1923        // P1-1 (codex): the noise write's boolean AND any thrown exception from1924        // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB1925        // escaped the helper and left slot A committed.1926        try {1927            if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")1928            store.stampLocalMutation()1929        } catch (_: Exception) {1930            rollbackRotation(priorA, priorB)1931            return false1932        }1933        return true1934    }19351936    /**1937     * Best-effort undo of the writes [commitVaultRotation] may have committed.1938     * Each step runs independently — a failed rollback must not mask the1939     * original commit failure. Log is a throwing stub off-device.1940     */1941    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {1942        val failures = mutableListOf<String>()1943        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }1944            .onFailure { failures += "writeSlotA: $it" }1945        if (priorB != null) {1946            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }1947                .onFailure { failures += "writeSlotB: $it" }1948        }1949        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }1950    }19511952    /** Persist a local mutation, rolling the in-memory list back on failure. */1953    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1954        try {1955            if (!persistSlotA(bumpVersion = true)) {1956                mutateState { entryList = rollback }1957                throw IllegalStateException("storage_write_failed")1958            }1959        } catch (e: Exception) {1960            mutateState { entryList = rollback }1961            throw e1962        }1963        mutateState { publish() }1964    }1965}