VaultRepository.kt

49.7 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import java.util.Base6411import java.util.UUID12import javax.inject.Inject13import javax.inject.Singleton1415private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)16private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)1718/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */19private const val PASSWORD_HISTORY_MAX = 5020private const val VAULT_HISTORY_MAX = 52122sealed interface UnlockResult {23    data object Success : UnlockResult24    data object WrongPassword : UnlockResult25    data class Error(val reason: String) : UnlockResult26}2728sealed interface CreateVaultResult {29    data object Success : CreateVaultResult30    data class Error(val reason: String) : CreateVaultResult31}3233/**34 * In-memory vault session backed by [VaultStore]. Faithful port of the browser35 * `vault.ts` semantics that matter for correctness:36 *37 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched38 *    noise blob so a fresh install shows two similarly-shaped records.39 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a40 *    deletion always wins last-write-wins against an in-flight edit.41 *  - Local writes bump the version counter; sync-applied writes do NOT (so a42 *    sync round never cascades into another outbound sync).43 *  - add/update/delete roll back in-memory state if the disk write fails.44 *  - destroyVault never touches the Pro license (separate store).45 */46@Singleton47class VaultRepository @Inject constructor(48    private val store: VaultStorage,49) {50    private val mutex = Mutex()51    private val rng = SecureRandom()5253    private var derivedKey: ByteArray? = null54    private var rootKey: ByteArray? = null55    private var entryList: MutableList<VaultEntry> = mutableListOf()56    private var duressConfigured: Boolean = false57    private var recoveryWordHash: String? = null58    private var recoveryWordsSet: Boolean = false59    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */60    private var decoyMode: Boolean = false6162    private val _isUnlocked = MutableStateFlow(false)63    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6465    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())66    /** Active (non-deleted) entryList. */67    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()6869    private val _settings = MutableStateFlow(VaultSettings())70    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7172    fun hasVault(): Boolean = store.hasVault()7374    // ---- Codex (printed paper backup) -------------------------------------7576    /**77     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the78     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable79     * on any device that has the master password. Null when no vault exists.80     */81    fun codexVaultBlobBase64(): String? {82        val slotA = store.loadSlotA() ?: return null83        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""84        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))85    }8687    /**88     * Replace the local vault from a Codex blob, then unlock with [password].89     * Writes slot A (and a size-matched noise slot B) before unlocking so the90     * restored vault is at rest exactly as a freshly-created one would be.91     */92    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {93        return try {94            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)95            val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)96                ?: return UnlockResult.Error("invalid_codex_blob")97            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")98            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")99            writeMatchingNoiseSlotB(slotA)100            store.stampLocalMutation()101            unlockLocked(password)102        } catch (e: Exception) {103            UnlockResult.Error(e.message ?: "restore_failed")104        }105    }106107    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {108        return try {109            val salt = VaultCrypto.generateSalt()110            val key = VaultCrypto.deriveKey(password, salt)111            entryList = mutableListOf()112            duressConfigured = false113            recoveryWordHash = null114            recoveryWordsSet = false115            decoyMode = false116            rootKey = generateRecoverableRootKey()117            val slotA = sealSlotA(key, salt)118            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")119            writeMatchingNoiseSlotB(slotA)120            store.stampLocalMutation()121            store.writeSettings(_settings.value)122            derivedKey = key123            publish()124            _isUnlocked.value = true125            CreateVaultResult.Success126        } catch (e: Exception) {127            CreateVaultResult.Error(e.message ?: "create_failed")128        }129    }130131    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }132133    /** Unlock body without acquiring the mutex — callers must already hold it. */134    private fun unlockLocked(password: String): UnlockResult {135        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")136        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))137        val payload = try {138            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))139        } catch (_: Exception) {140            VaultCrypto.zero(key)141            // LV-1: a vault sealed before NFC normalization with a non-NFC142            // password derives a different key now. If the supplied password is143            // not already in NFC form, retry with the raw (un-normalized) bytes.144            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {145                val legacy = tryLegacyRawUnlock(password, slotA)146                if (legacy != null) return legacy147            }148            return UnlockResult.WrongPassword149        }150        return adoptSlotA(key, payload)151    }152153    /**154     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw155     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed156     * under the NFC-normalized key — so every future unlock takes the normal157     * path. Returns null when the raw key also fails (genuinely wrong password).158     */159    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {160        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))161        val payload = try {162            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))163        } catch (_: Exception) {164            VaultCrypto.zero(rawKey)165            return null166        }167        // Heal: re-seal slot A under the NFC key (mirrors changePassword's168        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.169        val newSalt = VaultCrypto.generateSalt()170        val newKey = VaultCrypto.deriveKey(password, newSalt)171        val healed = try {172            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))173            val next = StoredVault(174                version = slotA.version,175                salt = b64(newSalt),176                blob = blob,177                recoveryEscrow = slotA.recoveryEscrow,178                rootKeyEscrow = slotA.rootKeyEscrow,179            )180            if (store.writeSlotA(next)) {181                writeMatchingNoiseSlotB(next)182                store.stampLocalMutation()183                true184            } else {185                false186            }187        } catch (_: Exception) {188            false189        }190        return if (healed) {191            VaultCrypto.zero(rawKey)192            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot193        } else {194            VaultCrypto.zero(newKey)195            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key196        }197    }198199    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */200    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {201        return try {202            derivedKey = key203            decoyMode = false204            entryList = payload.entries.toMutableList()205            duressConfigured = payload.duressConfigured206            recoveryWordHash = payload.recoveryWordHash207            recoveryWordsSet = payload.recoveryWordsSet208            _settings.value = store.loadSettings()209            ensureRootKey(payload)210            autoPurgeExpiredTrash()211            publish()212            _isUnlocked.value = true213            UnlockResult.Success214        } catch (e: Exception) {215            UnlockResult.Error(e.message ?: "unlock_failed")216        }217    }218219    // ---- Constant-time unlock (MF-5) --------------------------------------220221    /**222     * A slot-A unlock that has been derived but not yet adopted. Holds the live223     * Argon2id key and decoded payload so the caller can derive several candidates224     * concurrently and commit only the winner. The key is owned by this token until225     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.226     */227    class SlotAUnlock internal constructor(228        internal val key: ByteArray,229        internal val payload: VaultPayload,230    )231232    /**233     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.234     * Pure computation (no mutex), so it can run concurrently with other candidate235     * derivations before the caller decides which to commit (MF-5). Returns null on236     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).237     */238    fun tryUnlockSlotA(password: String): SlotAUnlock? {239        val slotA = store.loadSlotA() ?: return null240        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))241        return try {242            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))243            SlotAUnlock(key, payload)244        } catch (_: Exception) {245            VaultCrypto.zero(key)246            null247        }248    }249250    /** Adopt a previously-derived [SlotAUnlock] as the live session. */251    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {252        adoptSlotA(unlock.key, unlock.payload)253    }254255    /** Zero a derived [SlotAUnlock] that will not be adopted. */256    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)257258    // ---- Duress slot B (MF-6) ---------------------------------------------259260    /**261     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key262     * and decoded decoy payload so it can be derived concurrently with slot A and263     * only committed if the duress passphrase was the one entered (MF-5/MF-6).264     */265    class DuressUnlock internal constructor(266        internal val key: ByteArray,267        internal val payload: VaultPayload,268    )269270    /**271     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the272     * session. Success means the entered password is the duress passphrase that keys273     * slot B — indistinguishable from the real slot A at the lock screen. Pure274     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].275     * Returns null when no duress slot exists or the password is not the duress one.276     */277    fun tryUnlockDuress(password: String): DuressUnlock? {278        val slot = store.loadDecoySlot() ?: return null279        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))280        return try {281            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))282            DuressUnlock(key, payload)283        } catch (_: Exception) {284            VaultCrypto.zero(key)285            null286        }287    }288289    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */290    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {291        return try {292            derivedKey = unlock.key293            decoyMode = true294            entryList = unlock.payload.entries.toMutableList()295            duressConfigured = false296            recoveryWordHash = unlock.payload.recoveryWordHash297            recoveryWordsSet = unlock.payload.recoveryWordsSet298            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)299                      else VaultCrypto.generateRootKey()300            _settings.value = store.loadSettings()301            publish()302            _isUnlocked.value = true303            UnlockResult.Success304        } catch (e: Exception) {305            UnlockResult.Error(e.message ?: "decoy_unlock_failed")306        }307    }308309    /** Zero a derived [DuressUnlock] that will not be adopted. */310    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)311312    /**313     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an314     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The315     * passphrase is never stored — only its ability to decrypt slot B proves it.316     * Requires the real vault to be unlocked and not itself a decoy session.317     */318    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {319        if (derivedKey == null || decoyMode) return false320        // Reject a duress passphrase that also opens the real vault: it would make the321        // lock screen ambiguous (both slots would decrypt for the same input).322        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }323        return try {324            val salt = VaultCrypto.generateSalt()325            val key = VaultCrypto.deriveKey(duressPassphrase, salt)326            // An empty decoy vault with its own fresh sync root key — nothing real leaks.327            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))328            val plaintext = VaultCodec.encode(decoyPayload)329            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))330            VaultCrypto.zero(key)331            VaultCrypto.zero(plaintext)332            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))333            if (ok) {334                duressConfigured = true335                runCatching { persistSlotA(bumpVersion = false) }336            }337            ok338        } catch (_: Exception) {339            false340        }341    }342343    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */344    suspend fun clearDuress(): Boolean = mutex.withLock {345        store.clearDecoySlot()346        if (derivedKey != null && !decoyMode) {347            duressConfigured = false348            runCatching { persistSlotA(bumpVersion = false) }349        }350        true351    }352353    /** Unlock with an already-derived AES-256 key (biometric path). */354    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {355        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")356        return try {357            val payload = VaultCodec.decode(358                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),359            )360            derivedKey = key.copyOf()361            decoyMode = false362            entryList = payload.entries.toMutableList()363            duressConfigured = payload.duressConfigured364            recoveryWordHash = payload.recoveryWordHash365            recoveryWordsSet = payload.recoveryWordsSet366            _settings.value = store.loadSettings()367            ensureRootKey(payload)368            autoPurgeExpiredTrash()369            publish()370            _isUnlocked.value = true371            UnlockResult.Success372        } catch (_: Exception) {373            UnlockResult.WrongPassword374        }375    }376377    fun lock() {378        derivedKey?.let(VaultCrypto::zero)379        rootKey?.let(VaultCrypto::zero)380        derivedKey = null381        rootKey = null382        entryList = mutableListOf()383        duressConfigured = false384        recoveryWordHash = null385        recoveryWordsSet = false386        decoyMode = false387        _entries.value = emptyList()388        _isUnlocked.value = false389    }390391    // ---- Sync identity ----------------------------------------------------392393    /**394     * The vault's stable sync master key (a copy). Caller must zero it. Backs395     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry396     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].397     */398    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()399400    /** Stable per-vault id two paired devices must share to sync. Null when locked. */401    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }402403    /**404     * A copy of the live Argon2id-derived vault key, for biometric escrow only405     * (wrapped under a hardware KeyStore key on biometric enable). Null when406     * locked. Caller must [VaultCrypto.zero] it after use.407     */408    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()409410    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {411        val slotA = store.loadSlotA() ?: return false412        return try {413            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))414            val payload = VaultCodec.decode(415                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),416            )417            val newSalt = VaultCrypto.generateSalt()418            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)419            // Re-seal the existing payload under the new key/salt.420            val plaintext = VaultCodec.encode(payload)421            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))422            // The escrow seals the slot-A PLAINTEXT, which a password change does not423            // alter — only the wrapping changes — so carry it forward; rebuilding the424            // StoredVault from scratch would silently drop it.425            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key426            // content that a password change does not alter — only the slot-A427            // wrapping rotates. The root-key escrow staying stable across password428            // changes matches the browser (`vault.ts:363-364`).429            val next = StoredVault(430                version = slotA.version,431                salt = b64(newSalt),432                blob = blob,433                recoveryEscrow = slotA.recoveryEscrow,434                rootKeyEscrow = slotA.rootKeyEscrow,435            )436            if (!store.writeSlotA(next)) return false437            writeMatchingNoiseSlotB(next)438            store.stampLocalMutation()439            VaultCrypto.zero(oldKey)440            derivedKey?.let(VaultCrypto::zero)441            derivedKey = newKey442            true443        } catch (_: Exception) {444            false445        }446    }447448    /**449     * Irreversible wipe. Locks the session and clears every vault key. The Pro450     * license is in a separate store and is preserved by construction.451     */452    suspend fun destroyVault() = mutex.withLock {453        lock()454        store.clearVault()455        store.clearDecoySlot()456    }457458    // ---- Entry CRUD -------------------------------------------------------459460    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {461        requireUnlocked()462        val stamp = VaultCrypto.monotonicEntryTimestamp()463        val now = System.currentTimeMillis()464        val entry = withDerivedLast4(draft.copy(465            id = draft.id.ifBlank { UUID.randomUUID().toString() },466            createdAt = if (draft.createdAt > 0) draft.createdAt else now,467            modifiedAt = stamp,468            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,469        ))470        val rollback = entryList.toMutableList()471        entryList.add(entry)472        persistOrRollback(rollback)473        entry474    }475476    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {477        requireUnlocked()478        val idx = entryList.indexOfFirst { it.id == patch.id }479        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")480        val current = entryList[idx]481        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)482        // Only refresh passwordChangedAt when the password value actually changes.483        val passwordChanged = patch.password != null && patch.password != current.password484        // Capture the outgoing password (newest first) so the user can reveal/copy/485        // restore it later. History is computed authoritatively from `current`, not486        // the patch — updateEntry is a full-entry replace, so trusting patch's value487        // would let a stale form clobber the real history. Record the age the old488        // password carried, skip an empty prior, and skip an immediate exact dupe489        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.490        val priorPassword = current.password491        val shouldCapture = passwordChanged &&492            !priorPassword.isNullOrEmpty() &&493            current.passwordHistory?.firstOrNull()?.password != priorPassword494        val nextHistory = if (shouldCapture) {495            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +496                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)497        } else {498            current.passwordHistory499        }500        val updated = withDerivedLast4(patch.copy(501            createdAt = current.createdAt,502            modifiedAt = stamp,503            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,504            passwordHistory = nextHistory,505        ))506        val rollback = entryList.toMutableList()507        entryList[idx] = updated508        persistOrRollback(rollback)509        updated510    }511512    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */513    suspend fun deleteEntry(id: String) = mutex.withLock {514        requireUnlocked()515        val idx = entryList.indexOfFirst { it.id == id }516        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")517        val current = entryList[idx]518        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)519        val rollback = entryList.toMutableList()520        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)521        persistOrRollback(rollback)522    }523524    suspend fun restoreEntry(id: String) = mutex.withLock {525        requireUnlocked()526        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }527        if (idx == -1) throw IllegalStateException("deleted entry not found")528        val current = entryList[idx]529        val rollback = entryList.toMutableList()530        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))531        persistOrRollback(rollback)532    }533534    /** Permanent delete — no tombstone, no recovery. */535    suspend fun purgeEntry(id: String) = mutex.withLock {536        requireUnlocked()537        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }538        if (idx == -1) throw IllegalStateException("deleted entry not found")539        val rollback = entryList.toMutableList()540        entryList.removeAt(idx)541        persistOrRollback(rollback)542    }543544    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */545    suspend fun purgeDeletedEntries(): Int = mutex.withLock {546        requireUnlocked()547        val rollback = entryList.toMutableList()548        val before = entryList.size549        entryList.removeAll { it.deletedAt != null }550        val removed = before - entryList.size551        if (removed > 0) persistOrRollback(rollback)552        removed553    }554555    /** Browser parity: number of active entries that dedupe would move to trash. */556    suspend fun countDuplicateEntries(): Int = mutex.withLock {557        requireUnlocked()558        countDuplicateEntriesLocked()559    }560561    /**562     * Browser parity: soft-delete duplicate entries, keeping the most recently563     * modified member of each group. Refuse in decoy mode so the duress vault is564     * not reshaped into a forensically distinct state.565     */566    suspend fun deduplicateEntries(): Int = mutex.withLock {567        requireUnlocked()568        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")569        val active = entryList.filter { it.deletedAt == null }570        val keepIds = mutableSetOf<String>()571        active.groupBy { it.duplicateKey() }.values.forEach { group ->572            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id573        }574575        val rollback = entryList.toMutableList()576        var removed = 0577        entryList = entryList.map { entry ->578            if (entry.deletedAt == null && entry.id !in keepIds) {579                removed++580                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)581                entry.copy(deletedAt = stamp, modifiedAt = stamp)582            } else {583                entry584            }585        }.toMutableList()586        if (removed > 0) persistOrRollback(rollback)587        removed588    }589590    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }591    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }592    fun getDeletedEntries(): List<VaultEntry> =593        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }594595    /** Active entries, optionally narrowed to those carrying [tagFilter]. */596    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {597        val active = entryList.filter { it.deletedAt == null }598        if (tagFilter == null) return active599        val needle = tagFilter.trim().lowercase()600        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }601    }602603    /** Sorted, de-duplicated tags across all active entries. */604    fun getDistinctTags(): List<String> =605        entryList.asSequence()606            .filter { it.deletedAt == null }607            .flatMap { it.tags?.asSequence() ?: emptySequence() }608            .map { it.trim().lowercase() }609            .filter { it.isNotEmpty() }610            .distinct()611            .sorted()612            .toList()613614    /**615     * Distinct non-blank values for an identity field across active entries,616     * most-frequently-used first. Supports "username" and "email"; unknown field617     * names return an empty list. Values keep original casing (deduped618     * case-insensitively) so a suggestion matches what the user typed.619     */620    fun getDistinctFieldValues(field: String): List<String> {621        val select: (VaultEntry) -> String? = when (field) {622            "username" -> { e -> e.username }623            "email" -> { e -> e.email }624            else -> return emptyList()625        }626        return entryList.asSequence()627            .filter { it.deletedAt == null }628            .mapNotNull { select(it)?.trim() }629            .filter { it.isNotEmpty() }630            .groupingBy { it.lowercase() }631            .aggregate { _, acc: Pair<String, Int>?, value, _ ->632                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)633            }634            .values635            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })636            .map { it.first }637    }638639    fun getTombstones(): List<Tombstone> =640        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }641    fun getVaultVersion(): Long = store.version()642    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots643644    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {645        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false646        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false647        takeVaultSnapshot("restore")648        if (!store.writeSlotA(restoredA)) return false649        if (!store.writeSlotB(snapshot.vault_b)) return false650        store.stampLocalMutation()651        lock()652        true653    }654655    // ---- Sync apply (does NOT advance the version) ------------------------656657    /**658     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists659     * WITHOUT bumping the version counter, so a sync round cannot cascade into660     * another outbound sync. Returns the number of locally-applied changes.661     */662    suspend fun applyMergedRemoteEntries(663        remoteEntries: List<VaultEntry>,664        remoteTombstones: List<Tombstone>,665    ): Int = mutex.withLock {666        requireUnlocked()667        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }668        var applied = 0669        val byId = entryList.associateBy { it.id }.toMutableMap()670671        for (remote in remoteEntries) {672            val tomb = tombMap[remote.id]673            if (tomb != null && tomb >= remote.modifiedAt) continue674            val local = byId[remote.id]675            if (local == null) {676                entryList.add(remote); byId[remote.id] = remote; applied++677            } else if (remote.modifiedAt > local.modifiedAt) {678                val i = entryList.indexOfFirst { it.id == local.id }679                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }680            }681        }682683        for ((id, deletedAt) in tombMap) {684            val local = byId[id] ?: continue685            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue686            if (deletedAt < local.modifiedAt) continue687            val i = entryList.indexOfFirst { it.id == id }688            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }689        }690691        if (applied > 0) {692            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")693            publish()694        }695        applied696    }697698    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {699        _settings.value = settings700        store.writeSettings(settings)701    }702703    // ---- Recovery words (vault metadata) ----------------------------------704705    /** True once the user has confirmed their recovery words for this vault. */706    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet707708    /**709     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.710     * Persisted without bumping the version — recovery metadata is local-only and711     * is not an entry mutation paired devices should sync against.712     */713    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {714        requireUnlocked()715        recoveryWordHash = recoveryWordsHash(words)716        recoveryWordsSet = true717        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow718        // (cross-platform recovery of the sync root key) and Android's whole-payload719        // escrow (offline forgot-password restore of entries). Both derive their key720        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.721        runCatching {722            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))723        }.getOrDefault(false)724    }725726    /** Verify entered words against the stored recovery hash. */727    fun verifyRecoveryWords(words: List<String>): Boolean {728        val stored = recoveryWordHash ?: return false729        return stored == recoveryWordsHash(words)730    }731732    // ---- Recovery escrow (forgot-password) --------------------------------733734    /** True at rest if this vault has any recovery escrow the recovery flow can use. */735    fun hasRecoveryEscrow(): Boolean =736        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false737738    /**739     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of740     * the master password. On success the slot-A plaintext sealed in the escrow is741     * adopted as the live session (entries, rootKey, settings), but no Argon2id742     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a743     * new password and finish unlocking. Returns false when there is no escrow, the744     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).745     *746     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no747     * detached vault key to return here — slot A is wrapped under the rotating748     * Argon2id key — so this opens the escrowed payload directly.749     */750    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {751        val slotA = store.loadSlotA() ?: return false752        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false753754        // Prefer Android's whole-payload escrow: a full offline restore of entries,755        // root key, and settings. Present for Android-created vaults.756        slotA.recoveryEscrow?.let { escrow ->757            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let758            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))759            val keyBytes = recoveryKey.encoded760            try {761                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)762                val payload = VaultCodec.decode(plaintext)763                decoyMode = false764                entryList = payload.entries.toMutableList()765                duressConfigured = payload.duressConfigured766                recoveryWordHash = payload.recoveryWordHash767                recoveryWordsSet = payload.recoveryWordsSet768                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)769                          else generateRecoverableRootKey()770                _settings.value = store.loadSettings()771                VaultCrypto.zero(plaintext)772                return true773            } catch (_: Exception) {774                // Wrong words against this escrow — fall through to the root-key escrow.775            } finally {776                VaultCrypto.zero(material)777                VaultCrypto.zero(keyBytes)778            }779        }780781        // Browser-format vault (only the root-key escrow): recover the 32-byte root782        // key (sync identity) and reset the password; entries return via sync. This783        // is exactly the browser's recovery semantics (`messages.ts:312-347`).784        slotA.rootKeyEscrow?.let { escrow ->785            val rk = unsealRootKeyEscrow(words, escrow) ?: return false786            decoyMode = false787            entryList = mutableListOf()788            duressConfigured = false789            recoveryWordHash = recoveryWordsHash(words)790            recoveryWordsSet = true791            rootKey = rk792            _settings.value = store.loadSettings()793            return true794        }795        return false796    }797798    /**799     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:800     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same801     * words, mark the session fully unlocked, and publish. Returns false if called802     * without a prior successful recovery unlock (no in-memory root key).803     *804     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`805     * into one step, since there is no detached vault key here.806     */807    suspend fun resetPasswordWithRecoveryWords(808        words: List<String>,809        newPassword: String,810    ): Boolean = mutex.withLock {811        if (rootKey == null) return false // must have recovered first812        return try {813            val newSalt = VaultCrypto.generateSalt()814            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)815            val plaintext = VaultCodec.encode(currentPayload())816            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))817            val prior = store.loadSlotA()818            // Rebuild both escrows against the recovered session so their salts rotate819            // with the password reset; fall back to the prior escrows if rebuild fails820            // (e.g. a checksum-invalid phrase). The same 12 words keep working.821            val valid = words.size == 12 && findInvalidWords(words).isEmpty()822            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow823            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow824            val next = StoredVault(825                version = prior?.version ?: 1,826                salt = b64(newSalt),827                blob = blob,828                recoveryEscrow = freshRecovery,829                rootKeyEscrow = freshRootKey,830            )831            if (!store.writeSlotA(next)) return false832            writeMatchingNoiseSlotB(next)833            store.stampLocalMutation()834            derivedKey?.let(VaultCrypto::zero)835            derivedKey = newKey836            VaultCrypto.zero(plaintext)837            publish()838            _isUnlocked.value = true839            true840        } catch (_: Exception) {841            false842        }843    }844845    /**846     * Seal the current slot-A plaintext under a key derived from the 12 BIP39847     * recovery [words] (Android offline-restore escrow). IKM is the browser's848     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].849     * Returns null for a checksum-invalid phrase.850     */851    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {852        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null853        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null854        val salt = VaultCrypto.generateSalt()855        val recoveryKey = deriveRecoveryKey(material, salt)856        val keyBytes = recoveryKey.encoded857        val plaintext = VaultCodec.encode(currentPayload())858        return try {859            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)860            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))861        } finally {862            VaultCrypto.zero(material)863            VaultCrypto.zero(keyBytes)864            VaultCrypto.zero(plaintext)865        }866    }867868    /**869     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,870     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex871     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,872     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap873     * the same root key on the browser. Returns null with no in-memory root key or874     * a checksum-invalid phrase.875     */876    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {877        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null878        val rk = rootKey ?: return null879        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null880        val salt = VaultCrypto.generateSalt()881        val recoveryKey = deriveRecoveryKey(material, salt)882        val keyBytes = recoveryKey.encoded883        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)884        return try {885            // combined = nonce(12) || ciphertext || tag(16)886            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)887            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)888            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)889            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)890            val ct = ctTag.copyOfRange(0, ctTag.size - 16)891            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))892        } finally {893            VaultCrypto.zero(material)894            VaultCrypto.zero(keyBytes)895            VaultCrypto.zero(plaintext)896        }897    }898899    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */900    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {901        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null902        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))903        val keyBytes = recoveryKey.encoded904        return try {905            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)906            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)907            hexToRootKey(String(pt, Charsets.US_ASCII))908        } catch (_: Exception) {909            null910        } finally {911            VaultCrypto.zero(material)912            VaultCrypto.zero(keyBytes)913        }914    }915916    /**917     * Re-seal slot A from the current in-memory state under the live key, carrying918     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs919     * in decoy mode (recovery is never offered for the decoy) and persists without920     * a version bump, like the recovery-words metadata write it replaces.921     */922    private fun persistSlotAWithEscrows(923        recoveryEscrow: RecoveryEscrow?,924        rootKeyEscrow: RootKeyEscrow?,925    ): Boolean {926        val key = derivedKey ?: throw IllegalStateException("vault_locked")927        if (decoyMode) return false928        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")929        val plaintext = VaultCodec.encode(currentPayload())930        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))931        val next = prior.copy(932            blob = blob,933            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,934            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,935        )936        if (!store.writeSlotA(next)) return false937        try {938            writeMatchingNoiseSlotB(next)939        } catch (e: Exception) {940            store.writeSlotA(prior)941            throw e942        }943        return true944    }945946    // ---- Internals --------------------------------------------------------947948    private fun requireUnlocked() {949        if (derivedKey == null) throw IllegalStateException("vault_locked")950    }951952    private fun countDuplicateEntriesLocked(): Int {953        val seen = mutableMapOf<String, VaultEntry>()954        var duplicates = 0955        for (entry in entryList.filter { it.deletedAt == null }) {956            val key = entry.duplicateKey()957            val existing = seen[key]958            if (existing == null) {959                seen[key] = entry960            } else {961                duplicates++962                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt963                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt964                if (entryStamp > existingStamp) seen[key] = entry965            }966        }967        return duplicates968    }969970    private fun VaultEntry.duplicateKey(): String {971        if (type == "note") return "note|${title.lowercase()}"972        val passkeyData = passkey973        if (passkeyData != null) {974            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"975        }976        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"977    }978979    private fun takeVaultSnapshot(reason: String) {980        runCatching {981            val slotA = store.loadSlotA() ?: return982            val vaultA = VaultCodec.encodeSlot(slotA)983            val vaultB = store.loadSlotB() ?: slotA.blob984            val history = store.loadVaultHistory()985            val latest = history.snapshots.firstOrNull()986            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return987            val entryCount = if (derivedKey != null && !decoyMode) {988                bucketEntryCount(entryList.count { it.deletedAt == null })989            } else {990                null991            }992            val snapshot = VaultSnapshot(993                id = UUID.randomUUID().toString(),994                savedAt = System.currentTimeMillis(),995                reason = reason,996                vault_a = vaultA,997                vault_b = vaultB,998                entryCount = entryCount,999            )1000            store.writeVaultHistory(1001                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1002            )1003        }1004    }10051006    private fun bucketEntryCount(n: Int): Int = when {1007        n <= 0 -> 01008        n <= 10 -> 101009        n <= 25 -> 251010        n <= 50 -> 501011        n <= 100 -> 1001012        n <= 250 -> 2501013        n <= 500 -> 5001014        else -> ((n + 499) / 500) * 5001015    }10161017    /**1018     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1019     * so it is stored explicitly and survives the sync wire format rather than being1020     * re-derived at render time. Non-card entries pass through unchanged.1021     */1022    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1023        if (!entry.isCard()) return entry1024        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1025        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1026    }10271028    /**1029     * On unlock, hard-delete trashed entries older than the retention window.1030     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1031     * (the app's "Never" option), so nothing is purged. Persists without a1032     * version bump and without touching the publish state (caller publishes).1033     */1034    private fun autoPurgeExpiredTrash() {1035        val days = _settings.value.trashRetentionDays ?: return1036        if (days <= 0) return1037        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1038        val before = entryList.size1039        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1040        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1041    }10421043    /** The slot-A plaintext envelope for the current in-memory session. */1044    private fun currentPayload(): VaultPayload =1045        VaultPayload(1046            entries = entryList.toList(),1047            duressConfigured = duressConfigured,1048            rootKeyB64 = rootKey?.let(::b64) ?: "",1049            recoveryWordHash = recoveryWordHash,1050            recoveryWordsSet = recoveryWordsSet,1051        )10521053    /**1054     * Adopt the payload's root key, lazily minting one for legacy payloads.1055     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1056     * a root key is not a mutation paired devices should sync against.1057     */1058    private fun ensureRootKey(payload: VaultPayload) {1059        if (payload.rootKeyB64.isNotBlank()) {1060            rootKey = unb64(payload.rootKeyB64)1061            return1062        }1063        rootKey = generateRecoverableRootKey()1064        runCatching { persistSlotA(bumpVersion = false) }1065    }10661067    private fun publish() {1068        _entries.value = entryList.filter { it.deletedAt == null }1069    }10701071    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1072        val plaintext = VaultCodec.encode(currentPayload())1073        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1074        return StoredVault(version = 1, salt = b64(salt), blob = blob)1075    }10761077    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1078        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1079        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1080        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1081        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1082        val noise = ByteArray(approxBytes).also(rng::nextBytes)1083        store.writeSlotB(b64(noise))1084    }10851086    /** Re-seal slot A from the current in-memory state under the live key. */1087    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1088        val key = derivedKey ?: throw IllegalStateException("vault_locked")1089        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1090        if (decoyMode) {1091            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1092            val plaintext = VaultCodec.encode(currentPayload())1093            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1094            return store.writeDecoySlot(prior.copy(blob = blob))1095        }1096        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1097        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1098        val plaintext = VaultCodec.encode(currentPayload())1099        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1100        val next = prior.copy(blob = blob)1101        if (!store.writeSlotA(next)) return false1102        // Slot A committed — keep slot B size-matched, then advance the version1103        // for local writes only. Restore slot A if a later step fails.1104        try {1105            writeMatchingNoiseSlotB(next)1106            if (bumpVersion) store.stampLocalMutation()1107        } catch (e: Exception) {1108            store.writeSlotA(prior)1109            throw e1110        }1111        return true1112    }11131114    /** Persist a local mutation, rolling the in-memory list back on failure. */1115    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1116        try {1117            if (!persistSlotA(bumpVersion = true)) {1118                entryList = rollback1119                throw IllegalStateException("storage_write_failed")1120            }1121        } catch (e: Exception) {1122            entryList = rollback1123            throw e1124        }1125        publish()1126    }1127}