VaultRepository.kt

47.7 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import java.util.Base6411import java.util.UUID12import javax.inject.Inject13import javax.inject.Singleton1415private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)16private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)1718/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */19private const val PASSWORD_HISTORY_MAX = 502021sealed interface UnlockResult {22    data object Success : UnlockResult23    data object WrongPassword : UnlockResult24    data class Error(val reason: String) : UnlockResult25}2627sealed interface CreateVaultResult {28    data object Success : CreateVaultResult29    data class Error(val reason: String) : CreateVaultResult30}3132/**33 * In-memory vault session backed by [VaultStore]. Faithful port of the browser34 * `vault.ts` semantics that matter for correctness:35 *36 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched37 *    noise blob so a fresh install shows two similarly-shaped records.38 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a39 *    deletion always wins last-write-wins against an in-flight edit.40 *  - Local writes bump the version counter; sync-applied writes do NOT (so a41 *    sync round never cascades into another outbound sync).42 *  - add/update/delete roll back in-memory state if the disk write fails.43 *  - destroyVault never touches the Pro license (separate store).44 */45@Singleton46class VaultRepository @Inject constructor(47    private val store: VaultStorage,48) {49    private val mutex = Mutex()50    private val rng = SecureRandom()5152    private var derivedKey: ByteArray? = null53    private var rootKey: ByteArray? = null54    private var entryList: MutableList<VaultEntry> = mutableListOf()55    private var duressConfigured: Boolean = false56    private var recoveryWordHash: String? = null57    private var recoveryWordsSet: Boolean = false58    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */59    private var decoyMode: Boolean = false6061    private val _isUnlocked = MutableStateFlow(false)62    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6364    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())65    /** Active (non-deleted) entryList. */66    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()6768    private val _settings = MutableStateFlow(VaultSettings())69    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7071    fun hasVault(): Boolean = store.hasVault()7273    // ---- Codex (printed paper backup) -------------------------------------7475    /**76     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the77     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable78     * on any device that has the master password. Null when no vault exists.79     */80    fun codexVaultBlobBase64(): String? {81        val slotA = store.loadSlotA() ?: return null82        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""83        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))84    }8586    /**87     * Replace the local vault from a Codex blob, then unlock with [password].88     * Writes slot A (and a size-matched noise slot B) before unlocking so the89     * restored vault is at rest exactly as a freshly-created one would be.90     */91    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {92        return try {93            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)94            val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)95                ?: return UnlockResult.Error("invalid_codex_blob")96            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")97            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")98            writeMatchingNoiseSlotB(slotA)99            store.stampLocalMutation()100            unlockLocked(password)101        } catch (e: Exception) {102            UnlockResult.Error(e.message ?: "restore_failed")103        }104    }105106    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {107        return try {108            val salt = VaultCrypto.generateSalt()109            val key = VaultCrypto.deriveKey(password, salt)110            entryList = mutableListOf()111            duressConfigured = false112            recoveryWordHash = null113            recoveryWordsSet = false114            decoyMode = false115            rootKey = generateRecoverableRootKey()116            val slotA = sealSlotA(key, salt)117            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")118            writeMatchingNoiseSlotB(slotA)119            store.stampLocalMutation()120            store.writeSettings(_settings.value)121            derivedKey = key122            publish()123            _isUnlocked.value = true124            CreateVaultResult.Success125        } catch (e: Exception) {126            CreateVaultResult.Error(e.message ?: "create_failed")127        }128    }129130    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }131132    /** Unlock body without acquiring the mutex — callers must already hold it. */133    private fun unlockLocked(password: String): UnlockResult {134        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")135        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))136        val payload = try {137            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))138        } catch (_: Exception) {139            VaultCrypto.zero(key)140            // LV-1: a vault sealed before NFC normalization with a non-NFC141            // password derives a different key now. If the supplied password is142            // not already in NFC form, retry with the raw (un-normalized) bytes.143            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {144                val legacy = tryLegacyRawUnlock(password, slotA)145                if (legacy != null) return legacy146            }147            return UnlockResult.WrongPassword148        }149        return adoptSlotA(key, payload)150    }151152    /**153     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw154     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed155     * under the NFC-normalized key — so every future unlock takes the normal156     * path. Returns null when the raw key also fails (genuinely wrong password).157     */158    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {159        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))160        val payload = try {161            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))162        } catch (_: Exception) {163            VaultCrypto.zero(rawKey)164            return null165        }166        // Heal: re-seal slot A under the NFC key (mirrors changePassword's167        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.168        val newSalt = VaultCrypto.generateSalt()169        val newKey = VaultCrypto.deriveKey(password, newSalt)170        val healed = try {171            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))172            val next = StoredVault(173                version = slotA.version,174                salt = b64(newSalt),175                blob = blob,176                recoveryEscrow = slotA.recoveryEscrow,177                rootKeyEscrow = slotA.rootKeyEscrow,178            )179            if (store.writeSlotA(next)) {180                writeMatchingNoiseSlotB(next)181                store.stampLocalMutation()182                true183            } else {184                false185            }186        } catch (_: Exception) {187            false188        }189        return if (healed) {190            VaultCrypto.zero(rawKey)191            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot192        } else {193            VaultCrypto.zero(newKey)194            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key195        }196    }197198    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */199    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {200        return try {201            derivedKey = key202            decoyMode = false203            entryList = payload.entries.toMutableList()204            duressConfigured = payload.duressConfigured205            recoveryWordHash = payload.recoveryWordHash206            recoveryWordsSet = payload.recoveryWordsSet207            _settings.value = store.loadSettings()208            ensureRootKey(payload)209            autoPurgeExpiredTrash()210            publish()211            _isUnlocked.value = true212            UnlockResult.Success213        } catch (e: Exception) {214            UnlockResult.Error(e.message ?: "unlock_failed")215        }216    }217218    // ---- Constant-time unlock (MF-5) --------------------------------------219220    /**221     * A slot-A unlock that has been derived but not yet adopted. Holds the live222     * Argon2id key and decoded payload so the caller can derive several candidates223     * concurrently and commit only the winner. The key is owned by this token until224     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.225     */226    class SlotAUnlock internal constructor(227        internal val key: ByteArray,228        internal val payload: VaultPayload,229    )230231    /**232     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.233     * Pure computation (no mutex), so it can run concurrently with other candidate234     * derivations before the caller decides which to commit (MF-5). Returns null on235     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).236     */237    fun tryUnlockSlotA(password: String): SlotAUnlock? {238        val slotA = store.loadSlotA() ?: return null239        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))240        return try {241            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))242            SlotAUnlock(key, payload)243        } catch (_: Exception) {244            VaultCrypto.zero(key)245            null246        }247    }248249    /** Adopt a previously-derived [SlotAUnlock] as the live session. */250    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {251        adoptSlotA(unlock.key, unlock.payload)252    }253254    /** Zero a derived [SlotAUnlock] that will not be adopted. */255    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)256257    // ---- Duress slot B (MF-6) ---------------------------------------------258259    /**260     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key261     * and decoded decoy payload so it can be derived concurrently with slot A and262     * only committed if the duress passphrase was the one entered (MF-5/MF-6).263     */264    class DuressUnlock internal constructor(265        internal val key: ByteArray,266        internal val payload: VaultPayload,267    )268269    /**270     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the271     * session. Success means the entered password is the duress passphrase that keys272     * slot B — indistinguishable from the real slot A at the lock screen. Pure273     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].274     * Returns null when no duress slot exists or the password is not the duress one.275     */276    fun tryUnlockDuress(password: String): DuressUnlock? {277        val slot = store.loadDecoySlot() ?: return null278        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))279        return try {280            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))281            DuressUnlock(key, payload)282        } catch (_: Exception) {283            VaultCrypto.zero(key)284            null285        }286    }287288    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */289    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {290        return try {291            derivedKey = unlock.key292            decoyMode = true293            entryList = unlock.payload.entries.toMutableList()294            duressConfigured = false295            recoveryWordHash = unlock.payload.recoveryWordHash296            recoveryWordsSet = unlock.payload.recoveryWordsSet297            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)298                      else VaultCrypto.generateRootKey()299            _settings.value = store.loadSettings()300            publish()301            _isUnlocked.value = true302            UnlockResult.Success303        } catch (e: Exception) {304            UnlockResult.Error(e.message ?: "decoy_unlock_failed")305        }306    }307308    /** Zero a derived [DuressUnlock] that will not be adopted. */309    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)310311    /**312     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an313     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The314     * passphrase is never stored — only its ability to decrypt slot B proves it.315     * Requires the real vault to be unlocked and not itself a decoy session.316     */317    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {318        if (derivedKey == null || decoyMode) return false319        // Reject a duress passphrase that also opens the real vault: it would make the320        // lock screen ambiguous (both slots would decrypt for the same input).321        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }322        return try {323            val salt = VaultCrypto.generateSalt()324            val key = VaultCrypto.deriveKey(duressPassphrase, salt)325            // An empty decoy vault with its own fresh sync root key — nothing real leaks.326            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))327            val plaintext = VaultCodec.encode(decoyPayload)328            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))329            VaultCrypto.zero(key)330            VaultCrypto.zero(plaintext)331            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))332            if (ok) {333                duressConfigured = true334                runCatching { persistSlotA(bumpVersion = false) }335            }336            ok337        } catch (_: Exception) {338            false339        }340    }341342    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */343    suspend fun clearDuress(): Boolean = mutex.withLock {344        store.clearDecoySlot()345        if (derivedKey != null && !decoyMode) {346            duressConfigured = false347            runCatching { persistSlotA(bumpVersion = false) }348        }349        true350    }351352    /** Unlock with an already-derived AES-256 key (biometric path). */353    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {354        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")355        return try {356            val payload = VaultCodec.decode(357                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),358            )359            derivedKey = key.copyOf()360            decoyMode = false361            entryList = payload.entries.toMutableList()362            duressConfigured = payload.duressConfigured363            recoveryWordHash = payload.recoveryWordHash364            recoveryWordsSet = payload.recoveryWordsSet365            _settings.value = store.loadSettings()366            ensureRootKey(payload)367            autoPurgeExpiredTrash()368            publish()369            _isUnlocked.value = true370            UnlockResult.Success371        } catch (_: Exception) {372            UnlockResult.WrongPassword373        }374    }375376    fun lock() {377        derivedKey?.let(VaultCrypto::zero)378        rootKey?.let(VaultCrypto::zero)379        derivedKey = null380        rootKey = null381        entryList = mutableListOf()382        duressConfigured = false383        recoveryWordHash = null384        recoveryWordsSet = false385        decoyMode = false386        _entries.value = emptyList()387        _isUnlocked.value = false388    }389390    // ---- Sync identity ----------------------------------------------------391392    /**393     * The vault's stable sync master key (a copy). Caller must zero it. Backs394     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry395     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].396     */397    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()398399    /** Stable per-vault id two paired devices must share to sync. Null when locked. */400    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }401402    /**403     * A copy of the live Argon2id-derived vault key, for biometric escrow only404     * (wrapped under a hardware KeyStore key on biometric enable). Null when405     * locked. Caller must [VaultCrypto.zero] it after use.406     */407    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()408409    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {410        val slotA = store.loadSlotA() ?: return false411        return try {412            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))413            val payload = VaultCodec.decode(414                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),415            )416            val newSalt = VaultCrypto.generateSalt()417            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)418            // Re-seal the existing payload under the new key/salt.419            val plaintext = VaultCodec.encode(payload)420            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))421            // The escrow seals the slot-A PLAINTEXT, which a password change does not422            // alter — only the wrapping changes — so carry it forward; rebuilding the423            // StoredVault from scratch would silently drop it.424            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key425            // content that a password change does not alter — only the slot-A426            // wrapping rotates. The root-key escrow staying stable across password427            // changes matches the browser (`vault.ts:363-364`).428            val next = StoredVault(429                version = slotA.version,430                salt = b64(newSalt),431                blob = blob,432                recoveryEscrow = slotA.recoveryEscrow,433                rootKeyEscrow = slotA.rootKeyEscrow,434            )435            if (!store.writeSlotA(next)) return false436            writeMatchingNoiseSlotB(next)437            store.stampLocalMutation()438            VaultCrypto.zero(oldKey)439            derivedKey?.let(VaultCrypto::zero)440            derivedKey = newKey441            true442        } catch (_: Exception) {443            false444        }445    }446447    /**448     * Irreversible wipe. Locks the session and clears every vault key. The Pro449     * license is in a separate store and is preserved by construction.450     */451    suspend fun destroyVault() = mutex.withLock {452        lock()453        store.clearVault()454        store.clearDecoySlot()455    }456457    // ---- Entry CRUD -------------------------------------------------------458459    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {460        requireUnlocked()461        val stamp = VaultCrypto.monotonicEntryTimestamp()462        val now = System.currentTimeMillis()463        val entry = withDerivedLast4(draft.copy(464            id = draft.id.ifBlank { UUID.randomUUID().toString() },465            createdAt = if (draft.createdAt > 0) draft.createdAt else now,466            modifiedAt = stamp,467            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,468        ))469        val rollback = entryList.toMutableList()470        entryList.add(entry)471        persistOrRollback(rollback)472        entry473    }474475    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {476        requireUnlocked()477        val idx = entryList.indexOfFirst { it.id == patch.id }478        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")479        val current = entryList[idx]480        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)481        // Only refresh passwordChangedAt when the password value actually changes.482        val passwordChanged = patch.password != null && patch.password != current.password483        // Capture the outgoing password (newest first) so the user can reveal/copy/484        // restore it later. History is computed authoritatively from `current`, not485        // the patch — updateEntry is a full-entry replace, so trusting patch's value486        // would let a stale form clobber the real history. Record the age the old487        // password carried, skip an empty prior, and skip an immediate exact dupe488        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.489        val priorPassword = current.password490        val shouldCapture = passwordChanged &&491            !priorPassword.isNullOrEmpty() &&492            current.passwordHistory?.firstOrNull()?.password != priorPassword493        val nextHistory = if (shouldCapture) {494            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +495                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)496        } else {497            current.passwordHistory498        }499        val updated = withDerivedLast4(patch.copy(500            createdAt = current.createdAt,501            modifiedAt = stamp,502            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,503            passwordHistory = nextHistory,504        ))505        val rollback = entryList.toMutableList()506        entryList[idx] = updated507        persistOrRollback(rollback)508        updated509    }510511    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */512    suspend fun deleteEntry(id: String) = mutex.withLock {513        requireUnlocked()514        val idx = entryList.indexOfFirst { it.id == id }515        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")516        val current = entryList[idx]517        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)518        val rollback = entryList.toMutableList()519        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)520        persistOrRollback(rollback)521    }522523    suspend fun restoreEntry(id: String) = mutex.withLock {524        requireUnlocked()525        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }526        if (idx == -1) throw IllegalStateException("deleted entry not found")527        val current = entryList[idx]528        val rollback = entryList.toMutableList()529        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))530        persistOrRollback(rollback)531    }532533    /** Permanent delete — no tombstone, no recovery. */534    suspend fun purgeEntry(id: String) = mutex.withLock {535        requireUnlocked()536        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }537        if (idx == -1) throw IllegalStateException("deleted entry not found")538        val rollback = entryList.toMutableList()539        entryList.removeAt(idx)540        persistOrRollback(rollback)541    }542543    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */544    suspend fun purgeDeletedEntries(): Int = mutex.withLock {545        requireUnlocked()546        val rollback = entryList.toMutableList()547        val before = entryList.size548        entryList.removeAll { it.deletedAt != null }549        val removed = before - entryList.size550        if (removed > 0) persistOrRollback(rollback)551        removed552    }553554    /** Browser parity: number of active entries that dedupe would move to trash. */555    suspend fun countDuplicateEntries(): Int = mutex.withLock {556        requireUnlocked()557        countDuplicateEntriesLocked()558    }559560    /**561     * Browser parity: soft-delete duplicate entries, keeping the most recently562     * modified member of each group. Refuse in decoy mode so the duress vault is563     * not reshaped into a forensically distinct state.564     */565    suspend fun deduplicateEntries(): Int = mutex.withLock {566        requireUnlocked()567        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")568        val active = entryList.filter { it.deletedAt == null }569        val keepIds = mutableSetOf<String>()570        active.groupBy { it.duplicateKey() }.values.forEach { group ->571            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id572        }573574        val rollback = entryList.toMutableList()575        var removed = 0576        entryList = entryList.map { entry ->577            if (entry.deletedAt == null && entry.id !in keepIds) {578                removed++579                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)580                entry.copy(deletedAt = stamp, modifiedAt = stamp)581            } else {582                entry583            }584        }.toMutableList()585        if (removed > 0) persistOrRollback(rollback)586        removed587    }588589    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }590    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }591    fun getDeletedEntries(): List<VaultEntry> =592        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }593594    /** Active entries, optionally narrowed to those carrying [tagFilter]. */595    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {596        val active = entryList.filter { it.deletedAt == null }597        if (tagFilter == null) return active598        val needle = tagFilter.trim().lowercase()599        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }600    }601602    /** Sorted, de-duplicated tags across all active entries. */603    fun getDistinctTags(): List<String> =604        entryList.asSequence()605            .filter { it.deletedAt == null }606            .flatMap { it.tags?.asSequence() ?: emptySequence() }607            .map { it.trim().lowercase() }608            .filter { it.isNotEmpty() }609            .distinct()610            .sorted()611            .toList()612613    /**614     * Distinct non-blank values for an identity field across active entries,615     * most-frequently-used first. Supports "username" and "email"; unknown field616     * names return an empty list. Values keep original casing (deduped617     * case-insensitively) so a suggestion matches what the user typed.618     */619    fun getDistinctFieldValues(field: String): List<String> {620        val select: (VaultEntry) -> String? = when (field) {621            "username" -> { e -> e.username }622            "email" -> { e -> e.email }623            else -> return emptyList()624        }625        return entryList.asSequence()626            .filter { it.deletedAt == null }627            .mapNotNull { select(it)?.trim() }628            .filter { it.isNotEmpty() }629            .groupingBy { it.lowercase() }630            .aggregate { _, acc: Pair<String, Int>?, value, _ ->631                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)632            }633            .values634            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })635            .map { it.first }636    }637638    fun getTombstones(): List<Tombstone> =639        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }640    fun getVaultVersion(): Long = store.version()641642    // ---- Sync apply (does NOT advance the version) ------------------------643644    /**645     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists646     * WITHOUT bumping the version counter, so a sync round cannot cascade into647     * another outbound sync. Returns the number of locally-applied changes.648     */649    suspend fun applyMergedRemoteEntries(650        remoteEntries: List<VaultEntry>,651        remoteTombstones: List<Tombstone>,652    ): Int = mutex.withLock {653        requireUnlocked()654        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }655        var applied = 0656        val byId = entryList.associateBy { it.id }.toMutableMap()657658        for (remote in remoteEntries) {659            val tomb = tombMap[remote.id]660            if (tomb != null && tomb >= remote.modifiedAt) continue661            val local = byId[remote.id]662            if (local == null) {663                entryList.add(remote); byId[remote.id] = remote; applied++664            } else if (remote.modifiedAt > local.modifiedAt) {665                val i = entryList.indexOfFirst { it.id == local.id }666                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }667            }668        }669670        for ((id, deletedAt) in tombMap) {671            val local = byId[id] ?: continue672            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue673            if (deletedAt < local.modifiedAt) continue674            val i = entryList.indexOfFirst { it.id == id }675            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }676        }677678        if (applied > 0) {679            persistSlotA(bumpVersion = false)680            publish()681        }682        applied683    }684685    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {686        _settings.value = settings687        store.writeSettings(settings)688    }689690    // ---- Recovery words (vault metadata) ----------------------------------691692    /** True once the user has confirmed their recovery words for this vault. */693    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet694695    /**696     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.697     * Persisted without bumping the version — recovery metadata is local-only and698     * is not an entry mutation paired devices should sync against.699     */700    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {701        requireUnlocked()702        recoveryWordHash = recoveryWordsHash(words)703        recoveryWordsSet = true704        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow705        // (cross-platform recovery of the sync root key) and Android's whole-payload706        // escrow (offline forgot-password restore of entries). Both derive their key707        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.708        runCatching {709            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))710        }.getOrDefault(false)711    }712713    /** Verify entered words against the stored recovery hash. */714    fun verifyRecoveryWords(words: List<String>): Boolean {715        val stored = recoveryWordHash ?: return false716        return stored == recoveryWordsHash(words)717    }718719    // ---- Recovery escrow (forgot-password) --------------------------------720721    /** True at rest if this vault has any recovery escrow the recovery flow can use. */722    fun hasRecoveryEscrow(): Boolean =723        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false724725    /**726     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of727     * the master password. On success the slot-A plaintext sealed in the escrow is728     * adopted as the live session (entries, rootKey, settings), but no Argon2id729     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a730     * new password and finish unlocking. Returns false when there is no escrow, the731     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).732     *733     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no734     * detached vault key to return here — slot A is wrapped under the rotating735     * Argon2id key — so this opens the escrowed payload directly.736     */737    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {738        val slotA = store.loadSlotA() ?: return false739        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false740741        // Prefer Android's whole-payload escrow: a full offline restore of entries,742        // root key, and settings. Present for Android-created vaults.743        slotA.recoveryEscrow?.let { escrow ->744            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let745            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))746            val keyBytes = recoveryKey.encoded747            try {748                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)749                val payload = VaultCodec.decode(plaintext)750                decoyMode = false751                entryList = payload.entries.toMutableList()752                duressConfigured = payload.duressConfigured753                recoveryWordHash = payload.recoveryWordHash754                recoveryWordsSet = payload.recoveryWordsSet755                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)756                          else generateRecoverableRootKey()757                _settings.value = store.loadSettings()758                VaultCrypto.zero(plaintext)759                return true760            } catch (_: Exception) {761                // Wrong words against this escrow — fall through to the root-key escrow.762            } finally {763                VaultCrypto.zero(material)764                VaultCrypto.zero(keyBytes)765            }766        }767768        // Browser-format vault (only the root-key escrow): recover the 32-byte root769        // key (sync identity) and reset the password; entries return via sync. This770        // is exactly the browser's recovery semantics (`messages.ts:312-347`).771        slotA.rootKeyEscrow?.let { escrow ->772            val rk = unsealRootKeyEscrow(words, escrow) ?: return false773            decoyMode = false774            entryList = mutableListOf()775            duressConfigured = false776            recoveryWordHash = recoveryWordsHash(words)777            recoveryWordsSet = true778            rootKey = rk779            _settings.value = store.loadSettings()780            return true781        }782        return false783    }784785    /**786     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:787     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same788     * words, mark the session fully unlocked, and publish. Returns false if called789     * without a prior successful recovery unlock (no in-memory root key).790     *791     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`792     * into one step, since there is no detached vault key here.793     */794    suspend fun resetPasswordWithRecoveryWords(795        words: List<String>,796        newPassword: String,797    ): Boolean = mutex.withLock {798        if (rootKey == null) return false // must have recovered first799        return try {800            val newSalt = VaultCrypto.generateSalt()801            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)802            val plaintext = VaultCodec.encode(currentPayload())803            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))804            val prior = store.loadSlotA()805            // Rebuild both escrows against the recovered session so their salts rotate806            // with the password reset; fall back to the prior escrows if rebuild fails807            // (e.g. a checksum-invalid phrase). The same 12 words keep working.808            val valid = words.size == 12 && findInvalidWords(words).isEmpty()809            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow810            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow811            val next = StoredVault(812                version = prior?.version ?: 1,813                salt = b64(newSalt),814                blob = blob,815                recoveryEscrow = freshRecovery,816                rootKeyEscrow = freshRootKey,817            )818            if (!store.writeSlotA(next)) return false819            writeMatchingNoiseSlotB(next)820            store.stampLocalMutation()821            derivedKey?.let(VaultCrypto::zero)822            derivedKey = newKey823            VaultCrypto.zero(plaintext)824            publish()825            _isUnlocked.value = true826            true827        } catch (_: Exception) {828            false829        }830    }831832    /**833     * Seal the current slot-A plaintext under a key derived from the 12 BIP39834     * recovery [words] (Android offline-restore escrow). IKM is the browser's835     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].836     * Returns null for a checksum-invalid phrase.837     */838    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {839        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null840        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null841        val salt = VaultCrypto.generateSalt()842        val recoveryKey = deriveRecoveryKey(material, salt)843        val keyBytes = recoveryKey.encoded844        val plaintext = VaultCodec.encode(currentPayload())845        return try {846            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)847            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))848        } finally {849            VaultCrypto.zero(material)850            VaultCrypto.zero(keyBytes)851            VaultCrypto.zero(plaintext)852        }853    }854855    /**856     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,857     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex858     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,859     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap860     * the same root key on the browser. Returns null with no in-memory root key or861     * a checksum-invalid phrase.862     */863    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {864        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null865        val rk = rootKey ?: return null866        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null867        val salt = VaultCrypto.generateSalt()868        val recoveryKey = deriveRecoveryKey(material, salt)869        val keyBytes = recoveryKey.encoded870        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)871        return try {872            // combined = nonce(12) || ciphertext || tag(16)873            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)874            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)875            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)876            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)877            val ct = ctTag.copyOfRange(0, ctTag.size - 16)878            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))879        } finally {880            VaultCrypto.zero(material)881            VaultCrypto.zero(keyBytes)882            VaultCrypto.zero(plaintext)883        }884    }885886    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */887    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {888        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null889        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))890        val keyBytes = recoveryKey.encoded891        return try {892            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)893            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)894            hexToRootKey(String(pt, Charsets.US_ASCII))895        } catch (_: Exception) {896            null897        } finally {898            VaultCrypto.zero(material)899            VaultCrypto.zero(keyBytes)900        }901    }902903    /**904     * Re-seal slot A from the current in-memory state under the live key, carrying905     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs906     * in decoy mode (recovery is never offered for the decoy) and persists without907     * a version bump, like the recovery-words metadata write it replaces.908     */909    private fun persistSlotAWithEscrows(910        recoveryEscrow: RecoveryEscrow?,911        rootKeyEscrow: RootKeyEscrow?,912    ): Boolean {913        val key = derivedKey ?: throw IllegalStateException("vault_locked")914        if (decoyMode) return false915        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")916        val plaintext = VaultCodec.encode(currentPayload())917        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))918        val next = prior.copy(919            blob = blob,920            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,921            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,922        )923        if (!store.writeSlotA(next)) return false924        try {925            writeMatchingNoiseSlotB(next)926        } catch (e: Exception) {927            store.writeSlotA(prior)928            throw e929        }930        return true931    }932933    // ---- Internals --------------------------------------------------------934935    private fun requireUnlocked() {936        if (derivedKey == null) throw IllegalStateException("vault_locked")937    }938939    private fun countDuplicateEntriesLocked(): Int {940        val seen = mutableMapOf<String, VaultEntry>()941        var duplicates = 0942        for (entry in entryList.filter { it.deletedAt == null }) {943            val key = entry.duplicateKey()944            val existing = seen[key]945            if (existing == null) {946                seen[key] = entry947            } else {948                duplicates++949                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt950                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt951                if (entryStamp > existingStamp) seen[key] = entry952            }953        }954        return duplicates955    }956957    private fun VaultEntry.duplicateKey(): String {958        if (type == "note") return "note|${title.lowercase()}"959        val passkeyData = passkey960        if (passkeyData != null) {961            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"962        }963        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"964    }965966    /**967     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),968     * so it is stored explicitly and survives the sync wire format rather than being969     * re-derived at render time. Non-card entries pass through unchanged.970     */971    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {972        if (!entry.isCard()) return entry973        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""974        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)975    }976977    /**978     * On unlock, hard-delete trashed entries older than the retention window.979     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"980     * (the app's "Never" option), so nothing is purged. Persists without a981     * version bump and without touching the publish state (caller publishes).982     */983    private fun autoPurgeExpiredTrash() {984        val days = _settings.value.trashRetentionDays ?: return985        if (days <= 0) return986        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L987        val before = entryList.size988        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }989        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }990    }991992    /** The slot-A plaintext envelope for the current in-memory session. */993    private fun currentPayload(): VaultPayload =994        VaultPayload(995            entries = entryList.toList(),996            duressConfigured = duressConfigured,997            rootKeyB64 = rootKey?.let(::b64) ?: "",998            recoveryWordHash = recoveryWordHash,999            recoveryWordsSet = recoveryWordsSet,1000        )10011002    /**1003     * Adopt the payload's root key, lazily minting one for legacy payloads.1004     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1005     * a root key is not a mutation paired devices should sync against.1006     */1007    private fun ensureRootKey(payload: VaultPayload) {1008        if (payload.rootKeyB64.isNotBlank()) {1009            rootKey = unb64(payload.rootKeyB64)1010            return1011        }1012        rootKey = generateRecoverableRootKey()1013        runCatching { persistSlotA(bumpVersion = false) }1014    }10151016    private fun publish() {1017        _entries.value = entryList.filter { it.deletedAt == null }1018    }10191020    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1021        val plaintext = VaultCodec.encode(currentPayload())1022        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1023        return StoredVault(version = 1, salt = b64(salt), blob = blob)1024    }10251026    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1027        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1028        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1029        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1030        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1031        val noise = ByteArray(approxBytes).also(rng::nextBytes)1032        store.writeSlotB(b64(noise))1033    }10341035    /** Re-seal slot A from the current in-memory state under the live key. */1036    private fun persistSlotA(bumpVersion: Boolean): Boolean {1037        val key = derivedKey ?: throw IllegalStateException("vault_locked")1038        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1039        if (decoyMode) {1040            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1041            val plaintext = VaultCodec.encode(currentPayload())1042            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1043            return store.writeDecoySlot(prior.copy(blob = blob))1044        }1045        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1046        val plaintext = VaultCodec.encode(currentPayload())1047        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1048        val next = prior.copy(blob = blob)1049        if (!store.writeSlotA(next)) return false1050        // Slot A committed — keep slot B size-matched, then advance the version1051        // for local writes only. Restore slot A if a later step fails.1052        try {1053            writeMatchingNoiseSlotB(next)1054            if (bumpVersion) store.stampLocalMutation()1055        } catch (e: Exception) {1056            store.writeSlotA(prior)1057            throw e1058        }1059        return true1060    }10611062    /** Persist a local mutation, rolling the in-memory list back on failure. */1063    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1064        try {1065            if (!persistSlotA(bumpVersion = true)) {1066                entryList = rollback1067                throw IllegalStateException("storage_write_failed")1068            }1069        } catch (e: Exception) {1070            entryList = rollback1071            throw e1072        }1073        publish()1074    }1075}