VaultRepository.kt

56.1 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            // The trailing `}` (the wrapper object's own close-brace, after the101            // captured inner slot object) must be escaped — Android's regex102            // compiler rejects a bare, unescaped `}` outside a quantifier103            // context as a syntax error, so this never matched anything and104            // Codex restore always failed with a generic "invalid_codex_blob"105            // regardless of whether the codes themselves decoded correctly.106            val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapperJson)?.groupValues?.get(1)107                ?: return UnlockResult.Error("invalid_codex_blob")108            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")109            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")110            writeMatchingNoiseSlotB(slotA)111            store.stampLocalMutation()112            unlockLocked(password)113        } catch (e: Exception) {114            UnlockResult.Error(e.message ?: "restore_failed")115        }116    }117118    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {119        return try {120            val salt = VaultCrypto.generateSalt()121            val key = VaultCrypto.deriveKey(password, salt)122            entryList = mutableListOf()123            duressConfigured = false124            recoveryWordHash = null125            recoveryWordsSet = false126            decoyMode = false127            rootKey = generateRecoverableRootKey()128            val slotA = sealSlotA(key, salt)129            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")130            writeMatchingNoiseSlotB(slotA)131            store.stampLocalMutation()132            store.writeSettings(_settings.value)133            derivedKey = key134            publish()135            _isUnlocked.value = true136            CreateVaultResult.Success137        } catch (e: Exception) {138            CreateVaultResult.Error(e.message ?: "create_failed")139        }140    }141142    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {143        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")144        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {145            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")146        }147        var adoptedRootKey: ByteArray? = null148        var nextDerivedKey: ByteArray? = null149        var sessionRootKey: ByteArray? = null150        return try {151            adoptedRootKey = hexToRootKey(rootKeyHex)152            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {153                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")154            }155            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)156            val salt = VaultCrypto.generateSalt()157            nextDerivedKey = VaultCrypto.deriveKey(password, salt)158            sessionRootKey = adoptedRootKey.copyOf()159            val payload = VaultPayload(160                entries = emptyList(),161                duressConfigured = false,162                rootKeyB64 = b64(sessionRootKey),163                recoveryWordHash = null,164                recoveryWordsSet = false,165            )166            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))167            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)168            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")169            writeMatchingNoiseSlotB(slotA)170            store.stampLocalMutation()171            store.writeSettings(_settings.value)172173            val previousDerivedKey = derivedKey174            val previousRootKey = rootKey175            derivedKey = nextDerivedKey176            rootKey = sessionRootKey177            nextDerivedKey = null178            sessionRootKey = null179            entryList = mutableListOf()180            duressConfigured = false181            recoveryWordHash = null182            recoveryWordsSet = false183            decoyMode = false184            publish()185            _isUnlocked.value = true186            previousDerivedKey?.let(VaultCrypto::zero)187            previousRootKey?.let(VaultCrypto::zero)188            AdoptVaultResult.Success(vaultId)189        } catch (e: Exception) {190            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")191        } finally {192            adoptedRootKey?.let(VaultCrypto::zero)193            nextDerivedKey?.let(VaultCrypto::zero)194            sessionRootKey?.let(VaultCrypto::zero)195        }196    }197198    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }199200    /** Unlock body without acquiring the mutex — callers must already hold it. */201    private fun unlockLocked(password: String): UnlockResult {202        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")203        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))204        val payload = try {205            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))206        } catch (_: Exception) {207            VaultCrypto.zero(key)208            // LV-1: a vault sealed before NFC normalization with a non-NFC209            // password derives a different key now. If the supplied password is210            // not already in NFC form, retry with the raw (un-normalized) bytes.211            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {212                val legacy = tryLegacyRawUnlock(password, slotA)213                if (legacy != null) return legacy214            }215            return UnlockResult.WrongPassword216        }217        return adoptSlotA(key, payload)218    }219220    /**221     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw222     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed223     * under the NFC-normalized key — so every future unlock takes the normal224     * path. Returns null when the raw key also fails (genuinely wrong password).225     */226    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {227        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))228        val payload = try {229            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))230        } catch (_: Exception) {231            VaultCrypto.zero(rawKey)232            return null233        }234        // Heal: re-seal slot A under the NFC key (mirrors changePassword's235        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.236        val newSalt = VaultCrypto.generateSalt()237        val newKey = VaultCrypto.deriveKey(password, newSalt)238        val healed = try {239            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))240            val next = StoredVault(241                version = slotA.version,242                salt = b64(newSalt),243                blob = blob,244                recoveryEscrow = slotA.recoveryEscrow,245                rootKeyEscrow = slotA.rootKeyEscrow,246            )247            if (store.writeSlotA(next)) {248                writeMatchingNoiseSlotB(next)249                store.stampLocalMutation()250                true251            } else {252                false253            }254        } catch (_: Exception) {255            false256        }257        return if (healed) {258            VaultCrypto.zero(rawKey)259            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot260        } else {261            VaultCrypto.zero(newKey)262            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key263        }264    }265266    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */267    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {268        return try {269            derivedKey = key270            decoyMode = false271            entryList = payload.entries.toMutableList()272            duressConfigured = payload.duressConfigured273            recoveryWordHash = payload.recoveryWordHash274            recoveryWordsSet = payload.recoveryWordsSet275            _settings.value = store.loadSettings()276            ensureRootKey(payload)277            autoPurgeExpiredTrash()278            publish()279            _isUnlocked.value = true280            UnlockResult.Success281        } catch (e: Exception) {282            UnlockResult.Error(e.message ?: "unlock_failed")283        }284    }285286    // ---- Constant-time unlock (MF-5) --------------------------------------287288    /**289     * A slot-A unlock that has been derived but not yet adopted. Holds the live290     * Argon2id key and decoded payload so the caller can derive several candidates291     * concurrently and commit only the winner. The key is owned by this token until292     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.293     */294    class SlotAUnlock internal constructor(295        internal val key: ByteArray,296        internal val payload: VaultPayload,297    )298299    /**300     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.301     * Pure computation (no mutex), so it can run concurrently with other candidate302     * derivations before the caller decides which to commit (MF-5). Returns null on303     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).304     */305    fun tryUnlockSlotA(password: String): SlotAUnlock? {306        val slotA = store.loadSlotA() ?: return null307        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))308        return try {309            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))310            SlotAUnlock(key, payload)311        } catch (_: Exception) {312            VaultCrypto.zero(key)313            null314        }315    }316317    /** Adopt a previously-derived [SlotAUnlock] as the live session. */318    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {319        adoptSlotA(unlock.key, unlock.payload)320    }321322    /** Zero a derived [SlotAUnlock] that will not be adopted. */323    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)324325    // ---- Duress slot B (MF-6) ---------------------------------------------326327    /**328     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key329     * and decoded decoy payload so it can be derived concurrently with slot A and330     * only committed if the duress passphrase was the one entered (MF-5/MF-6).331     */332    class DuressUnlock internal constructor(333        internal val key: ByteArray,334        internal val payload: VaultPayload,335    )336337    /**338     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the339     * session. Success means the entered password is the duress passphrase that keys340     * slot B — indistinguishable from the real slot A at the lock screen. Pure341     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].342     * Returns null when no duress slot exists or the password is not the duress one.343     */344    fun tryUnlockDuress(password: String): DuressUnlock? {345        val slot = store.loadDecoySlot() ?: return null346        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))347        return try {348            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))349            DuressUnlock(key, payload)350        } catch (_: Exception) {351            VaultCrypto.zero(key)352            null353        }354    }355356    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */357    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {358        return try {359            derivedKey = unlock.key360            decoyMode = true361            entryList = unlock.payload.entries.toMutableList()362            duressConfigured = false363            recoveryWordHash = unlock.payload.recoveryWordHash364            recoveryWordsSet = unlock.payload.recoveryWordsSet365            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)366                      else VaultCrypto.generateRootKey()367            _settings.value = store.loadSettings()368            publish()369            _isUnlocked.value = true370            UnlockResult.Success371        } catch (e: Exception) {372            UnlockResult.Error(e.message ?: "decoy_unlock_failed")373        }374    }375376    /** Zero a derived [DuressUnlock] that will not be adopted. */377    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)378379    /**380     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an381     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The382     * passphrase is never stored — only its ability to decrypt slot B proves it.383     * Requires the real vault to be unlocked and not itself a decoy session.384     */385    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {386        if (derivedKey == null || decoyMode) return false387        // Reject a duress passphrase that also opens the real vault: it would make the388        // lock screen ambiguous (both slots would decrypt for the same input).389        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }390        return try {391            val salt = VaultCrypto.generateSalt()392            val key = VaultCrypto.deriveKey(duressPassphrase, salt)393            // An empty decoy vault with its own fresh sync root key — nothing real leaks.394            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))395            val plaintext = VaultCodec.encode(decoyPayload)396            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))397            VaultCrypto.zero(key)398            VaultCrypto.zero(plaintext)399            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))400            if (ok) {401                duressConfigured = true402                runCatching { persistSlotA(bumpVersion = false) }403            }404            ok405        } catch (_: Exception) {406            false407        }408    }409410    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */411    suspend fun clearDuress(): Boolean = mutex.withLock {412        store.clearDecoySlot()413        if (derivedKey != null && !decoyMode) {414            duressConfigured = false415            runCatching { persistSlotA(bumpVersion = false) }416        }417        true418    }419420    /** Unlock with an already-derived AES-256 key (biometric path). */421    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {422        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")423        return try {424            val payload = VaultCodec.decode(425                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),426            )427            derivedKey = key.copyOf()428            decoyMode = false429            entryList = payload.entries.toMutableList()430            duressConfigured = payload.duressConfigured431            recoveryWordHash = payload.recoveryWordHash432            recoveryWordsSet = payload.recoveryWordsSet433            _settings.value = store.loadSettings()434            ensureRootKey(payload)435            autoPurgeExpiredTrash()436            publish()437            _isUnlocked.value = true438            UnlockResult.Success439        } catch (_: Exception) {440            UnlockResult.WrongPassword441        }442    }443444    fun lock() {445        derivedKey?.let(VaultCrypto::zero)446        rootKey?.let(VaultCrypto::zero)447        derivedKey = null448        rootKey = null449        entryList = mutableListOf()450        duressConfigured = false451        recoveryWordHash = null452        recoveryWordsSet = false453        decoyMode = false454        _entries.value = emptyList()455        _isUnlocked.value = false456    }457458    @Volatile private var suppressNextBackgroundLock = false459460    /**461     * Call immediately before launching a system Activity (a SAF file462     * picker/save dialog, a share sheet) that briefly takes the whole process463     * out of the foreground as an expected part of an in-app flow — not a464     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the465     * two apart on its own (it fires for both), and without this, exporting or466     * importing a file silently locked and wiped the vault mid-flow the moment467     * the picker took over, before its result ever reached the app.468     */469    fun expectTransientBackground() {470        suppressNextBackgroundLock = true471    }472473    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */474    fun consumeTransientBackgroundSuppression(): Boolean {475        val suppressed = suppressNextBackgroundLock476        suppressNextBackgroundLock = false477        return suppressed478    }479480    // ---- Sync identity ----------------------------------------------------481482    /**483     * The vault's stable sync master key (a copy). Caller must zero it. Backs484     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry485     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].486     */487    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()488489    /** Stable per-vault id two paired devices must share to sync. Null when locked. */490    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }491492    /**493     * Browser parity (MV-8): rotate the sync identity by replacing the stable494     * root key. Entries remain under the master-password key, while sync495     * credentials, vault_id, and recovery words all change.496     */497    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {498        val key = derivedKey ?: return false499        if (decoyMode) return false500        val priorSlot = store.loadSlotA() ?: return false501        val priorRootKey = rootKey ?: return false502        val priorRecoveryHash = recoveryWordHash503        val priorRecoverySet = recoveryWordsSet504        val nextRootKey = generateRecoverableRootKey()505        val nextWords = rootKeyToWords(nextRootKey)506507        try {508            takeVaultSnapshot("local_save")509            rootKey = nextRootKey510            recoveryWordHash = recoveryWordsHash(nextWords)511            recoveryWordsSet = false512513            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)514                ?: throw IllegalStateException("recovery_escrow_failed")515            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)516                ?: throw IllegalStateException("root_escrow_failed")517            val plaintext = VaultCodec.encode(currentPayload())518            val blob = try {519                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))520            } finally {521                VaultCrypto.zero(plaintext)522            }523            val nextSlot = priorSlot.copy(524                blob = blob,525                recoveryEscrow = nextRecoveryEscrow,526                rootKeyEscrow = nextRootKeyEscrow,527            )528            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")529            try {530                writeMatchingNoiseSlotB(nextSlot)531                store.stampLocalMutation()532            } catch (e: Exception) {533                store.writeSlotA(priorSlot)534                throw e535            }536            VaultCrypto.zero(priorRootKey)537            publish()538            true539        } catch (_: Exception) {540            rootKey = priorRootKey541            recoveryWordHash = priorRecoveryHash542            recoveryWordsSet = priorRecoverySet543            VaultCrypto.zero(nextRootKey)544            store.writeSlotA(priorSlot)545            false546        }547    }548549    /**550     * A copy of the live Argon2id-derived vault key, for biometric escrow only551     * (wrapped under a hardware KeyStore key on biometric enable). Null when552     * locked. Caller must [VaultCrypto.zero] it after use.553     */554    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()555556    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {557        val slotA = store.loadSlotA() ?: return false558        return try {559            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))560            val payload = VaultCodec.decode(561                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),562            )563            val newSalt = VaultCrypto.generateSalt()564            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)565            // Re-seal the existing payload under the new key/salt.566            val plaintext = VaultCodec.encode(payload)567            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))568            // The escrow seals the slot-A PLAINTEXT, which a password change does not569            // alter — only the wrapping changes — so carry it forward; rebuilding the570            // StoredVault from scratch would silently drop it.571            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key572            // content that a password change does not alter — only the slot-A573            // wrapping rotates. The root-key escrow staying stable across password574            // changes matches the browser (`vault.ts:363-364`).575            val next = StoredVault(576                version = slotA.version,577                salt = b64(newSalt),578                blob = blob,579                recoveryEscrow = slotA.recoveryEscrow,580                rootKeyEscrow = slotA.rootKeyEscrow,581            )582            if (!store.writeSlotA(next)) return false583            writeMatchingNoiseSlotB(next)584            store.stampLocalMutation()585            VaultCrypto.zero(oldKey)586            derivedKey?.let(VaultCrypto::zero)587            derivedKey = newKey588            true589        } catch (_: Exception) {590            false591        }592    }593594    /**595     * Irreversible wipe. Locks the session and clears every vault key. The Pro596     * license is in a separate store and is preserved by construction.597     */598    suspend fun destroyVault() = mutex.withLock {599        lock()600        store.clearVault()601        store.clearDecoySlot()602    }603604    // ---- Entry CRUD -------------------------------------------------------605606    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {607        requireUnlocked()608        val stamp = VaultCrypto.monotonicEntryTimestamp()609        val now = System.currentTimeMillis()610        val entry = withDerivedLast4(draft.copy(611            id = draft.id.ifBlank { UUID.randomUUID().toString() },612            createdAt = if (draft.createdAt > 0) draft.createdAt else now,613            modifiedAt = stamp,614            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,615        ))616        val rollback = entryList.toMutableList()617        entryList.add(entry)618        persistOrRollback(rollback)619        entry620    }621622    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {623        requireUnlocked()624        val idx = entryList.indexOfFirst { it.id == patch.id }625        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")626        val current = entryList[idx]627        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)628        // Only refresh passwordChangedAt when the password value actually changes.629        val passwordChanged = patch.password != null && patch.password != current.password630        // Capture the outgoing password (newest first) so the user can reveal/copy/631        // restore it later. History is computed authoritatively from `current`, not632        // the patch — updateEntry is a full-entry replace, so trusting patch's value633        // would let a stale form clobber the real history. Record the age the old634        // password carried, skip an empty prior, and skip an immediate exact dupe635        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.636        val priorPassword = current.password637        val shouldCapture = passwordChanged &&638            !priorPassword.isNullOrEmpty() &&639            current.passwordHistory?.firstOrNull()?.password != priorPassword640        val nextHistory = if (shouldCapture) {641            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +642                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)643        } else {644            current.passwordHistory645        }646        val updated = withDerivedLast4(patch.copy(647            createdAt = current.createdAt,648            modifiedAt = stamp,649            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,650            passwordHistory = nextHistory,651        ))652        val rollback = entryList.toMutableList()653        entryList[idx] = updated654        persistOrRollback(rollback)655        updated656    }657658    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */659    suspend fun deleteEntry(id: String) = mutex.withLock {660        requireUnlocked()661        val idx = entryList.indexOfFirst { it.id == id }662        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")663        val current = entryList[idx]664        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)665        val rollback = entryList.toMutableList()666        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)667        persistOrRollback(rollback)668    }669670    suspend fun restoreEntry(id: String) = mutex.withLock {671        requireUnlocked()672        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }673        if (idx == -1) throw IllegalStateException("deleted entry not found")674        val current = entryList[idx]675        val rollback = entryList.toMutableList()676        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))677        persistOrRollback(rollback)678    }679680    /** Permanent delete — no tombstone, no recovery. */681    suspend fun purgeEntry(id: String) = mutex.withLock {682        requireUnlocked()683        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }684        if (idx == -1) throw IllegalStateException("deleted entry not found")685        val rollback = entryList.toMutableList()686        entryList.removeAt(idx)687        persistOrRollback(rollback)688    }689690    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */691    suspend fun purgeDeletedEntries(): Int = mutex.withLock {692        requireUnlocked()693        val rollback = entryList.toMutableList()694        val before = entryList.size695        entryList.removeAll { it.deletedAt != null }696        val removed = before - entryList.size697        if (removed > 0) persistOrRollback(rollback)698        removed699    }700701    /** Browser parity: number of active entries that dedupe would move to trash. */702    suspend fun countDuplicateEntries(): Int = mutex.withLock {703        requireUnlocked()704        countDuplicateEntriesLocked()705    }706707    /**708     * Browser parity: soft-delete duplicate entries, keeping the most recently709     * modified member of each group. Refuse in decoy mode so the duress vault is710     * not reshaped into a forensically distinct state.711     */712    suspend fun deduplicateEntries(): Int = mutex.withLock {713        requireUnlocked()714        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")715        val active = entryList.filter { it.deletedAt == null }716        val keepIds = mutableSetOf<String>()717        active.groupBy { it.duplicateKey() }.values.forEach { group ->718            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id719        }720721        val rollback = entryList.toMutableList()722        var removed = 0723        entryList = entryList.map { entry ->724            if (entry.deletedAt == null && entry.id !in keepIds) {725                removed++726                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)727                entry.copy(deletedAt = stamp, modifiedAt = stamp)728            } else {729                entry730            }731        }.toMutableList()732        if (removed > 0) persistOrRollback(rollback)733        removed734    }735736    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }737    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }738    fun getDeletedEntries(): List<VaultEntry> =739        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }740741    /** Active entries, optionally narrowed to those carrying [tagFilter]. */742    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {743        val active = entryList.filter { it.deletedAt == null }744        if (tagFilter == null) return active745        val needle = tagFilter.trim().lowercase()746        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }747    }748749    /** Sorted, de-duplicated tags across all active entries. */750    fun getDistinctTags(): List<String> =751        entryList.asSequence()752            .filter { it.deletedAt == null }753            .flatMap { it.tags?.asSequence() ?: emptySequence() }754            .map { it.trim().lowercase() }755            .filter { it.isNotEmpty() }756            .distinct()757            .sorted()758            .toList()759760    /**761     * Distinct non-blank values for an identity field across active entries,762     * most-frequently-used first. Supports "username" and "email"; unknown field763     * names return an empty list. Values keep original casing (deduped764     * case-insensitively) so a suggestion matches what the user typed.765     */766    fun getDistinctFieldValues(field: String): List<String> {767        val select: (VaultEntry) -> String? = when (field) {768            "username" -> { e -> e.username }769            "email" -> { e -> e.email }770            else -> return emptyList()771        }772        return entryList.asSequence()773            .filter { it.deletedAt == null }774            .mapNotNull { select(it)?.trim() }775            .filter { it.isNotEmpty() }776            .groupingBy { it.lowercase() }777            .aggregate { _, acc: Pair<String, Int>?, value, _ ->778                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)779            }780            .values781            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })782            .map { it.first }783    }784785    fun getTombstones(): List<Tombstone> =786        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }787    fun getVaultVersion(): Long = store.version()788    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots789790    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {791        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false792        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false793        takeVaultSnapshot("restore")794        if (!store.writeSlotA(restoredA)) return false795        if (!store.writeSlotB(snapshot.vault_b)) return false796        store.stampLocalMutation()797        lock()798        true799    }800801    // ---- Sync apply (does NOT advance the version) ------------------------802803    /**804     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists805     * WITHOUT bumping the version counter, so a sync round cannot cascade into806     * another outbound sync. Returns the number of locally-applied changes.807     */808    suspend fun applyMergedRemoteEntries(809        remoteEntries: List<VaultEntry>,810        remoteTombstones: List<Tombstone>,811    ): Int = mutex.withLock {812        requireUnlocked()813        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }814        var applied = 0815        val byId = entryList.associateBy { it.id }.toMutableMap()816817        for (remote in remoteEntries) {818            val tomb = tombMap[remote.id]819            if (tomb != null && tomb >= remote.modifiedAt) continue820            val local = byId[remote.id]821            if (local == null) {822                entryList.add(remote); byId[remote.id] = remote; applied++823            } else if (remote.modifiedAt > local.modifiedAt) {824                val i = entryList.indexOfFirst { it.id == local.id }825                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }826            }827        }828829        for ((id, deletedAt) in tombMap) {830            val local = byId[id] ?: continue831            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue832            if (deletedAt < local.modifiedAt) continue833            val i = entryList.indexOfFirst { it.id == id }834            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }835        }836837        if (applied > 0) {838            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")839            publish()840        }841        applied842    }843844    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {845        _settings.value = settings846        store.writeSettings(settings)847    }848849    // ---- Recovery words (vault metadata) ----------------------------------850851    /** True once the user has confirmed their recovery words for this vault. */852    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet853854    /**855     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.856     * Persisted without bumping the version — recovery metadata is local-only and857     * is not an entry mutation paired devices should sync against.858     */859    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {860        requireUnlocked()861        recoveryWordHash = recoveryWordsHash(words)862        recoveryWordsSet = true863        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow864        // (cross-platform recovery of the sync root key) and Android's whole-payload865        // escrow (offline forgot-password restore of entries). Both derive their key866        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.867        runCatching {868            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))869        }.getOrDefault(false)870    }871872    /** Verify entered words against the stored recovery hash. */873    fun verifyRecoveryWords(words: List<String>): Boolean {874        val stored = recoveryWordHash ?: return false875        return stored == recoveryWordsHash(words)876    }877878    // ---- Recovery escrow (forgot-password) --------------------------------879880    /** True at rest if this vault has any recovery escrow the recovery flow can use. */881    fun hasRecoveryEscrow(): Boolean =882        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false883884    /**885     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of886     * the master password. On success the slot-A plaintext sealed in the escrow is887     * adopted as the live session (entries, rootKey, settings), but no Argon2id888     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a889     * new password and finish unlocking. Returns false when there is no escrow, the890     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).891     *892     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no893     * detached vault key to return here — slot A is wrapped under the rotating894     * Argon2id key — so this opens the escrowed payload directly.895     */896    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {897        val slotA = store.loadSlotA() ?: return false898        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false899900        // Prefer Android's whole-payload escrow: a full offline restore of entries,901        // root key, and settings. Present for Android-created vaults.902        slotA.recoveryEscrow?.let { escrow ->903            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let904            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))905            val keyBytes = recoveryKey.encoded906            try {907                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)908                val payload = VaultCodec.decode(plaintext)909                decoyMode = false910                entryList = payload.entries.toMutableList()911                duressConfigured = payload.duressConfigured912                recoveryWordHash = payload.recoveryWordHash913                recoveryWordsSet = payload.recoveryWordsSet914                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)915                          else generateRecoverableRootKey()916                _settings.value = store.loadSettings()917                VaultCrypto.zero(plaintext)918                return true919            } catch (_: Exception) {920                // Wrong words against this escrow — fall through to the root-key escrow.921            } finally {922                VaultCrypto.zero(material)923                VaultCrypto.zero(keyBytes)924            }925        }926927        // Browser-format vault (only the root-key escrow): recover the 32-byte root928        // key (sync identity) and reset the password; entries return via sync. This929        // is exactly the browser's recovery semantics (`messages.ts:312-347`).930        slotA.rootKeyEscrow?.let { escrow ->931            val rk = unsealRootKeyEscrow(words, escrow) ?: return false932            decoyMode = false933            entryList = mutableListOf()934            duressConfigured = false935            recoveryWordHash = recoveryWordsHash(words)936            recoveryWordsSet = true937            rootKey = rk938            _settings.value = store.loadSettings()939            return true940        }941        return false942    }943944    /**945     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:946     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same947     * words, mark the session fully unlocked, and publish. Returns false if called948     * without a prior successful recovery unlock (no in-memory root key).949     *950     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`951     * into one step, since there is no detached vault key here.952     */953    suspend fun resetPasswordWithRecoveryWords(954        words: List<String>,955        newPassword: String,956    ): Boolean = mutex.withLock {957        if (rootKey == null) return false // must have recovered first958        return try {959            val newSalt = VaultCrypto.generateSalt()960            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)961            val plaintext = VaultCodec.encode(currentPayload())962            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))963            val prior = store.loadSlotA()964            // Rebuild both escrows against the recovered session so their salts rotate965            // with the password reset; fall back to the prior escrows if rebuild fails966            // (e.g. a checksum-invalid phrase). The same 12 words keep working.967            val valid = words.size == 12 && findInvalidWords(words).isEmpty()968            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow969            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow970            val next = StoredVault(971                version = prior?.version ?: 1,972                salt = b64(newSalt),973                blob = blob,974                recoveryEscrow = freshRecovery,975                rootKeyEscrow = freshRootKey,976            )977            if (!store.writeSlotA(next)) return false978            writeMatchingNoiseSlotB(next)979            store.stampLocalMutation()980            derivedKey?.let(VaultCrypto::zero)981            derivedKey = newKey982            VaultCrypto.zero(plaintext)983            publish()984            _isUnlocked.value = true985            true986        } catch (_: Exception) {987            false988        }989    }990991    /**992     * Seal the current slot-A plaintext under a key derived from the 12 BIP39993     * recovery [words] (Android offline-restore escrow). IKM is the browser's994     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].995     * Returns null for a checksum-invalid phrase.996     */997    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {998        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null999        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1000        val salt = VaultCrypto.generateSalt()1001        val recoveryKey = deriveRecoveryKey(material, salt)1002        val keyBytes = recoveryKey.encoded1003        val plaintext = VaultCodec.encode(currentPayload())1004        return try {1005            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1006            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1007        } finally {1008            VaultCrypto.zero(material)1009            VaultCrypto.zero(keyBytes)1010            VaultCrypto.zero(plaintext)1011        }1012    }10131014    /**1015     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1016     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1017     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1018     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1019     * the same root key on the browser. Returns null with no in-memory root key or1020     * a checksum-invalid phrase.1021     */1022    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1023        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1024        val rk = rootKey ?: return null1025        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1026        val salt = VaultCrypto.generateSalt()1027        val recoveryKey = deriveRecoveryKey(material, salt)1028        val keyBytes = recoveryKey.encoded1029        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1030        return try {1031            // combined = nonce(12) || ciphertext || tag(16)1032            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1033            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1034            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1035            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1036            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1037            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1038        } finally {1039            VaultCrypto.zero(material)1040            VaultCrypto.zero(keyBytes)1041            VaultCrypto.zero(plaintext)1042        }1043    }10441045    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1046    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1047        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1048        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1049        val keyBytes = recoveryKey.encoded1050        return try {1051            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1052            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1053            hexToRootKey(String(pt, Charsets.US_ASCII))1054        } catch (_: Exception) {1055            null1056        } finally {1057            VaultCrypto.zero(material)1058            VaultCrypto.zero(keyBytes)1059        }1060    }10611062    /**1063     * Re-seal slot A from the current in-memory state under the live key, carrying1064     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1065     * in decoy mode (recovery is never offered for the decoy) and persists without1066     * a version bump, like the recovery-words metadata write it replaces.1067     */1068    private fun persistSlotAWithEscrows(1069        recoveryEscrow: RecoveryEscrow?,1070        rootKeyEscrow: RootKeyEscrow?,1071    ): Boolean {1072        val key = derivedKey ?: throw IllegalStateException("vault_locked")1073        if (decoyMode) return false1074        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1075        val plaintext = VaultCodec.encode(currentPayload())1076        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1077        val next = prior.copy(1078            blob = blob,1079            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1080            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1081        )1082        if (!store.writeSlotA(next)) return false1083        try {1084            writeMatchingNoiseSlotB(next)1085        } catch (e: Exception) {1086            store.writeSlotA(prior)1087            throw e1088        }1089        return true1090    }10911092    // ---- Internals --------------------------------------------------------10931094    private fun requireUnlocked() {1095        if (derivedKey == null) throw IllegalStateException("vault_locked")1096    }10971098    private fun countDuplicateEntriesLocked(): Int {1099        val seen = mutableMapOf<String, VaultEntry>()1100        var duplicates = 01101        for (entry in entryList.filter { it.deletedAt == null }) {1102            val key = entry.duplicateKey()1103            val existing = seen[key]1104            if (existing == null) {1105                seen[key] = entry1106            } else {1107                duplicates++1108                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1109                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1110                if (entryStamp > existingStamp) seen[key] = entry1111            }1112        }1113        return duplicates1114    }11151116    private fun VaultEntry.duplicateKey(): String {1117        if (type == "note") return "note|${title.lowercase()}"1118        val passkeyData = passkey1119        if (passkeyData != null) {1120            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1121        }1122        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1123    }11241125    private fun takeVaultSnapshot(reason: String) {1126        runCatching {1127            val slotA = store.loadSlotA() ?: return1128            val vaultA = VaultCodec.encodeSlot(slotA)1129            val vaultB = store.loadSlotB() ?: slotA.blob1130            val history = store.loadVaultHistory()1131            val latest = history.snapshots.firstOrNull()1132            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1133            val entryCount = if (derivedKey != null && !decoyMode) {1134                bucketEntryCount(entryList.count { it.deletedAt == null })1135            } else {1136                null1137            }1138            val snapshot = VaultSnapshot(1139                id = UUID.randomUUID().toString(),1140                savedAt = System.currentTimeMillis(),1141                reason = reason,1142                vault_a = vaultA,1143                vault_b = vaultB,1144                entryCount = entryCount,1145            )1146            store.writeVaultHistory(1147                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1148            )1149        }1150    }11511152    private fun bucketEntryCount(n: Int): Int = when {1153        n <= 0 -> 01154        n <= 10 -> 101155        n <= 25 -> 251156        n <= 50 -> 501157        n <= 100 -> 1001158        n <= 250 -> 2501159        n <= 500 -> 5001160        else -> ((n + 499) / 500) * 5001161    }11621163    /**1164     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1165     * so it is stored explicitly and survives the sync wire format rather than being1166     * re-derived at render time. Non-card entries pass through unchanged.1167     */1168    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1169        if (!entry.isCard()) return entry1170        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1171        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1172    }11731174    /**1175     * On unlock, hard-delete trashed entries older than the retention window.1176     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1177     * (the app's "Never" option), so nothing is purged. Persists without a1178     * version bump and without touching the publish state (caller publishes).1179     */1180    private fun autoPurgeExpiredTrash() {1181        val days = _settings.value.trashRetentionDays ?: return1182        if (days <= 0) return1183        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1184        val before = entryList.size1185        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1186        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1187    }11881189    /** The slot-A plaintext envelope for the current in-memory session. */1190    private fun currentPayload(): VaultPayload =1191        VaultPayload(1192            entries = entryList.toList(),1193            duressConfigured = duressConfigured,1194            rootKeyB64 = rootKey?.let(::b64) ?: "",1195            recoveryWordHash = recoveryWordHash,1196            recoveryWordsSet = recoveryWordsSet,1197        )11981199    /**1200     * Adopt the payload's root key, lazily minting one for legacy payloads.1201     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1202     * a root key is not a mutation paired devices should sync against.1203     */1204    private fun ensureRootKey(payload: VaultPayload) {1205        if (payload.rootKeyB64.isNotBlank()) {1206            rootKey = unb64(payload.rootKeyB64)1207            return1208        }1209        rootKey = generateRecoverableRootKey()1210        runCatching { persistSlotA(bumpVersion = false) }1211    }12121213    private fun publish() {1214        _entries.value = entryList.filter { it.deletedAt == null }1215    }12161217    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1218        val plaintext = VaultCodec.encode(currentPayload())1219        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1220        return StoredVault(version = 1, salt = b64(salt), blob = blob)1221    }12221223    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1224        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1225        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1226        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1227        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1228        val noise = ByteArray(approxBytes).also(rng::nextBytes)1229        store.writeSlotB(b64(noise))1230    }12311232    /** Re-seal slot A from the current in-memory state under the live key. */1233    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1234        val key = derivedKey ?: throw IllegalStateException("vault_locked")1235        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1236        if (decoyMode) {1237            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1238            val plaintext = VaultCodec.encode(currentPayload())1239            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1240            return store.writeDecoySlot(prior.copy(blob = blob))1241        }1242        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1243        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1244        val plaintext = VaultCodec.encode(currentPayload())1245        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1246        val next = prior.copy(blob = blob)1247        if (!store.writeSlotA(next)) return false1248        // Slot A committed — keep slot B size-matched, then advance the version1249        // for local writes only. Restore slot A if a later step fails.1250        try {1251            writeMatchingNoiseSlotB(next)1252            if (bumpVersion) store.stampLocalMutation()1253        } catch (e: Exception) {1254            store.writeSlotA(prior)1255            throw e1256        }1257        return true1258    }12591260    /** Persist a local mutation, rolling the in-memory list back on failure. */1261    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1262        try {1263            if (!persistSlotA(bumpVersion = true)) {1264                entryList = rollback1265                throw IllegalStateException("storage_write_failed")1266            }1267        } catch (e: Exception) {1268            entryList = rollback1269            throw e1270        }1271        publish()1272    }1273}