VaultRepository.kt

52.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 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}3233/**34 * In-memory vault session backed by [VaultStore]. Faithful port of the browser35 * `vault.ts` semantics that matter for correctness:36 *37 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched38 *    noise blob so a fresh install shows two similarly-shaped records.39 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a40 *    deletion always wins last-write-wins against an in-flight edit.41 *  - Local writes bump the version counter; sync-applied writes do NOT (so a42 *    sync round never cascades into another outbound sync).43 *  - add/update/delete roll back in-memory state if the disk write fails.44 *  - destroyVault never touches the Pro license (separate store).45 */46@Singleton47class VaultRepository @Inject constructor(48    private val store: VaultStorage,49) {50    private val mutex = Mutex()51    private val rng = SecureRandom()5253    private var derivedKey: ByteArray? = null54    private var rootKey: ByteArray? = null55    private var entryList: MutableList<VaultEntry> = mutableListOf()56    private var duressConfigured: Boolean = false57    private var recoveryWordHash: String? = null58    private var recoveryWordsSet: Boolean = false59    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */60    private var decoyMode: Boolean = false6162    private val _isUnlocked = MutableStateFlow(false)63    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6465    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())66    /** Active (non-deleted) entryList. */67    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()6869    private val _settings = MutableStateFlow(VaultSettings())70    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7172    fun hasVault(): Boolean = store.hasVault()7374    // ---- Codex (printed paper backup) -------------------------------------7576    /**77     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the78     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable79     * on any device that has the master password. Null when no vault exists.80     */81    fun codexVaultBlobBase64(): String? {82        val slotA = store.loadSlotA() ?: return null83        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""84        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))85    }8687    /**88     * Replace the local vault from a Codex blob, then unlock with [password].89     * Writes slot A (and a size-matched noise slot B) before unlocking so the90     * restored vault is at rest exactly as a freshly-created one would be.91     */92    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {93        return try {94            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)95            val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)96                ?: return UnlockResult.Error("invalid_codex_blob")97            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")98            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")99            writeMatchingNoiseSlotB(slotA)100            store.stampLocalMutation()101            unlockLocked(password)102        } catch (e: Exception) {103            UnlockResult.Error(e.message ?: "restore_failed")104        }105    }106107    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {108        return try {109            val salt = VaultCrypto.generateSalt()110            val key = VaultCrypto.deriveKey(password, salt)111            entryList = mutableListOf()112            duressConfigured = false113            recoveryWordHash = null114            recoveryWordsSet = false115            decoyMode = false116            rootKey = generateRecoverableRootKey()117            val slotA = sealSlotA(key, salt)118            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")119            writeMatchingNoiseSlotB(slotA)120            store.stampLocalMutation()121            store.writeSettings(_settings.value)122            derivedKey = key123            publish()124            _isUnlocked.value = true125            CreateVaultResult.Success126        } catch (e: Exception) {127            CreateVaultResult.Error(e.message ?: "create_failed")128        }129    }130131    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }132133    /** Unlock body without acquiring the mutex — callers must already hold it. */134    private fun unlockLocked(password: String): UnlockResult {135        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")136        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))137        val payload = try {138            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))139        } catch (_: Exception) {140            VaultCrypto.zero(key)141            // LV-1: a vault sealed before NFC normalization with a non-NFC142            // password derives a different key now. If the supplied password is143            // not already in NFC form, retry with the raw (un-normalized) bytes.144            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {145                val legacy = tryLegacyRawUnlock(password, slotA)146                if (legacy != null) return legacy147            }148            return UnlockResult.WrongPassword149        }150        return adoptSlotA(key, payload)151    }152153    /**154     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw155     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed156     * under the NFC-normalized key — so every future unlock takes the normal157     * path. Returns null when the raw key also fails (genuinely wrong password).158     */159    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {160        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))161        val payload = try {162            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))163        } catch (_: Exception) {164            VaultCrypto.zero(rawKey)165            return null166        }167        // Heal: re-seal slot A under the NFC key (mirrors changePassword's168        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.169        val newSalt = VaultCrypto.generateSalt()170        val newKey = VaultCrypto.deriveKey(password, newSalt)171        val healed = try {172            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))173            val next = StoredVault(174                version = slotA.version,175                salt = b64(newSalt),176                blob = blob,177                recoveryEscrow = slotA.recoveryEscrow,178                rootKeyEscrow = slotA.rootKeyEscrow,179            )180            if (store.writeSlotA(next)) {181                writeMatchingNoiseSlotB(next)182                store.stampLocalMutation()183                true184            } else {185                false186            }187        } catch (_: Exception) {188            false189        }190        return if (healed) {191            VaultCrypto.zero(rawKey)192            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot193        } else {194            VaultCrypto.zero(newKey)195            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key196        }197    }198199    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */200    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {201        return try {202            derivedKey = key203            decoyMode = false204            entryList = payload.entries.toMutableList()205            duressConfigured = payload.duressConfigured206            recoveryWordHash = payload.recoveryWordHash207            recoveryWordsSet = payload.recoveryWordsSet208            _settings.value = store.loadSettings()209            ensureRootKey(payload)210            autoPurgeExpiredTrash()211            publish()212            _isUnlocked.value = true213            UnlockResult.Success214        } catch (e: Exception) {215            UnlockResult.Error(e.message ?: "unlock_failed")216        }217    }218219    // ---- Constant-time unlock (MF-5) --------------------------------------220221    /**222     * A slot-A unlock that has been derived but not yet adopted. Holds the live223     * Argon2id key and decoded payload so the caller can derive several candidates224     * concurrently and commit only the winner. The key is owned by this token until225     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.226     */227    class SlotAUnlock internal constructor(228        internal val key: ByteArray,229        internal val payload: VaultPayload,230    )231232    /**233     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.234     * Pure computation (no mutex), so it can run concurrently with other candidate235     * derivations before the caller decides which to commit (MF-5). Returns null on236     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).237     */238    fun tryUnlockSlotA(password: String): SlotAUnlock? {239        val slotA = store.loadSlotA() ?: return null240        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))241        return try {242            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))243            SlotAUnlock(key, payload)244        } catch (_: Exception) {245            VaultCrypto.zero(key)246            null247        }248    }249250    /** Adopt a previously-derived [SlotAUnlock] as the live session. */251    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {252        adoptSlotA(unlock.key, unlock.payload)253    }254255    /** Zero a derived [SlotAUnlock] that will not be adopted. */256    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)257258    // ---- Duress slot B (MF-6) ---------------------------------------------259260    /**261     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key262     * and decoded decoy payload so it can be derived concurrently with slot A and263     * only committed if the duress passphrase was the one entered (MF-5/MF-6).264     */265    class DuressUnlock internal constructor(266        internal val key: ByteArray,267        internal val payload: VaultPayload,268    )269270    /**271     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the272     * session. Success means the entered password is the duress passphrase that keys273     * slot B — indistinguishable from the real slot A at the lock screen. Pure274     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].275     * Returns null when no duress slot exists or the password is not the duress one.276     */277    fun tryUnlockDuress(password: String): DuressUnlock? {278        val slot = store.loadDecoySlot() ?: return null279        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))280        return try {281            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))282            DuressUnlock(key, payload)283        } catch (_: Exception) {284            VaultCrypto.zero(key)285            null286        }287    }288289    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */290    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {291        return try {292            derivedKey = unlock.key293            decoyMode = true294            entryList = unlock.payload.entries.toMutableList()295            duressConfigured = false296            recoveryWordHash = unlock.payload.recoveryWordHash297            recoveryWordsSet = unlock.payload.recoveryWordsSet298            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)299                      else VaultCrypto.generateRootKey()300            _settings.value = store.loadSettings()301            publish()302            _isUnlocked.value = true303            UnlockResult.Success304        } catch (e: Exception) {305            UnlockResult.Error(e.message ?: "decoy_unlock_failed")306        }307    }308309    /** Zero a derived [DuressUnlock] that will not be adopted. */310    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)311312    /**313     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an314     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The315     * passphrase is never stored — only its ability to decrypt slot B proves it.316     * Requires the real vault to be unlocked and not itself a decoy session.317     */318    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {319        if (derivedKey == null || decoyMode) return false320        // Reject a duress passphrase that also opens the real vault: it would make the321        // lock screen ambiguous (both slots would decrypt for the same input).322        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }323        return try {324            val salt = VaultCrypto.generateSalt()325            val key = VaultCrypto.deriveKey(duressPassphrase, salt)326            // An empty decoy vault with its own fresh sync root key — nothing real leaks.327            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))328            val plaintext = VaultCodec.encode(decoyPayload)329            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))330            VaultCrypto.zero(key)331            VaultCrypto.zero(plaintext)332            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))333            if (ok) {334                duressConfigured = true335                runCatching { persistSlotA(bumpVersion = false) }336            }337            ok338        } catch (_: Exception) {339            false340        }341    }342343    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */344    suspend fun clearDuress(): Boolean = mutex.withLock {345        store.clearDecoySlot()346        if (derivedKey != null && !decoyMode) {347            duressConfigured = false348            runCatching { persistSlotA(bumpVersion = false) }349        }350        true351    }352353    /** Unlock with an already-derived AES-256 key (biometric path). */354    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {355        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")356        return try {357            val payload = VaultCodec.decode(358                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),359            )360            derivedKey = key.copyOf()361            decoyMode = false362            entryList = payload.entries.toMutableList()363            duressConfigured = payload.duressConfigured364            recoveryWordHash = payload.recoveryWordHash365            recoveryWordsSet = payload.recoveryWordsSet366            _settings.value = store.loadSettings()367            ensureRootKey(payload)368            autoPurgeExpiredTrash()369            publish()370            _isUnlocked.value = true371            UnlockResult.Success372        } catch (_: Exception) {373            UnlockResult.WrongPassword374        }375    }376377    fun lock() {378        derivedKey?.let(VaultCrypto::zero)379        rootKey?.let(VaultCrypto::zero)380        derivedKey = null381        rootKey = null382        entryList = mutableListOf()383        duressConfigured = false384        recoveryWordHash = null385        recoveryWordsSet = false386        decoyMode = false387        _entries.value = emptyList()388        _isUnlocked.value = false389    }390391    // ---- Sync identity ----------------------------------------------------392393    /**394     * The vault's stable sync master key (a copy). Caller must zero it. Backs395     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry396     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].397     */398    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()399400    /** Stable per-vault id two paired devices must share to sync. Null when locked. */401    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }402403    /**404     * Browser parity (MV-8): rotate the sync identity by replacing the stable405     * root key. Entries remain under the master-password key, while sync406     * credentials, vault_id, and recovery words all change.407     */408    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {409        val key = derivedKey ?: return false410        if (decoyMode) return false411        val priorSlot = store.loadSlotA() ?: return false412        val priorRootKey = rootKey ?: return false413        val priorRecoveryHash = recoveryWordHash414        val priorRecoverySet = recoveryWordsSet415        val nextRootKey = generateRecoverableRootKey()416        val nextWords = rootKeyToWords(nextRootKey)417418        try {419            takeVaultSnapshot("local_save")420            rootKey = nextRootKey421            recoveryWordHash = recoveryWordsHash(nextWords)422            recoveryWordsSet = false423424            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)425                ?: throw IllegalStateException("recovery_escrow_failed")426            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)427                ?: throw IllegalStateException("root_escrow_failed")428            val plaintext = VaultCodec.encode(currentPayload())429            val blob = try {430                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))431            } finally {432                VaultCrypto.zero(plaintext)433            }434            val nextSlot = priorSlot.copy(435                blob = blob,436                recoveryEscrow = nextRecoveryEscrow,437                rootKeyEscrow = nextRootKeyEscrow,438            )439            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")440            try {441                writeMatchingNoiseSlotB(nextSlot)442                store.stampLocalMutation()443            } catch (e: Exception) {444                store.writeSlotA(priorSlot)445                throw e446            }447            VaultCrypto.zero(priorRootKey)448            publish()449            true450        } catch (_: Exception) {451            rootKey = priorRootKey452            recoveryWordHash = priorRecoveryHash453            recoveryWordsSet = priorRecoverySet454            VaultCrypto.zero(nextRootKey)455            store.writeSlotA(priorSlot)456            false457        }458    }459460    /**461     * A copy of the live Argon2id-derived vault key, for biometric escrow only462     * (wrapped under a hardware KeyStore key on biometric enable). Null when463     * locked. Caller must [VaultCrypto.zero] it after use.464     */465    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()466467    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {468        val slotA = store.loadSlotA() ?: return false469        return try {470            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))471            val payload = VaultCodec.decode(472                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),473            )474            val newSalt = VaultCrypto.generateSalt()475            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)476            // Re-seal the existing payload under the new key/salt.477            val plaintext = VaultCodec.encode(payload)478            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))479            // The escrow seals the slot-A PLAINTEXT, which a password change does not480            // alter — only the wrapping changes — so carry it forward; rebuilding the481            // StoredVault from scratch would silently drop it.482            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key483            // content that a password change does not alter — only the slot-A484            // wrapping rotates. The root-key escrow staying stable across password485            // changes matches the browser (`vault.ts:363-364`).486            val next = StoredVault(487                version = slotA.version,488                salt = b64(newSalt),489                blob = blob,490                recoveryEscrow = slotA.recoveryEscrow,491                rootKeyEscrow = slotA.rootKeyEscrow,492            )493            if (!store.writeSlotA(next)) return false494            writeMatchingNoiseSlotB(next)495            store.stampLocalMutation()496            VaultCrypto.zero(oldKey)497            derivedKey?.let(VaultCrypto::zero)498            derivedKey = newKey499            true500        } catch (_: Exception) {501            false502        }503    }504505    /**506     * Irreversible wipe. Locks the session and clears every vault key. The Pro507     * license is in a separate store and is preserved by construction.508     */509    suspend fun destroyVault() = mutex.withLock {510        lock()511        store.clearVault()512        store.clearDecoySlot()513    }514515    // ---- Entry CRUD -------------------------------------------------------516517    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {518        requireUnlocked()519        val stamp = VaultCrypto.monotonicEntryTimestamp()520        val now = System.currentTimeMillis()521        val entry = withDerivedLast4(draft.copy(522            id = draft.id.ifBlank { UUID.randomUUID().toString() },523            createdAt = if (draft.createdAt > 0) draft.createdAt else now,524            modifiedAt = stamp,525            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,526        ))527        val rollback = entryList.toMutableList()528        entryList.add(entry)529        persistOrRollback(rollback)530        entry531    }532533    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {534        requireUnlocked()535        val idx = entryList.indexOfFirst { it.id == patch.id }536        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")537        val current = entryList[idx]538        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)539        // Only refresh passwordChangedAt when the password value actually changes.540        val passwordChanged = patch.password != null && patch.password != current.password541        // Capture the outgoing password (newest first) so the user can reveal/copy/542        // restore it later. History is computed authoritatively from `current`, not543        // the patch — updateEntry is a full-entry replace, so trusting patch's value544        // would let a stale form clobber the real history. Record the age the old545        // password carried, skip an empty prior, and skip an immediate exact dupe546        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.547        val priorPassword = current.password548        val shouldCapture = passwordChanged &&549            !priorPassword.isNullOrEmpty() &&550            current.passwordHistory?.firstOrNull()?.password != priorPassword551        val nextHistory = if (shouldCapture) {552            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +553                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)554        } else {555            current.passwordHistory556        }557        val updated = withDerivedLast4(patch.copy(558            createdAt = current.createdAt,559            modifiedAt = stamp,560            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,561            passwordHistory = nextHistory,562        ))563        val rollback = entryList.toMutableList()564        entryList[idx] = updated565        persistOrRollback(rollback)566        updated567    }568569    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */570    suspend fun deleteEntry(id: String) = mutex.withLock {571        requireUnlocked()572        val idx = entryList.indexOfFirst { it.id == id }573        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")574        val current = entryList[idx]575        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)576        val rollback = entryList.toMutableList()577        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)578        persistOrRollback(rollback)579    }580581    suspend fun restoreEntry(id: String) = mutex.withLock {582        requireUnlocked()583        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }584        if (idx == -1) throw IllegalStateException("deleted entry not found")585        val current = entryList[idx]586        val rollback = entryList.toMutableList()587        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))588        persistOrRollback(rollback)589    }590591    /** Permanent delete — no tombstone, no recovery. */592    suspend fun purgeEntry(id: String) = mutex.withLock {593        requireUnlocked()594        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }595        if (idx == -1) throw IllegalStateException("deleted entry not found")596        val rollback = entryList.toMutableList()597        entryList.removeAt(idx)598        persistOrRollback(rollback)599    }600601    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */602    suspend fun purgeDeletedEntries(): Int = mutex.withLock {603        requireUnlocked()604        val rollback = entryList.toMutableList()605        val before = entryList.size606        entryList.removeAll { it.deletedAt != null }607        val removed = before - entryList.size608        if (removed > 0) persistOrRollback(rollback)609        removed610    }611612    /** Browser parity: number of active entries that dedupe would move to trash. */613    suspend fun countDuplicateEntries(): Int = mutex.withLock {614        requireUnlocked()615        countDuplicateEntriesLocked()616    }617618    /**619     * Browser parity: soft-delete duplicate entries, keeping the most recently620     * modified member of each group. Refuse in decoy mode so the duress vault is621     * not reshaped into a forensically distinct state.622     */623    suspend fun deduplicateEntries(): Int = mutex.withLock {624        requireUnlocked()625        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")626        val active = entryList.filter { it.deletedAt == null }627        val keepIds = mutableSetOf<String>()628        active.groupBy { it.duplicateKey() }.values.forEach { group ->629            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id630        }631632        val rollback = entryList.toMutableList()633        var removed = 0634        entryList = entryList.map { entry ->635            if (entry.deletedAt == null && entry.id !in keepIds) {636                removed++637                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)638                entry.copy(deletedAt = stamp, modifiedAt = stamp)639            } else {640                entry641            }642        }.toMutableList()643        if (removed > 0) persistOrRollback(rollback)644        removed645    }646647    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }648    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }649    fun getDeletedEntries(): List<VaultEntry> =650        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }651652    /** Active entries, optionally narrowed to those carrying [tagFilter]. */653    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {654        val active = entryList.filter { it.deletedAt == null }655        if (tagFilter == null) return active656        val needle = tagFilter.trim().lowercase()657        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }658    }659660    /** Sorted, de-duplicated tags across all active entries. */661    fun getDistinctTags(): List<String> =662        entryList.asSequence()663            .filter { it.deletedAt == null }664            .flatMap { it.tags?.asSequence() ?: emptySequence() }665            .map { it.trim().lowercase() }666            .filter { it.isNotEmpty() }667            .distinct()668            .sorted()669            .toList()670671    /**672     * Distinct non-blank values for an identity field across active entries,673     * most-frequently-used first. Supports "username" and "email"; unknown field674     * names return an empty list. Values keep original casing (deduped675     * case-insensitively) so a suggestion matches what the user typed.676     */677    fun getDistinctFieldValues(field: String): List<String> {678        val select: (VaultEntry) -> String? = when (field) {679            "username" -> { e -> e.username }680            "email" -> { e -> e.email }681            else -> return emptyList()682        }683        return entryList.asSequence()684            .filter { it.deletedAt == null }685            .mapNotNull { select(it)?.trim() }686            .filter { it.isNotEmpty() }687            .groupingBy { it.lowercase() }688            .aggregate { _, acc: Pair<String, Int>?, value, _ ->689                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)690            }691            .values692            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })693            .map { it.first }694    }695696    fun getTombstones(): List<Tombstone> =697        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }698    fun getVaultVersion(): Long = store.version()699    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots700701    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {702        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false703        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false704        takeVaultSnapshot("restore")705        if (!store.writeSlotA(restoredA)) return false706        if (!store.writeSlotB(snapshot.vault_b)) return false707        store.stampLocalMutation()708        lock()709        true710    }711712    // ---- Sync apply (does NOT advance the version) ------------------------713714    /**715     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists716     * WITHOUT bumping the version counter, so a sync round cannot cascade into717     * another outbound sync. Returns the number of locally-applied changes.718     */719    suspend fun applyMergedRemoteEntries(720        remoteEntries: List<VaultEntry>,721        remoteTombstones: List<Tombstone>,722    ): Int = mutex.withLock {723        requireUnlocked()724        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }725        var applied = 0726        val byId = entryList.associateBy { it.id }.toMutableMap()727728        for (remote in remoteEntries) {729            val tomb = tombMap[remote.id]730            if (tomb != null && tomb >= remote.modifiedAt) continue731            val local = byId[remote.id]732            if (local == null) {733                entryList.add(remote); byId[remote.id] = remote; applied++734            } else if (remote.modifiedAt > local.modifiedAt) {735                val i = entryList.indexOfFirst { it.id == local.id }736                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }737            }738        }739740        for ((id, deletedAt) in tombMap) {741            val local = byId[id] ?: continue742            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue743            if (deletedAt < local.modifiedAt) continue744            val i = entryList.indexOfFirst { it.id == id }745            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }746        }747748        if (applied > 0) {749            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")750            publish()751        }752        applied753    }754755    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {756        _settings.value = settings757        store.writeSettings(settings)758    }759760    // ---- Recovery words (vault metadata) ----------------------------------761762    /** True once the user has confirmed their recovery words for this vault. */763    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet764765    /**766     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.767     * Persisted without bumping the version — recovery metadata is local-only and768     * is not an entry mutation paired devices should sync against.769     */770    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {771        requireUnlocked()772        recoveryWordHash = recoveryWordsHash(words)773        recoveryWordsSet = true774        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow775        // (cross-platform recovery of the sync root key) and Android's whole-payload776        // escrow (offline forgot-password restore of entries). Both derive their key777        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.778        runCatching {779            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))780        }.getOrDefault(false)781    }782783    /** Verify entered words against the stored recovery hash. */784    fun verifyRecoveryWords(words: List<String>): Boolean {785        val stored = recoveryWordHash ?: return false786        return stored == recoveryWordsHash(words)787    }788789    // ---- Recovery escrow (forgot-password) --------------------------------790791    /** True at rest if this vault has any recovery escrow the recovery flow can use. */792    fun hasRecoveryEscrow(): Boolean =793        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false794795    /**796     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of797     * the master password. On success the slot-A plaintext sealed in the escrow is798     * adopted as the live session (entries, rootKey, settings), but no Argon2id799     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a800     * new password and finish unlocking. Returns false when there is no escrow, the801     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).802     *803     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no804     * detached vault key to return here — slot A is wrapped under the rotating805     * Argon2id key — so this opens the escrowed payload directly.806     */807    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {808        val slotA = store.loadSlotA() ?: return false809        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false810811        // Prefer Android's whole-payload escrow: a full offline restore of entries,812        // root key, and settings. Present for Android-created vaults.813        slotA.recoveryEscrow?.let { escrow ->814            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let815            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))816            val keyBytes = recoveryKey.encoded817            try {818                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)819                val payload = VaultCodec.decode(plaintext)820                decoyMode = false821                entryList = payload.entries.toMutableList()822                duressConfigured = payload.duressConfigured823                recoveryWordHash = payload.recoveryWordHash824                recoveryWordsSet = payload.recoveryWordsSet825                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)826                          else generateRecoverableRootKey()827                _settings.value = store.loadSettings()828                VaultCrypto.zero(plaintext)829                return true830            } catch (_: Exception) {831                // Wrong words against this escrow — fall through to the root-key escrow.832            } finally {833                VaultCrypto.zero(material)834                VaultCrypto.zero(keyBytes)835            }836        }837838        // Browser-format vault (only the root-key escrow): recover the 32-byte root839        // key (sync identity) and reset the password; entries return via sync. This840        // is exactly the browser's recovery semantics (`messages.ts:312-347`).841        slotA.rootKeyEscrow?.let { escrow ->842            val rk = unsealRootKeyEscrow(words, escrow) ?: return false843            decoyMode = false844            entryList = mutableListOf()845            duressConfigured = false846            recoveryWordHash = recoveryWordsHash(words)847            recoveryWordsSet = true848            rootKey = rk849            _settings.value = store.loadSettings()850            return true851        }852        return false853    }854855    /**856     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:857     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same858     * words, mark the session fully unlocked, and publish. Returns false if called859     * without a prior successful recovery unlock (no in-memory root key).860     *861     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`862     * into one step, since there is no detached vault key here.863     */864    suspend fun resetPasswordWithRecoveryWords(865        words: List<String>,866        newPassword: String,867    ): Boolean = mutex.withLock {868        if (rootKey == null) return false // must have recovered first869        return try {870            val newSalt = VaultCrypto.generateSalt()871            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)872            val plaintext = VaultCodec.encode(currentPayload())873            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))874            val prior = store.loadSlotA()875            // Rebuild both escrows against the recovered session so their salts rotate876            // with the password reset; fall back to the prior escrows if rebuild fails877            // (e.g. a checksum-invalid phrase). The same 12 words keep working.878            val valid = words.size == 12 && findInvalidWords(words).isEmpty()879            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow880            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow881            val next = StoredVault(882                version = prior?.version ?: 1,883                salt = b64(newSalt),884                blob = blob,885                recoveryEscrow = freshRecovery,886                rootKeyEscrow = freshRootKey,887            )888            if (!store.writeSlotA(next)) return false889            writeMatchingNoiseSlotB(next)890            store.stampLocalMutation()891            derivedKey?.let(VaultCrypto::zero)892            derivedKey = newKey893            VaultCrypto.zero(plaintext)894            publish()895            _isUnlocked.value = true896            true897        } catch (_: Exception) {898            false899        }900    }901902    /**903     * Seal the current slot-A plaintext under a key derived from the 12 BIP39904     * recovery [words] (Android offline-restore escrow). IKM is the browser's905     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].906     * Returns null for a checksum-invalid phrase.907     */908    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {909        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null910        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null911        val salt = VaultCrypto.generateSalt()912        val recoveryKey = deriveRecoveryKey(material, salt)913        val keyBytes = recoveryKey.encoded914        val plaintext = VaultCodec.encode(currentPayload())915        return try {916            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)917            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))918        } finally {919            VaultCrypto.zero(material)920            VaultCrypto.zero(keyBytes)921            VaultCrypto.zero(plaintext)922        }923    }924925    /**926     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,927     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex928     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,929     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap930     * the same root key on the browser. Returns null with no in-memory root key or931     * a checksum-invalid phrase.932     */933    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {934        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null935        val rk = rootKey ?: return null936        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null937        val salt = VaultCrypto.generateSalt()938        val recoveryKey = deriveRecoveryKey(material, salt)939        val keyBytes = recoveryKey.encoded940        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)941        return try {942            // combined = nonce(12) || ciphertext || tag(16)943            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)944            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)945            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)946            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)947            val ct = ctTag.copyOfRange(0, ctTag.size - 16)948            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))949        } finally {950            VaultCrypto.zero(material)951            VaultCrypto.zero(keyBytes)952            VaultCrypto.zero(plaintext)953        }954    }955956    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */957    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {958        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null959        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))960        val keyBytes = recoveryKey.encoded961        return try {962            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)963            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)964            hexToRootKey(String(pt, Charsets.US_ASCII))965        } catch (_: Exception) {966            null967        } finally {968            VaultCrypto.zero(material)969            VaultCrypto.zero(keyBytes)970        }971    }972973    /**974     * Re-seal slot A from the current in-memory state under the live key, carrying975     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs976     * in decoy mode (recovery is never offered for the decoy) and persists without977     * a version bump, like the recovery-words metadata write it replaces.978     */979    private fun persistSlotAWithEscrows(980        recoveryEscrow: RecoveryEscrow?,981        rootKeyEscrow: RootKeyEscrow?,982    ): Boolean {983        val key = derivedKey ?: throw IllegalStateException("vault_locked")984        if (decoyMode) return false985        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")986        val plaintext = VaultCodec.encode(currentPayload())987        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))988        val next = prior.copy(989            blob = blob,990            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,991            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,992        )993        if (!store.writeSlotA(next)) return false994        try {995            writeMatchingNoiseSlotB(next)996        } catch (e: Exception) {997            store.writeSlotA(prior)998            throw e999        }1000        return true1001    }10021003    // ---- Internals --------------------------------------------------------10041005    private fun requireUnlocked() {1006        if (derivedKey == null) throw IllegalStateException("vault_locked")1007    }10081009    private fun countDuplicateEntriesLocked(): Int {1010        val seen = mutableMapOf<String, VaultEntry>()1011        var duplicates = 01012        for (entry in entryList.filter { it.deletedAt == null }) {1013            val key = entry.duplicateKey()1014            val existing = seen[key]1015            if (existing == null) {1016                seen[key] = entry1017            } else {1018                duplicates++1019                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1020                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1021                if (entryStamp > existingStamp) seen[key] = entry1022            }1023        }1024        return duplicates1025    }10261027    private fun VaultEntry.duplicateKey(): String {1028        if (type == "note") return "note|${title.lowercase()}"1029        val passkeyData = passkey1030        if (passkeyData != null) {1031            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1032        }1033        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1034    }10351036    private fun takeVaultSnapshot(reason: String) {1037        runCatching {1038            val slotA = store.loadSlotA() ?: return1039            val vaultA = VaultCodec.encodeSlot(slotA)1040            val vaultB = store.loadSlotB() ?: slotA.blob1041            val history = store.loadVaultHistory()1042            val latest = history.snapshots.firstOrNull()1043            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1044            val entryCount = if (derivedKey != null && !decoyMode) {1045                bucketEntryCount(entryList.count { it.deletedAt == null })1046            } else {1047                null1048            }1049            val snapshot = VaultSnapshot(1050                id = UUID.randomUUID().toString(),1051                savedAt = System.currentTimeMillis(),1052                reason = reason,1053                vault_a = vaultA,1054                vault_b = vaultB,1055                entryCount = entryCount,1056            )1057            store.writeVaultHistory(1058                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1059            )1060        }1061    }10621063    private fun bucketEntryCount(n: Int): Int = when {1064        n <= 0 -> 01065        n <= 10 -> 101066        n <= 25 -> 251067        n <= 50 -> 501068        n <= 100 -> 1001069        n <= 250 -> 2501070        n <= 500 -> 5001071        else -> ((n + 499) / 500) * 5001072    }10731074    /**1075     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1076     * so it is stored explicitly and survives the sync wire format rather than being1077     * re-derived at render time. Non-card entries pass through unchanged.1078     */1079    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1080        if (!entry.isCard()) return entry1081        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1082        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1083    }10841085    /**1086     * On unlock, hard-delete trashed entries older than the retention window.1087     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1088     * (the app's "Never" option), so nothing is purged. Persists without a1089     * version bump and without touching the publish state (caller publishes).1090     */1091    private fun autoPurgeExpiredTrash() {1092        val days = _settings.value.trashRetentionDays ?: return1093        if (days <= 0) return1094        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1095        val before = entryList.size1096        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1097        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1098    }10991100    /** The slot-A plaintext envelope for the current in-memory session. */1101    private fun currentPayload(): VaultPayload =1102        VaultPayload(1103            entries = entryList.toList(),1104            duressConfigured = duressConfigured,1105            rootKeyB64 = rootKey?.let(::b64) ?: "",1106            recoveryWordHash = recoveryWordHash,1107            recoveryWordsSet = recoveryWordsSet,1108        )11091110    /**1111     * Adopt the payload's root key, lazily minting one for legacy payloads.1112     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1113     * a root key is not a mutation paired devices should sync against.1114     */1115    private fun ensureRootKey(payload: VaultPayload) {1116        if (payload.rootKeyB64.isNotBlank()) {1117            rootKey = unb64(payload.rootKeyB64)1118            return1119        }1120        rootKey = generateRecoverableRootKey()1121        runCatching { persistSlotA(bumpVersion = false) }1122    }11231124    private fun publish() {1125        _entries.value = entryList.filter { it.deletedAt == null }1126    }11271128    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1129        val plaintext = VaultCodec.encode(currentPayload())1130        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1131        return StoredVault(version = 1, salt = b64(salt), blob = blob)1132    }11331134    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1135        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1136        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1137        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1138        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1139        val noise = ByteArray(approxBytes).also(rng::nextBytes)1140        store.writeSlotB(b64(noise))1141    }11421143    /** Re-seal slot A from the current in-memory state under the live key. */1144    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1145        val key = derivedKey ?: throw IllegalStateException("vault_locked")1146        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1147        if (decoyMode) {1148            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1149            val plaintext = VaultCodec.encode(currentPayload())1150            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1151            return store.writeDecoySlot(prior.copy(blob = blob))1152        }1153        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1154        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1155        val plaintext = VaultCodec.encode(currentPayload())1156        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1157        val next = prior.copy(blob = blob)1158        if (!store.writeSlotA(next)) return false1159        // Slot A committed — keep slot B size-matched, then advance the version1160        // for local writes only. Restore slot A if a later step fails.1161        try {1162            writeMatchingNoiseSlotB(next)1163            if (bumpVersion) store.stampLocalMutation()1164        } catch (e: Exception) {1165            store.writeSlotA(prior)1166            throw e1167        }1168        return true1169    }11701171    /** Persist a local mutation, rolling the in-memory list back on failure. */1172    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1173        try {1174            if (!persistSlotA(bumpVersion = true)) {1175                entryList = rollback1176                throw IllegalStateException("storage_write_failed")1177            }1178        } catch (e: Exception) {1179            entryList = rollback1180            throw e1181        }1182        publish()1183    }1184}