VaultRepository.kt

55.7 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 java.util.Base6411import java.util.UUID12import javax.inject.Inject13import javax.inject.Singleton1415private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)16private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)1718/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */19private const val PASSWORD_HISTORY_MAX = 5020private const val VAULT_HISTORY_MAX = 52122sealed interface UnlockResult {23    data object Success : UnlockResult24    data object WrongPassword : UnlockResult25    data class Error(val reason: String) : UnlockResult26}2728sealed interface CreateVaultResult {29    data object Success : CreateVaultResult30    data class Error(val reason: String) : CreateVaultResult31}3233sealed interface AdoptVaultResult {34    data class Success(val vaultId: String) : AdoptVaultResult35    data class Error(val reason: String) : AdoptVaultResult36}3738/**39 * In-memory vault session backed by [VaultStore]. Faithful port of the browser40 * `vault.ts` semantics that matter for correctness:41 *42 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched43 *    noise blob so a fresh install shows two similarly-shaped records.44 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a45 *    deletion always wins last-write-wins against an in-flight edit.46 *  - Local writes bump the version counter; sync-applied writes do NOT (so a47 *    sync round never cascades into another outbound sync).48 *  - add/update/delete roll back in-memory state if the disk write fails.49 *  - destroyVault never touches the Pro license (separate store).50 */51@Singleton52class VaultRepository @Inject constructor(53    private val store: VaultStorage,54) {55    private val mutex = Mutex()56    private val rng = SecureRandom()5758    private var derivedKey: ByteArray? = null59    private var rootKey: ByteArray? = null60    private var entryList: MutableList<VaultEntry> = mutableListOf()61    private var duressConfigured: Boolean = false62    private var recoveryWordHash: String? = null63    private var recoveryWordsSet: Boolean = false64    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */65    private var decoyMode: Boolean = false6667    private val _isUnlocked = MutableStateFlow(false)68    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6970    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())71    /** Active (non-deleted) entryList. */72    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()7374    private val _settings = MutableStateFlow(VaultSettings())75    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7677    fun hasVault(): Boolean = store.hasVault()7879    // ---- Codex (printed paper backup) -------------------------------------8081    /**82     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the83     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable84     * on any device that has the master password. Null when no vault exists.85     */86    fun codexVaultBlobBase64(): String? {87        val slotA = store.loadSlotA() ?: return null88        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""89        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))90    }9192    /**93     * Replace the local vault from a Codex blob, then unlock with [password].94     * Writes slot A (and a size-matched noise slot B) before unlocking so the95     * restored vault is at rest exactly as a freshly-created one would be.96     */97    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {98        return try {99            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)100            val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)101                ?: return UnlockResult.Error("invalid_codex_blob")102            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")103            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")104            writeMatchingNoiseSlotB(slotA)105            store.stampLocalMutation()106            unlockLocked(password)107        } catch (e: Exception) {108            UnlockResult.Error(e.message ?: "restore_failed")109        }110    }111112    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {113        return try {114            val salt = VaultCrypto.generateSalt()115            val key = VaultCrypto.deriveKey(password, salt)116            entryList = mutableListOf()117            duressConfigured = false118            recoveryWordHash = null119            recoveryWordsSet = false120            decoyMode = false121            rootKey = generateRecoverableRootKey()122            val slotA = sealSlotA(key, salt)123            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")124            writeMatchingNoiseSlotB(slotA)125            store.stampLocalMutation()126            store.writeSettings(_settings.value)127            derivedKey = key128            publish()129            _isUnlocked.value = true130            CreateVaultResult.Success131        } catch (e: Exception) {132            CreateVaultResult.Error(e.message ?: "create_failed")133        }134    }135136    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {137        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")138        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {139            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")140        }141        var adoptedRootKey: ByteArray? = null142        var nextDerivedKey: ByteArray? = null143        var sessionRootKey: ByteArray? = null144        return try {145            adoptedRootKey = hexToRootKey(rootKeyHex)146            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {147                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")148            }149            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)150            val salt = VaultCrypto.generateSalt()151            nextDerivedKey = VaultCrypto.deriveKey(password, salt)152            sessionRootKey = adoptedRootKey.copyOf()153            val payload = VaultPayload(154                entries = emptyList(),155                duressConfigured = false,156                rootKeyB64 = b64(sessionRootKey),157                recoveryWordHash = null,158                recoveryWordsSet = false,159            )160            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))161            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)162            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")163            writeMatchingNoiseSlotB(slotA)164            store.stampLocalMutation()165            store.writeSettings(_settings.value)166167            val previousDerivedKey = derivedKey168            val previousRootKey = rootKey169            derivedKey = nextDerivedKey170            rootKey = sessionRootKey171            nextDerivedKey = null172            sessionRootKey = null173            entryList = mutableListOf()174            duressConfigured = false175            recoveryWordHash = null176            recoveryWordsSet = false177            decoyMode = false178            publish()179            _isUnlocked.value = true180            previousDerivedKey?.let(VaultCrypto::zero)181            previousRootKey?.let(VaultCrypto::zero)182            AdoptVaultResult.Success(vaultId)183        } catch (e: Exception) {184            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")185        } finally {186            adoptedRootKey?.let(VaultCrypto::zero)187            nextDerivedKey?.let(VaultCrypto::zero)188            sessionRootKey?.let(VaultCrypto::zero)189        }190    }191192    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }193194    /** Unlock body without acquiring the mutex — callers must already hold it. */195    private fun unlockLocked(password: String): UnlockResult {196        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")197        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))198        val payload = try {199            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))200        } catch (_: Exception) {201            VaultCrypto.zero(key)202            // LV-1: a vault sealed before NFC normalization with a non-NFC203            // password derives a different key now. If the supplied password is204            // not already in NFC form, retry with the raw (un-normalized) bytes.205            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {206                val legacy = tryLegacyRawUnlock(password, slotA)207                if (legacy != null) return legacy208            }209            return UnlockResult.WrongPassword210        }211        return adoptSlotA(key, payload)212    }213214    /**215     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw216     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed217     * under the NFC-normalized key — so every future unlock takes the normal218     * path. Returns null when the raw key also fails (genuinely wrong password).219     */220    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {221        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))222        val payload = try {223            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))224        } catch (_: Exception) {225            VaultCrypto.zero(rawKey)226            return null227        }228        // Heal: re-seal slot A under the NFC key (mirrors changePassword's229        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.230        val newSalt = VaultCrypto.generateSalt()231        val newKey = VaultCrypto.deriveKey(password, newSalt)232        val healed = try {233            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))234            val next = StoredVault(235                version = slotA.version,236                salt = b64(newSalt),237                blob = blob,238                recoveryEscrow = slotA.recoveryEscrow,239                rootKeyEscrow = slotA.rootKeyEscrow,240            )241            if (store.writeSlotA(next)) {242                writeMatchingNoiseSlotB(next)243                store.stampLocalMutation()244                true245            } else {246                false247            }248        } catch (_: Exception) {249            false250        }251        return if (healed) {252            VaultCrypto.zero(rawKey)253            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot254        } else {255            VaultCrypto.zero(newKey)256            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key257        }258    }259260    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */261    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {262        return try {263            derivedKey = key264            decoyMode = false265            entryList = payload.entries.toMutableList()266            duressConfigured = payload.duressConfigured267            recoveryWordHash = payload.recoveryWordHash268            recoveryWordsSet = payload.recoveryWordsSet269            _settings.value = store.loadSettings()270            ensureRootKey(payload)271            autoPurgeExpiredTrash()272            publish()273            _isUnlocked.value = true274            UnlockResult.Success275        } catch (e: Exception) {276            UnlockResult.Error(e.message ?: "unlock_failed")277        }278    }279280    // ---- Constant-time unlock (MF-5) --------------------------------------281282    /**283     * A slot-A unlock that has been derived but not yet adopted. Holds the live284     * Argon2id key and decoded payload so the caller can derive several candidates285     * concurrently and commit only the winner. The key is owned by this token until286     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.287     */288    class SlotAUnlock internal constructor(289        internal val key: ByteArray,290        internal val payload: VaultPayload,291    )292293    /**294     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.295     * Pure computation (no mutex), so it can run concurrently with other candidate296     * derivations before the caller decides which to commit (MF-5). Returns null on297     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).298     */299    fun tryUnlockSlotA(password: String): SlotAUnlock? {300        val slotA = store.loadSlotA() ?: return null301        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))302        return try {303            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))304            SlotAUnlock(key, payload)305        } catch (_: Exception) {306            VaultCrypto.zero(key)307            null308        }309    }310311    /** Adopt a previously-derived [SlotAUnlock] as the live session. */312    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {313        adoptSlotA(unlock.key, unlock.payload)314    }315316    /** Zero a derived [SlotAUnlock] that will not be adopted. */317    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)318319    // ---- Duress slot B (MF-6) ---------------------------------------------320321    /**322     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key323     * and decoded decoy payload so it can be derived concurrently with slot A and324     * only committed if the duress passphrase was the one entered (MF-5/MF-6).325     */326    class DuressUnlock internal constructor(327        internal val key: ByteArray,328        internal val payload: VaultPayload,329    )330331    /**332     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the333     * session. Success means the entered password is the duress passphrase that keys334     * slot B — indistinguishable from the real slot A at the lock screen. Pure335     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].336     * Returns null when no duress slot exists or the password is not the duress one.337     */338    fun tryUnlockDuress(password: String): DuressUnlock? {339        val slot = store.loadDecoySlot() ?: return null340        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))341        return try {342            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))343            DuressUnlock(key, payload)344        } catch (_: Exception) {345            VaultCrypto.zero(key)346            null347        }348    }349350    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */351    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {352        return try {353            derivedKey = unlock.key354            decoyMode = true355            entryList = unlock.payload.entries.toMutableList()356            duressConfigured = false357            recoveryWordHash = unlock.payload.recoveryWordHash358            recoveryWordsSet = unlock.payload.recoveryWordsSet359            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)360                      else VaultCrypto.generateRootKey()361            _settings.value = store.loadSettings()362            publish()363            _isUnlocked.value = true364            UnlockResult.Success365        } catch (e: Exception) {366            UnlockResult.Error(e.message ?: "decoy_unlock_failed")367        }368    }369370    /** Zero a derived [DuressUnlock] that will not be adopted. */371    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)372373    /**374     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an375     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The376     * passphrase is never stored — only its ability to decrypt slot B proves it.377     * Requires the real vault to be unlocked and not itself a decoy session.378     */379    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {380        if (derivedKey == null || decoyMode) return false381        // Reject a duress passphrase that also opens the real vault: it would make the382        // lock screen ambiguous (both slots would decrypt for the same input).383        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }384        return try {385            val salt = VaultCrypto.generateSalt()386            val key = VaultCrypto.deriveKey(duressPassphrase, salt)387            // An empty decoy vault with its own fresh sync root key — nothing real leaks.388            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))389            val plaintext = VaultCodec.encode(decoyPayload)390            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))391            VaultCrypto.zero(key)392            VaultCrypto.zero(plaintext)393            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))394            if (ok) {395                duressConfigured = true396                runCatching { persistSlotA(bumpVersion = false) }397            }398            ok399        } catch (_: Exception) {400            false401        }402    }403404    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */405    suspend fun clearDuress(): Boolean = mutex.withLock {406        store.clearDecoySlot()407        if (derivedKey != null && !decoyMode) {408            duressConfigured = false409            runCatching { persistSlotA(bumpVersion = false) }410        }411        true412    }413414    /** Unlock with an already-derived AES-256 key (biometric path). */415    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {416        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")417        return try {418            val payload = VaultCodec.decode(419                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),420            )421            derivedKey = key.copyOf()422            decoyMode = false423            entryList = payload.entries.toMutableList()424            duressConfigured = payload.duressConfigured425            recoveryWordHash = payload.recoveryWordHash426            recoveryWordsSet = payload.recoveryWordsSet427            _settings.value = store.loadSettings()428            ensureRootKey(payload)429            autoPurgeExpiredTrash()430            publish()431            _isUnlocked.value = true432            UnlockResult.Success433        } catch (_: Exception) {434            UnlockResult.WrongPassword435        }436    }437438    fun lock() {439        derivedKey?.let(VaultCrypto::zero)440        rootKey?.let(VaultCrypto::zero)441        derivedKey = null442        rootKey = null443        entryList = mutableListOf()444        duressConfigured = false445        recoveryWordHash = null446        recoveryWordsSet = false447        decoyMode = false448        _entries.value = emptyList()449        _isUnlocked.value = false450    }451452    @Volatile private var suppressNextBackgroundLock = false453454    /**455     * Call immediately before launching a system Activity (a SAF file456     * picker/save dialog, a share sheet) that briefly takes the whole process457     * out of the foreground as an expected part of an in-app flow — not a458     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the459     * two apart on its own (it fires for both), and without this, exporting or460     * importing a file silently locked and wiped the vault mid-flow the moment461     * the picker took over, before its result ever reached the app.462     */463    fun expectTransientBackground() {464        suppressNextBackgroundLock = true465    }466467    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */468    fun consumeTransientBackgroundSuppression(): Boolean {469        val suppressed = suppressNextBackgroundLock470        suppressNextBackgroundLock = false471        return suppressed472    }473474    // ---- Sync identity ----------------------------------------------------475476    /**477     * The vault's stable sync master key (a copy). Caller must zero it. Backs478     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry479     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].480     */481    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()482483    /** Stable per-vault id two paired devices must share to sync. Null when locked. */484    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }485486    /**487     * Browser parity (MV-8): rotate the sync identity by replacing the stable488     * root key. Entries remain under the master-password key, while sync489     * credentials, vault_id, and recovery words all change.490     */491    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {492        val key = derivedKey ?: return false493        if (decoyMode) return false494        val priorSlot = store.loadSlotA() ?: return false495        val priorRootKey = rootKey ?: return false496        val priorRecoveryHash = recoveryWordHash497        val priorRecoverySet = recoveryWordsSet498        val nextRootKey = generateRecoverableRootKey()499        val nextWords = rootKeyToWords(nextRootKey)500501        try {502            takeVaultSnapshot("local_save")503            rootKey = nextRootKey504            recoveryWordHash = recoveryWordsHash(nextWords)505            recoveryWordsSet = false506507            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)508                ?: throw IllegalStateException("recovery_escrow_failed")509            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)510                ?: throw IllegalStateException("root_escrow_failed")511            val plaintext = VaultCodec.encode(currentPayload())512            val blob = try {513                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))514            } finally {515                VaultCrypto.zero(plaintext)516            }517            val nextSlot = priorSlot.copy(518                blob = blob,519                recoveryEscrow = nextRecoveryEscrow,520                rootKeyEscrow = nextRootKeyEscrow,521            )522            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")523            try {524                writeMatchingNoiseSlotB(nextSlot)525                store.stampLocalMutation()526            } catch (e: Exception) {527                store.writeSlotA(priorSlot)528                throw e529            }530            VaultCrypto.zero(priorRootKey)531            publish()532            true533        } catch (_: Exception) {534            rootKey = priorRootKey535            recoveryWordHash = priorRecoveryHash536            recoveryWordsSet = priorRecoverySet537            VaultCrypto.zero(nextRootKey)538            store.writeSlotA(priorSlot)539            false540        }541    }542543    /**544     * A copy of the live Argon2id-derived vault key, for biometric escrow only545     * (wrapped under a hardware KeyStore key on biometric enable). Null when546     * locked. Caller must [VaultCrypto.zero] it after use.547     */548    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()549550    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {551        val slotA = store.loadSlotA() ?: return false552        return try {553            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))554            val payload = VaultCodec.decode(555                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),556            )557            val newSalt = VaultCrypto.generateSalt()558            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)559            // Re-seal the existing payload under the new key/salt.560            val plaintext = VaultCodec.encode(payload)561            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))562            // The escrow seals the slot-A PLAINTEXT, which a password change does not563            // alter — only the wrapping changes — so carry it forward; rebuilding the564            // StoredVault from scratch would silently drop it.565            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key566            // content that a password change does not alter — only the slot-A567            // wrapping rotates. The root-key escrow staying stable across password568            // changes matches the browser (`vault.ts:363-364`).569            val next = StoredVault(570                version = slotA.version,571                salt = b64(newSalt),572                blob = blob,573                recoveryEscrow = slotA.recoveryEscrow,574                rootKeyEscrow = slotA.rootKeyEscrow,575            )576            if (!store.writeSlotA(next)) return false577            writeMatchingNoiseSlotB(next)578            store.stampLocalMutation()579            VaultCrypto.zero(oldKey)580            derivedKey?.let(VaultCrypto::zero)581            derivedKey = newKey582            true583        } catch (_: Exception) {584            false585        }586    }587588    /**589     * Irreversible wipe. Locks the session and clears every vault key. The Pro590     * license is in a separate store and is preserved by construction.591     */592    suspend fun destroyVault() = mutex.withLock {593        lock()594        store.clearVault()595        store.clearDecoySlot()596    }597598    // ---- Entry CRUD -------------------------------------------------------599600    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {601        requireUnlocked()602        val stamp = VaultCrypto.monotonicEntryTimestamp()603        val now = System.currentTimeMillis()604        val entry = withDerivedLast4(draft.copy(605            id = draft.id.ifBlank { UUID.randomUUID().toString() },606            createdAt = if (draft.createdAt > 0) draft.createdAt else now,607            modifiedAt = stamp,608            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,609        ))610        val rollback = entryList.toMutableList()611        entryList.add(entry)612        persistOrRollback(rollback)613        entry614    }615616    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {617        requireUnlocked()618        val idx = entryList.indexOfFirst { it.id == patch.id }619        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")620        val current = entryList[idx]621        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)622        // Only refresh passwordChangedAt when the password value actually changes.623        val passwordChanged = patch.password != null && patch.password != current.password624        // Capture the outgoing password (newest first) so the user can reveal/copy/625        // restore it later. History is computed authoritatively from `current`, not626        // the patch — updateEntry is a full-entry replace, so trusting patch's value627        // would let a stale form clobber the real history. Record the age the old628        // password carried, skip an empty prior, and skip an immediate exact dupe629        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.630        val priorPassword = current.password631        val shouldCapture = passwordChanged &&632            !priorPassword.isNullOrEmpty() &&633            current.passwordHistory?.firstOrNull()?.password != priorPassword634        val nextHistory = if (shouldCapture) {635            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +636                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)637        } else {638            current.passwordHistory639        }640        val updated = withDerivedLast4(patch.copy(641            createdAt = current.createdAt,642            modifiedAt = stamp,643            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,644            passwordHistory = nextHistory,645        ))646        val rollback = entryList.toMutableList()647        entryList[idx] = updated648        persistOrRollback(rollback)649        updated650    }651652    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */653    suspend fun deleteEntry(id: String) = mutex.withLock {654        requireUnlocked()655        val idx = entryList.indexOfFirst { it.id == id }656        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")657        val current = entryList[idx]658        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)659        val rollback = entryList.toMutableList()660        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)661        persistOrRollback(rollback)662    }663664    suspend fun restoreEntry(id: String) = mutex.withLock {665        requireUnlocked()666        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }667        if (idx == -1) throw IllegalStateException("deleted entry not found")668        val current = entryList[idx]669        val rollback = entryList.toMutableList()670        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))671        persistOrRollback(rollback)672    }673674    /** Permanent delete — no tombstone, no recovery. */675    suspend fun purgeEntry(id: String) = mutex.withLock {676        requireUnlocked()677        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }678        if (idx == -1) throw IllegalStateException("deleted entry not found")679        val rollback = entryList.toMutableList()680        entryList.removeAt(idx)681        persistOrRollback(rollback)682    }683684    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */685    suspend fun purgeDeletedEntries(): Int = mutex.withLock {686        requireUnlocked()687        val rollback = entryList.toMutableList()688        val before = entryList.size689        entryList.removeAll { it.deletedAt != null }690        val removed = before - entryList.size691        if (removed > 0) persistOrRollback(rollback)692        removed693    }694695    /** Browser parity: number of active entries that dedupe would move to trash. */696    suspend fun countDuplicateEntries(): Int = mutex.withLock {697        requireUnlocked()698        countDuplicateEntriesLocked()699    }700701    /**702     * Browser parity: soft-delete duplicate entries, keeping the most recently703     * modified member of each group. Refuse in decoy mode so the duress vault is704     * not reshaped into a forensically distinct state.705     */706    suspend fun deduplicateEntries(): Int = mutex.withLock {707        requireUnlocked()708        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")709        val active = entryList.filter { it.deletedAt == null }710        val keepIds = mutableSetOf<String>()711        active.groupBy { it.duplicateKey() }.values.forEach { group ->712            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id713        }714715        val rollback = entryList.toMutableList()716        var removed = 0717        entryList = entryList.map { entry ->718            if (entry.deletedAt == null && entry.id !in keepIds) {719                removed++720                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)721                entry.copy(deletedAt = stamp, modifiedAt = stamp)722            } else {723                entry724            }725        }.toMutableList()726        if (removed > 0) persistOrRollback(rollback)727        removed728    }729730    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }731    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }732    fun getDeletedEntries(): List<VaultEntry> =733        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }734735    /** Active entries, optionally narrowed to those carrying [tagFilter]. */736    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {737        val active = entryList.filter { it.deletedAt == null }738        if (tagFilter == null) return active739        val needle = tagFilter.trim().lowercase()740        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }741    }742743    /** Sorted, de-duplicated tags across all active entries. */744    fun getDistinctTags(): List<String> =745        entryList.asSequence()746            .filter { it.deletedAt == null }747            .flatMap { it.tags?.asSequence() ?: emptySequence() }748            .map { it.trim().lowercase() }749            .filter { it.isNotEmpty() }750            .distinct()751            .sorted()752            .toList()753754    /**755     * Distinct non-blank values for an identity field across active entries,756     * most-frequently-used first. Supports "username" and "email"; unknown field757     * names return an empty list. Values keep original casing (deduped758     * case-insensitively) so a suggestion matches what the user typed.759     */760    fun getDistinctFieldValues(field: String): List<String> {761        val select: (VaultEntry) -> String? = when (field) {762            "username" -> { e -> e.username }763            "email" -> { e -> e.email }764            else -> return emptyList()765        }766        return entryList.asSequence()767            .filter { it.deletedAt == null }768            .mapNotNull { select(it)?.trim() }769            .filter { it.isNotEmpty() }770            .groupingBy { it.lowercase() }771            .aggregate { _, acc: Pair<String, Int>?, value, _ ->772                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)773            }774            .values775            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })776            .map { it.first }777    }778779    fun getTombstones(): List<Tombstone> =780        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }781    fun getVaultVersion(): Long = store.version()782    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots783784    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {785        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false786        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false787        takeVaultSnapshot("restore")788        if (!store.writeSlotA(restoredA)) return false789        if (!store.writeSlotB(snapshot.vault_b)) return false790        store.stampLocalMutation()791        lock()792        true793    }794795    // ---- Sync apply (does NOT advance the version) ------------------------796797    /**798     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists799     * WITHOUT bumping the version counter, so a sync round cannot cascade into800     * another outbound sync. Returns the number of locally-applied changes.801     */802    suspend fun applyMergedRemoteEntries(803        remoteEntries: List<VaultEntry>,804        remoteTombstones: List<Tombstone>,805    ): Int = mutex.withLock {806        requireUnlocked()807        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }808        var applied = 0809        val byId = entryList.associateBy { it.id }.toMutableMap()810811        for (remote in remoteEntries) {812            val tomb = tombMap[remote.id]813            if (tomb != null && tomb >= remote.modifiedAt) continue814            val local = byId[remote.id]815            if (local == null) {816                entryList.add(remote); byId[remote.id] = remote; applied++817            } else if (remote.modifiedAt > local.modifiedAt) {818                val i = entryList.indexOfFirst { it.id == local.id }819                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }820            }821        }822823        for ((id, deletedAt) in tombMap) {824            val local = byId[id] ?: continue825            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue826            if (deletedAt < local.modifiedAt) continue827            val i = entryList.indexOfFirst { it.id == id }828            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }829        }830831        if (applied > 0) {832            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")833            publish()834        }835        applied836    }837838    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {839        _settings.value = settings840        store.writeSettings(settings)841    }842843    // ---- Recovery words (vault metadata) ----------------------------------844845    /** True once the user has confirmed their recovery words for this vault. */846    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet847848    /**849     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.850     * Persisted without bumping the version — recovery metadata is local-only and851     * is not an entry mutation paired devices should sync against.852     */853    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {854        requireUnlocked()855        recoveryWordHash = recoveryWordsHash(words)856        recoveryWordsSet = true857        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow858        // (cross-platform recovery of the sync root key) and Android's whole-payload859        // escrow (offline forgot-password restore of entries). Both derive their key860        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.861        runCatching {862            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))863        }.getOrDefault(false)864    }865866    /** Verify entered words against the stored recovery hash. */867    fun verifyRecoveryWords(words: List<String>): Boolean {868        val stored = recoveryWordHash ?: return false869        return stored == recoveryWordsHash(words)870    }871872    // ---- Recovery escrow (forgot-password) --------------------------------873874    /** True at rest if this vault has any recovery escrow the recovery flow can use. */875    fun hasRecoveryEscrow(): Boolean =876        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false877878    /**879     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of880     * the master password. On success the slot-A plaintext sealed in the escrow is881     * adopted as the live session (entries, rootKey, settings), but no Argon2id882     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a883     * new password and finish unlocking. Returns false when there is no escrow, the884     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).885     *886     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no887     * detached vault key to return here — slot A is wrapped under the rotating888     * Argon2id key — so this opens the escrowed payload directly.889     */890    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {891        val slotA = store.loadSlotA() ?: return false892        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false893894        // Prefer Android's whole-payload escrow: a full offline restore of entries,895        // root key, and settings. Present for Android-created vaults.896        slotA.recoveryEscrow?.let { escrow ->897            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let898            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))899            val keyBytes = recoveryKey.encoded900            try {901                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)902                val payload = VaultCodec.decode(plaintext)903                decoyMode = false904                entryList = payload.entries.toMutableList()905                duressConfigured = payload.duressConfigured906                recoveryWordHash = payload.recoveryWordHash907                recoveryWordsSet = payload.recoveryWordsSet908                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)909                          else generateRecoverableRootKey()910                _settings.value = store.loadSettings()911                VaultCrypto.zero(plaintext)912                return true913            } catch (_: Exception) {914                // Wrong words against this escrow — fall through to the root-key escrow.915            } finally {916                VaultCrypto.zero(material)917                VaultCrypto.zero(keyBytes)918            }919        }920921        // Browser-format vault (only the root-key escrow): recover the 32-byte root922        // key (sync identity) and reset the password; entries return via sync. This923        // is exactly the browser's recovery semantics (`messages.ts:312-347`).924        slotA.rootKeyEscrow?.let { escrow ->925            val rk = unsealRootKeyEscrow(words, escrow) ?: return false926            decoyMode = false927            entryList = mutableListOf()928            duressConfigured = false929            recoveryWordHash = recoveryWordsHash(words)930            recoveryWordsSet = true931            rootKey = rk932            _settings.value = store.loadSettings()933            return true934        }935        return false936    }937938    /**939     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:940     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same941     * words, mark the session fully unlocked, and publish. Returns false if called942     * without a prior successful recovery unlock (no in-memory root key).943     *944     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`945     * into one step, since there is no detached vault key here.946     */947    suspend fun resetPasswordWithRecoveryWords(948        words: List<String>,949        newPassword: String,950    ): Boolean = mutex.withLock {951        if (rootKey == null) return false // must have recovered first952        return try {953            val newSalt = VaultCrypto.generateSalt()954            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)955            val plaintext = VaultCodec.encode(currentPayload())956            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))957            val prior = store.loadSlotA()958            // Rebuild both escrows against the recovered session so their salts rotate959            // with the password reset; fall back to the prior escrows if rebuild fails960            // (e.g. a checksum-invalid phrase). The same 12 words keep working.961            val valid = words.size == 12 && findInvalidWords(words).isEmpty()962            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow963            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow964            val next = StoredVault(965                version = prior?.version ?: 1,966                salt = b64(newSalt),967                blob = blob,968                recoveryEscrow = freshRecovery,969                rootKeyEscrow = freshRootKey,970            )971            if (!store.writeSlotA(next)) return false972            writeMatchingNoiseSlotB(next)973            store.stampLocalMutation()974            derivedKey?.let(VaultCrypto::zero)975            derivedKey = newKey976            VaultCrypto.zero(plaintext)977            publish()978            _isUnlocked.value = true979            true980        } catch (_: Exception) {981            false982        }983    }984985    /**986     * Seal the current slot-A plaintext under a key derived from the 12 BIP39987     * recovery [words] (Android offline-restore escrow). IKM is the browser's988     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].989     * Returns null for a checksum-invalid phrase.990     */991    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {992        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null993        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null994        val salt = VaultCrypto.generateSalt()995        val recoveryKey = deriveRecoveryKey(material, salt)996        val keyBytes = recoveryKey.encoded997        val plaintext = VaultCodec.encode(currentPayload())998        return try {999            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1000            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1001        } finally {1002            VaultCrypto.zero(material)1003            VaultCrypto.zero(keyBytes)1004            VaultCrypto.zero(plaintext)1005        }1006    }10071008    /**1009     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1010     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1011     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1012     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1013     * the same root key on the browser. Returns null with no in-memory root key or1014     * a checksum-invalid phrase.1015     */1016    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1017        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1018        val rk = rootKey ?: return null1019        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1020        val salt = VaultCrypto.generateSalt()1021        val recoveryKey = deriveRecoveryKey(material, salt)1022        val keyBytes = recoveryKey.encoded1023        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1024        return try {1025            // combined = nonce(12) || ciphertext || tag(16)1026            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1027            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1028            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1029            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1030            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1031            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1032        } finally {1033            VaultCrypto.zero(material)1034            VaultCrypto.zero(keyBytes)1035            VaultCrypto.zero(plaintext)1036        }1037    }10381039    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1040    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1041        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1042        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1043        val keyBytes = recoveryKey.encoded1044        return try {1045            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1046            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1047            hexToRootKey(String(pt, Charsets.US_ASCII))1048        } catch (_: Exception) {1049            null1050        } finally {1051            VaultCrypto.zero(material)1052            VaultCrypto.zero(keyBytes)1053        }1054    }10551056    /**1057     * Re-seal slot A from the current in-memory state under the live key, carrying1058     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1059     * in decoy mode (recovery is never offered for the decoy) and persists without1060     * a version bump, like the recovery-words metadata write it replaces.1061     */1062    private fun persistSlotAWithEscrows(1063        recoveryEscrow: RecoveryEscrow?,1064        rootKeyEscrow: RootKeyEscrow?,1065    ): Boolean {1066        val key = derivedKey ?: throw IllegalStateException("vault_locked")1067        if (decoyMode) return false1068        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1069        val plaintext = VaultCodec.encode(currentPayload())1070        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1071        val next = prior.copy(1072            blob = blob,1073            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1074            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1075        )1076        if (!store.writeSlotA(next)) return false1077        try {1078            writeMatchingNoiseSlotB(next)1079        } catch (e: Exception) {1080            store.writeSlotA(prior)1081            throw e1082        }1083        return true1084    }10851086    // ---- Internals --------------------------------------------------------10871088    private fun requireUnlocked() {1089        if (derivedKey == null) throw IllegalStateException("vault_locked")1090    }10911092    private fun countDuplicateEntriesLocked(): Int {1093        val seen = mutableMapOf<String, VaultEntry>()1094        var duplicates = 01095        for (entry in entryList.filter { it.deletedAt == null }) {1096            val key = entry.duplicateKey()1097            val existing = seen[key]1098            if (existing == null) {1099                seen[key] = entry1100            } else {1101                duplicates++1102                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1103                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1104                if (entryStamp > existingStamp) seen[key] = entry1105            }1106        }1107        return duplicates1108    }11091110    private fun VaultEntry.duplicateKey(): String {1111        if (type == "note") return "note|${title.lowercase()}"1112        val passkeyData = passkey1113        if (passkeyData != null) {1114            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1115        }1116        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1117    }11181119    private fun takeVaultSnapshot(reason: String) {1120        runCatching {1121            val slotA = store.loadSlotA() ?: return1122            val vaultA = VaultCodec.encodeSlot(slotA)1123            val vaultB = store.loadSlotB() ?: slotA.blob1124            val history = store.loadVaultHistory()1125            val latest = history.snapshots.firstOrNull()1126            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1127            val entryCount = if (derivedKey != null && !decoyMode) {1128                bucketEntryCount(entryList.count { it.deletedAt == null })1129            } else {1130                null1131            }1132            val snapshot = VaultSnapshot(1133                id = UUID.randomUUID().toString(),1134                savedAt = System.currentTimeMillis(),1135                reason = reason,1136                vault_a = vaultA,1137                vault_b = vaultB,1138                entryCount = entryCount,1139            )1140            store.writeVaultHistory(1141                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1142            )1143        }1144    }11451146    private fun bucketEntryCount(n: Int): Int = when {1147        n <= 0 -> 01148        n <= 10 -> 101149        n <= 25 -> 251150        n <= 50 -> 501151        n <= 100 -> 1001152        n <= 250 -> 2501153        n <= 500 -> 5001154        else -> ((n + 499) / 500) * 5001155    }11561157    /**1158     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1159     * so it is stored explicitly and survives the sync wire format rather than being1160     * re-derived at render time. Non-card entries pass through unchanged.1161     */1162    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1163        if (!entry.isCard()) return entry1164        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1165        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1166    }11671168    /**1169     * On unlock, hard-delete trashed entries older than the retention window.1170     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1171     * (the app's "Never" option), so nothing is purged. Persists without a1172     * version bump and without touching the publish state (caller publishes).1173     */1174    private fun autoPurgeExpiredTrash() {1175        val days = _settings.value.trashRetentionDays ?: return1176        if (days <= 0) return1177        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1178        val before = entryList.size1179        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1180        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1181    }11821183    /** The slot-A plaintext envelope for the current in-memory session. */1184    private fun currentPayload(): VaultPayload =1185        VaultPayload(1186            entries = entryList.toList(),1187            duressConfigured = duressConfigured,1188            rootKeyB64 = rootKey?.let(::b64) ?: "",1189            recoveryWordHash = recoveryWordHash,1190            recoveryWordsSet = recoveryWordsSet,1191        )11921193    /**1194     * Adopt the payload's root key, lazily minting one for legacy payloads.1195     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1196     * a root key is not a mutation paired devices should sync against.1197     */1198    private fun ensureRootKey(payload: VaultPayload) {1199        if (payload.rootKeyB64.isNotBlank()) {1200            rootKey = unb64(payload.rootKeyB64)1201            return1202        }1203        rootKey = generateRecoverableRootKey()1204        runCatching { persistSlotA(bumpVersion = false) }1205    }12061207    private fun publish() {1208        _entries.value = entryList.filter { it.deletedAt == null }1209    }12101211    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1212        val plaintext = VaultCodec.encode(currentPayload())1213        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1214        return StoredVault(version = 1, salt = b64(salt), blob = blob)1215    }12161217    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1218        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1219        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1220        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1221        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1222        val noise = ByteArray(approxBytes).also(rng::nextBytes)1223        store.writeSlotB(b64(noise))1224    }12251226    /** Re-seal slot A from the current in-memory state under the live key. */1227    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1228        val key = derivedKey ?: throw IllegalStateException("vault_locked")1229        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1230        if (decoyMode) {1231            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1232            val plaintext = VaultCodec.encode(currentPayload())1233            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1234            return store.writeDecoySlot(prior.copy(blob = blob))1235        }1236        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1237        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1238        val plaintext = VaultCodec.encode(currentPayload())1239        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1240        val next = prior.copy(blob = blob)1241        if (!store.writeSlotA(next)) return false1242        // Slot A committed — keep slot B size-matched, then advance the version1243        // for local writes only. Restore slot A if a later step fails.1244        try {1245            writeMatchingNoiseSlotB(next)1246            if (bumpVersion) store.stampLocalMutation()1247        } catch (e: Exception) {1248            store.writeSlotA(prior)1249            throw e1250        }1251        return true1252    }12531254    /** Persist a local mutation, rolling the in-memory list back on failure. */1255    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1256        try {1257            if (!persistSlotA(bumpVersion = true)) {1258                entryList = rollback1259                throw IllegalStateException("storage_write_failed")1260            }1261        } catch (e: Exception) {1262            entryList = rollback1263            throw e1264        }1265        publish()1266    }1267}