VaultRepository.kt

57.2 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 kotlinx.serialization.json.Json11import kotlinx.serialization.json.JsonElement12import kotlinx.serialization.json.JsonNull13import kotlinx.serialization.json.JsonObject14import kotlinx.serialization.json.JsonPrimitive15import java.util.Base6416import java.util.UUID17import javax.inject.Inject18import javax.inject.Singleton1920/** Lenient parser for the Codex vault-blob wrapper only. */21private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2223private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)24private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)2526/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */27private const val PASSWORD_HISTORY_MAX = 5028private const val VAULT_HISTORY_MAX = 52930sealed interface UnlockResult {31    data object Success : UnlockResult32    data object WrongPassword : UnlockResult33    data class Error(val reason: String) : UnlockResult34}3536sealed interface CreateVaultResult {37    data object Success : CreateVaultResult38    data class Error(val reason: String) : CreateVaultResult39}4041sealed interface AdoptVaultResult {42    data class Success(val vaultId: String) : AdoptVaultResult43    data class Error(val reason: String) : AdoptVaultResult44}4546/**47 * In-memory vault session backed by [VaultStore]. Faithful port of the browser48 * `vault.ts` semantics that matter for correctness:49 *50 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched51 *    noise blob so a fresh install shows two similarly-shaped records.52 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a53 *    deletion always wins last-write-wins against an in-flight edit.54 *  - Local writes bump the version counter; sync-applied writes do NOT (so a55 *    sync round never cascades into another outbound sync).56 *  - add/update/delete roll back in-memory state if the disk write fails.57 *  - destroyVault never touches the Pro license (separate store).58 */59@Singleton60class VaultRepository @Inject constructor(61    private val store: VaultStorage,62) {63    private val mutex = Mutex()64    private val rng = SecureRandom()6566    private var derivedKey: ByteArray? = null67    private var rootKey: ByteArray? = null68    private var entryList: MutableList<VaultEntry> = mutableListOf()69    private var duressConfigured: Boolean = false70    private var recoveryWordHash: String? = null71    private var recoveryWordsSet: Boolean = false72    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */73    private var decoyMode: Boolean = false7475    private val _isUnlocked = MutableStateFlow(false)76    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()7778    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())79    /** Active (non-deleted) entryList. */80    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8182    private val _settings = MutableStateFlow(VaultSettings())83    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8485    fun hasVault(): Boolean = store.hasVault()8687    // ---- Codex (printed paper backup) -------------------------------------8889    /**90     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the91     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable92     * on any device that has the master password. Null when no vault exists.93     */94    fun codexVaultBlobBase64(): String? {95        val slotA = store.loadSlotA() ?: return null96        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""97        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))98    }99100    /**101     * Replace the local vault from a Codex blob, then unlock with [password].102     * Writes slot A (and a size-matched noise slot B) before unlocking so the103     * restored vault is at rest exactly as a freshly-created one would be.104     */105    /** A slot is stored either as a JSON string (browser) or inline object (Android). */106    private fun JsonElement?.asSlotJson(): String? = when {107        this == null || this is JsonNull -> null108        this is JsonPrimitive && isString -> content109        else -> toString()110    }111112    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {113        return try {114            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)115            // Parsed as JSON, not matched with a regex. The wrapper is116            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which117            // generates most real Codexes — writes each slot as a JSON118            // *string*, with "b" present. A regex looking for an object119            // literal under "a" at the very end of the wrapper matches120            // neither of those, so every browser-generated Codex failed here121            // with "invalid_codex_blob" after decoding perfectly.122            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject123                ?: return UnlockResult.Error("invalid_codex_blob")124            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")125            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")126            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")127            // Carry the real slot B across when the Codex has one — it holds a128            // duress vault, which noise would silently destroy.129            val slotB = wrapper["b"].asSlotJson()130            if (slotB != null) {131                store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))132            } else {133                writeMatchingNoiseSlotB(slotA)134            }135            store.stampLocalMutation()136            unlockLocked(password)137        } catch (e: Exception) {138            UnlockResult.Error(e.message ?: "restore_failed")139        }140    }141142    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {143        return try {144            val salt = VaultCrypto.generateSalt()145            val key = VaultCrypto.deriveKey(password, salt)146            entryList = mutableListOf()147            duressConfigured = false148            recoveryWordHash = null149            recoveryWordsSet = false150            decoyMode = false151            rootKey = generateRecoverableRootKey()152            val slotA = sealSlotA(key, salt)153            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")154            writeMatchingNoiseSlotB(slotA)155            store.stampLocalMutation()156            store.writeSettings(_settings.value)157            derivedKey = key158            publish()159            _isUnlocked.value = true160            CreateVaultResult.Success161        } catch (e: Exception) {162            CreateVaultResult.Error(e.message ?: "create_failed")163        }164    }165166    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {167        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")168        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {169            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")170        }171        var adoptedRootKey: ByteArray? = null172        var nextDerivedKey: ByteArray? = null173        var sessionRootKey: ByteArray? = null174        return try {175            adoptedRootKey = hexToRootKey(rootKeyHex)176            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {177                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")178            }179            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)180            val salt = VaultCrypto.generateSalt()181            nextDerivedKey = VaultCrypto.deriveKey(password, salt)182            sessionRootKey = adoptedRootKey.copyOf()183            val payload = VaultPayload(184                entries = emptyList(),185                duressConfigured = false,186                rootKeyB64 = b64(sessionRootKey),187                recoveryWordHash = null,188                recoveryWordsSet = false,189            )190            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))191            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)192            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")193            writeMatchingNoiseSlotB(slotA)194            store.stampLocalMutation()195            store.writeSettings(_settings.value)196197            val previousDerivedKey = derivedKey198            val previousRootKey = rootKey199            derivedKey = nextDerivedKey200            rootKey = sessionRootKey201            nextDerivedKey = null202            sessionRootKey = null203            entryList = mutableListOf()204            duressConfigured = false205            recoveryWordHash = null206            recoveryWordsSet = false207            decoyMode = false208            publish()209            _isUnlocked.value = true210            previousDerivedKey?.let(VaultCrypto::zero)211            previousRootKey?.let(VaultCrypto::zero)212            AdoptVaultResult.Success(vaultId)213        } catch (e: Exception) {214            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")215        } finally {216            adoptedRootKey?.let(VaultCrypto::zero)217            nextDerivedKey?.let(VaultCrypto::zero)218            sessionRootKey?.let(VaultCrypto::zero)219        }220    }221222    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }223224    /** Unlock body without acquiring the mutex — callers must already hold it. */225    private fun unlockLocked(password: String): UnlockResult {226        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")227        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))228        val payload = try {229            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))230        } catch (_: Exception) {231            VaultCrypto.zero(key)232            // LV-1: a vault sealed before NFC normalization with a non-NFC233            // password derives a different key now. If the supplied password is234            // not already in NFC form, retry with the raw (un-normalized) bytes.235            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {236                val legacy = tryLegacyRawUnlock(password, slotA)237                if (legacy != null) return legacy238            }239            return UnlockResult.WrongPassword240        }241        return adoptSlotA(key, payload)242    }243244    /**245     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw246     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed247     * under the NFC-normalized key — so every future unlock takes the normal248     * path. Returns null when the raw key also fails (genuinely wrong password).249     */250    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {251        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))252        val payload = try {253            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))254        } catch (_: Exception) {255            VaultCrypto.zero(rawKey)256            return null257        }258        // Heal: re-seal slot A under the NFC key (mirrors changePassword's259        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.260        val newSalt = VaultCrypto.generateSalt()261        val newKey = VaultCrypto.deriveKey(password, newSalt)262        val healed = try {263            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))264            val next = StoredVault(265                version = slotA.version,266                salt = b64(newSalt),267                blob = blob,268                recoveryEscrow = slotA.recoveryEscrow,269                rootKeyEscrow = slotA.rootKeyEscrow,270            )271            if (store.writeSlotA(next)) {272                writeMatchingNoiseSlotB(next)273                store.stampLocalMutation()274                true275            } else {276                false277            }278        } catch (_: Exception) {279            false280        }281        return if (healed) {282            VaultCrypto.zero(rawKey)283            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot284        } else {285            VaultCrypto.zero(newKey)286            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key287        }288    }289290    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */291    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {292        return try {293            derivedKey = key294            decoyMode = false295            entryList = payload.entries.toMutableList()296            duressConfigured = payload.duressConfigured297            recoveryWordHash = payload.recoveryWordHash298            recoveryWordsSet = payload.recoveryWordsSet299            _settings.value = store.loadSettings()300            ensureRootKey(payload)301            autoPurgeExpiredTrash()302            publish()303            _isUnlocked.value = true304            UnlockResult.Success305        } catch (e: Exception) {306            UnlockResult.Error(e.message ?: "unlock_failed")307        }308    }309310    // ---- Constant-time unlock (MF-5) --------------------------------------311312    /**313     * A slot-A unlock that has been derived but not yet adopted. Holds the live314     * Argon2id key and decoded payload so the caller can derive several candidates315     * concurrently and commit only the winner. The key is owned by this token until316     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.317     */318    class SlotAUnlock internal constructor(319        internal val key: ByteArray,320        internal val payload: VaultPayload,321    )322323    /**324     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.325     * Pure computation (no mutex), so it can run concurrently with other candidate326     * derivations before the caller decides which to commit (MF-5). Returns null on327     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).328     */329    fun tryUnlockSlotA(password: String): SlotAUnlock? {330        val slotA = store.loadSlotA() ?: return null331        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))332        return try {333            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))334            SlotAUnlock(key, payload)335        } catch (_: Exception) {336            VaultCrypto.zero(key)337            null338        }339    }340341    /** Adopt a previously-derived [SlotAUnlock] as the live session. */342    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {343        adoptSlotA(unlock.key, unlock.payload)344    }345346    /** Zero a derived [SlotAUnlock] that will not be adopted. */347    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)348349    // ---- Duress slot B (MF-6) ---------------------------------------------350351    /**352     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key353     * and decoded decoy payload so it can be derived concurrently with slot A and354     * only committed if the duress passphrase was the one entered (MF-5/MF-6).355     */356    class DuressUnlock internal constructor(357        internal val key: ByteArray,358        internal val payload: VaultPayload,359    )360361    /**362     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the363     * session. Success means the entered password is the duress passphrase that keys364     * slot B — indistinguishable from the real slot A at the lock screen. Pure365     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].366     * Returns null when no duress slot exists or the password is not the duress one.367     */368    fun tryUnlockDuress(password: String): DuressUnlock? {369        val slot = store.loadDecoySlot() ?: return null370        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))371        return try {372            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))373            DuressUnlock(key, payload)374        } catch (_: Exception) {375            VaultCrypto.zero(key)376            null377        }378    }379380    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */381    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {382        return try {383            derivedKey = unlock.key384            decoyMode = true385            entryList = unlock.payload.entries.toMutableList()386            duressConfigured = false387            recoveryWordHash = unlock.payload.recoveryWordHash388            recoveryWordsSet = unlock.payload.recoveryWordsSet389            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)390                      else VaultCrypto.generateRootKey()391            _settings.value = store.loadSettings()392            publish()393            _isUnlocked.value = true394            UnlockResult.Success395        } catch (e: Exception) {396            UnlockResult.Error(e.message ?: "decoy_unlock_failed")397        }398    }399400    /** Zero a derived [DuressUnlock] that will not be adopted. */401    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)402403    /**404     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an405     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The406     * passphrase is never stored — only its ability to decrypt slot B proves it.407     * Requires the real vault to be unlocked and not itself a decoy session.408     */409    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {410        if (derivedKey == null || decoyMode) return false411        // Reject a duress passphrase that also opens the real vault: it would make the412        // lock screen ambiguous (both slots would decrypt for the same input).413        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }414        return try {415            val salt = VaultCrypto.generateSalt()416            val key = VaultCrypto.deriveKey(duressPassphrase, salt)417            // An empty decoy vault with its own fresh sync root key — nothing real leaks.418            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))419            val plaintext = VaultCodec.encode(decoyPayload)420            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))421            VaultCrypto.zero(key)422            VaultCrypto.zero(plaintext)423            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))424            if (ok) {425                duressConfigured = true426                runCatching { persistSlotA(bumpVersion = false) }427            }428            ok429        } catch (_: Exception) {430            false431        }432    }433434    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */435    suspend fun clearDuress(): Boolean = mutex.withLock {436        store.clearDecoySlot()437        if (derivedKey != null && !decoyMode) {438            duressConfigured = false439            runCatching { persistSlotA(bumpVersion = false) }440        }441        true442    }443444    /** Unlock with an already-derived AES-256 key (biometric path). */445    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {446        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")447        return try {448            val payload = VaultCodec.decode(449                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),450            )451            derivedKey = key.copyOf()452            decoyMode = false453            entryList = payload.entries.toMutableList()454            duressConfigured = payload.duressConfigured455            recoveryWordHash = payload.recoveryWordHash456            recoveryWordsSet = payload.recoveryWordsSet457            _settings.value = store.loadSettings()458            ensureRootKey(payload)459            autoPurgeExpiredTrash()460            publish()461            _isUnlocked.value = true462            UnlockResult.Success463        } catch (_: Exception) {464            UnlockResult.WrongPassword465        }466    }467468    fun lock() {469        derivedKey?.let(VaultCrypto::zero)470        rootKey?.let(VaultCrypto::zero)471        derivedKey = null472        rootKey = null473        entryList = mutableListOf()474        duressConfigured = false475        recoveryWordHash = null476        recoveryWordsSet = false477        decoyMode = false478        _entries.value = emptyList()479        _isUnlocked.value = false480    }481482    @Volatile private var suppressNextBackgroundLock = false483484    /**485     * Call immediately before launching a system Activity (a SAF file486     * picker/save dialog, a share sheet) that briefly takes the whole process487     * out of the foreground as an expected part of an in-app flow — not a488     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the489     * two apart on its own (it fires for both), and without this, exporting or490     * importing a file silently locked and wiped the vault mid-flow the moment491     * the picker took over, before its result ever reached the app.492     */493    fun expectTransientBackground() {494        suppressNextBackgroundLock = true495    }496497    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */498    fun consumeTransientBackgroundSuppression(): Boolean {499        val suppressed = suppressNextBackgroundLock500        suppressNextBackgroundLock = false501        return suppressed502    }503504    // ---- Sync identity ----------------------------------------------------505506    /**507     * The vault's stable sync master key (a copy). Caller must zero it. Backs508     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry509     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].510     */511    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()512513    /** Stable per-vault id two paired devices must share to sync. Null when locked. */514    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }515516    /**517     * Browser parity (MV-8): rotate the sync identity by replacing the stable518     * root key. Entries remain under the master-password key, while sync519     * credentials, vault_id, and recovery words all change.520     */521    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {522        val key = derivedKey ?: return false523        if (decoyMode) return false524        val priorSlot = store.loadSlotA() ?: return false525        val priorRootKey = rootKey ?: return false526        val priorRecoveryHash = recoveryWordHash527        val priorRecoverySet = recoveryWordsSet528        val nextRootKey = generateRecoverableRootKey()529        val nextWords = rootKeyToWords(nextRootKey)530531        try {532            takeVaultSnapshot("local_save")533            rootKey = nextRootKey534            recoveryWordHash = recoveryWordsHash(nextWords)535            recoveryWordsSet = false536537            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)538                ?: throw IllegalStateException("recovery_escrow_failed")539            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)540                ?: throw IllegalStateException("root_escrow_failed")541            val plaintext = VaultCodec.encode(currentPayload())542            val blob = try {543                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))544            } finally {545                VaultCrypto.zero(plaintext)546            }547            val nextSlot = priorSlot.copy(548                blob = blob,549                recoveryEscrow = nextRecoveryEscrow,550                rootKeyEscrow = nextRootKeyEscrow,551            )552            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")553            try {554                writeMatchingNoiseSlotB(nextSlot)555                store.stampLocalMutation()556            } catch (e: Exception) {557                store.writeSlotA(priorSlot)558                throw e559            }560            VaultCrypto.zero(priorRootKey)561            publish()562            true563        } catch (_: Exception) {564            rootKey = priorRootKey565            recoveryWordHash = priorRecoveryHash566            recoveryWordsSet = priorRecoverySet567            VaultCrypto.zero(nextRootKey)568            store.writeSlotA(priorSlot)569            false570        }571    }572573    /**574     * A copy of the live Argon2id-derived vault key, for biometric escrow only575     * (wrapped under a hardware KeyStore key on biometric enable). Null when576     * locked. Caller must [VaultCrypto.zero] it after use.577     */578    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()579580    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {581        val slotA = store.loadSlotA() ?: return false582        return try {583            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))584            val payload = VaultCodec.decode(585                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),586            )587            val newSalt = VaultCrypto.generateSalt()588            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)589            // Re-seal the existing payload under the new key/salt.590            val plaintext = VaultCodec.encode(payload)591            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))592            // The escrow seals the slot-A PLAINTEXT, which a password change does not593            // alter — only the wrapping changes — so carry it forward; rebuilding the594            // StoredVault from scratch would silently drop it.595            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key596            // content that a password change does not alter — only the slot-A597            // wrapping rotates. The root-key escrow staying stable across password598            // changes matches the browser (`vault.ts:363-364`).599            val next = StoredVault(600                version = slotA.version,601                salt = b64(newSalt),602                blob = blob,603                recoveryEscrow = slotA.recoveryEscrow,604                rootKeyEscrow = slotA.rootKeyEscrow,605            )606            if (!store.writeSlotA(next)) return false607            writeMatchingNoiseSlotB(next)608            store.stampLocalMutation()609            VaultCrypto.zero(oldKey)610            derivedKey?.let(VaultCrypto::zero)611            derivedKey = newKey612            true613        } catch (_: Exception) {614            false615        }616    }617618    /**619     * Irreversible wipe. Locks the session and clears every vault key. The Pro620     * license is in a separate store and is preserved by construction.621     */622    suspend fun destroyVault() = mutex.withLock {623        lock()624        store.clearVault()625        store.clearDecoySlot()626    }627628    // ---- Entry CRUD -------------------------------------------------------629630    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {631        requireUnlocked()632        val stamp = VaultCrypto.monotonicEntryTimestamp()633        val now = System.currentTimeMillis()634        val entry = withDerivedLast4(draft.copy(635            id = draft.id.ifBlank { UUID.randomUUID().toString() },636            createdAt = if (draft.createdAt > 0) draft.createdAt else now,637            modifiedAt = stamp,638            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,639        ))640        val rollback = entryList.toMutableList()641        entryList.add(entry)642        persistOrRollback(rollback)643        entry644    }645646    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {647        requireUnlocked()648        val idx = entryList.indexOfFirst { it.id == patch.id }649        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")650        val current = entryList[idx]651        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)652        // Only refresh passwordChangedAt when the password value actually changes.653        val passwordChanged = patch.password != null && patch.password != current.password654        // Capture the outgoing password (newest first) so the user can reveal/copy/655        // restore it later. History is computed authoritatively from `current`, not656        // the patch — updateEntry is a full-entry replace, so trusting patch's value657        // would let a stale form clobber the real history. Record the age the old658        // password carried, skip an empty prior, and skip an immediate exact dupe659        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.660        val priorPassword = current.password661        val shouldCapture = passwordChanged &&662            !priorPassword.isNullOrEmpty() &&663            current.passwordHistory?.firstOrNull()?.password != priorPassword664        val nextHistory = if (shouldCapture) {665            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +666                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)667        } else {668            current.passwordHistory669        }670        val updated = withDerivedLast4(patch.copy(671            createdAt = current.createdAt,672            modifiedAt = stamp,673            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,674            passwordHistory = nextHistory,675        ))676        val rollback = entryList.toMutableList()677        entryList[idx] = updated678        persistOrRollback(rollback)679        updated680    }681682    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */683    suspend fun deleteEntry(id: String) = mutex.withLock {684        requireUnlocked()685        val idx = entryList.indexOfFirst { it.id == id }686        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")687        val current = entryList[idx]688        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)689        val rollback = entryList.toMutableList()690        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)691        persistOrRollback(rollback)692    }693694    suspend fun restoreEntry(id: String) = mutex.withLock {695        requireUnlocked()696        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }697        if (idx == -1) throw IllegalStateException("deleted entry not found")698        val current = entryList[idx]699        val rollback = entryList.toMutableList()700        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))701        persistOrRollback(rollback)702    }703704    /** Permanent delete — no tombstone, no recovery. */705    suspend fun purgeEntry(id: String) = mutex.withLock {706        requireUnlocked()707        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }708        if (idx == -1) throw IllegalStateException("deleted entry not found")709        val rollback = entryList.toMutableList()710        entryList.removeAt(idx)711        persistOrRollback(rollback)712    }713714    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */715    suspend fun purgeDeletedEntries(): Int = mutex.withLock {716        requireUnlocked()717        val rollback = entryList.toMutableList()718        val before = entryList.size719        entryList.removeAll { it.deletedAt != null }720        val removed = before - entryList.size721        if (removed > 0) persistOrRollback(rollback)722        removed723    }724725    /** Browser parity: number of active entries that dedupe would move to trash. */726    suspend fun countDuplicateEntries(): Int = mutex.withLock {727        requireUnlocked()728        countDuplicateEntriesLocked()729    }730731    /**732     * Browser parity: soft-delete duplicate entries, keeping the most recently733     * modified member of each group. Refuse in decoy mode so the duress vault is734     * not reshaped into a forensically distinct state.735     */736    suspend fun deduplicateEntries(): Int = mutex.withLock {737        requireUnlocked()738        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")739        val active = entryList.filter { it.deletedAt == null }740        val keepIds = mutableSetOf<String>()741        active.groupBy { it.duplicateKey() }.values.forEach { group ->742            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id743        }744745        val rollback = entryList.toMutableList()746        var removed = 0747        entryList = entryList.map { entry ->748            if (entry.deletedAt == null && entry.id !in keepIds) {749                removed++750                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)751                entry.copy(deletedAt = stamp, modifiedAt = stamp)752            } else {753                entry754            }755        }.toMutableList()756        if (removed > 0) persistOrRollback(rollback)757        removed758    }759760    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }761    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }762    fun getDeletedEntries(): List<VaultEntry> =763        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }764765    /** Active entries, optionally narrowed to those carrying [tagFilter]. */766    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {767        val active = entryList.filter { it.deletedAt == null }768        if (tagFilter == null) return active769        val needle = tagFilter.trim().lowercase()770        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }771    }772773    /** Sorted, de-duplicated tags across all active entries. */774    fun getDistinctTags(): List<String> =775        entryList.asSequence()776            .filter { it.deletedAt == null }777            .flatMap { it.tags?.asSequence() ?: emptySequence() }778            .map { it.trim().lowercase() }779            .filter { it.isNotEmpty() }780            .distinct()781            .sorted()782            .toList()783784    /**785     * Distinct non-blank values for an identity field across active entries,786     * most-frequently-used first. Supports "username" and "email"; unknown field787     * names return an empty list. Values keep original casing (deduped788     * case-insensitively) so a suggestion matches what the user typed.789     */790    fun getDistinctFieldValues(field: String): List<String> {791        val select: (VaultEntry) -> String? = when (field) {792            "username" -> { e -> e.username }793            "email" -> { e -> e.email }794            else -> return emptyList()795        }796        return entryList.asSequence()797            .filter { it.deletedAt == null }798            .mapNotNull { select(it)?.trim() }799            .filter { it.isNotEmpty() }800            .groupingBy { it.lowercase() }801            .aggregate { _, acc: Pair<String, Int>?, value, _ ->802                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)803            }804            .values805            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })806            .map { it.first }807    }808809    fun getTombstones(): List<Tombstone> =810        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }811    fun getVaultVersion(): Long = store.version()812    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots813814    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {815        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false816        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false817        takeVaultSnapshot("restore")818        if (!store.writeSlotA(restoredA)) return false819        if (!store.writeSlotB(snapshot.vault_b)) return false820        store.stampLocalMutation()821        lock()822        true823    }824825    // ---- Sync apply (does NOT advance the version) ------------------------826827    /**828     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists829     * WITHOUT bumping the version counter, so a sync round cannot cascade into830     * another outbound sync. Returns the number of locally-applied changes.831     */832    suspend fun applyMergedRemoteEntries(833        remoteEntries: List<VaultEntry>,834        remoteTombstones: List<Tombstone>,835    ): Int = mutex.withLock {836        requireUnlocked()837        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }838        var applied = 0839        val byId = entryList.associateBy { it.id }.toMutableMap()840841        for (remote in remoteEntries) {842            val tomb = tombMap[remote.id]843            if (tomb != null && tomb >= remote.modifiedAt) continue844            val local = byId[remote.id]845            if (local == null) {846                entryList.add(remote); byId[remote.id] = remote; applied++847            } else if (remote.modifiedAt > local.modifiedAt) {848                val i = entryList.indexOfFirst { it.id == local.id }849                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }850            }851        }852853        for ((id, deletedAt) in tombMap) {854            val local = byId[id] ?: continue855            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue856            if (deletedAt < local.modifiedAt) continue857            val i = entryList.indexOfFirst { it.id == id }858            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }859        }860861        if (applied > 0) {862            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")863            publish()864        }865        applied866    }867868    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {869        _settings.value = settings870        store.writeSettings(settings)871    }872873    // ---- Recovery words (vault metadata) ----------------------------------874875    /** True once the user has confirmed their recovery words for this vault. */876    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet877878    /**879     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.880     * Persisted without bumping the version — recovery metadata is local-only and881     * is not an entry mutation paired devices should sync against.882     */883    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {884        requireUnlocked()885        recoveryWordHash = recoveryWordsHash(words)886        recoveryWordsSet = true887        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow888        // (cross-platform recovery of the sync root key) and Android's whole-payload889        // escrow (offline forgot-password restore of entries). Both derive their key890        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.891        runCatching {892            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))893        }.getOrDefault(false)894    }895896    /** Verify entered words against the stored recovery hash. */897    fun verifyRecoveryWords(words: List<String>): Boolean {898        val stored = recoveryWordHash ?: return false899        return stored == recoveryWordsHash(words)900    }901902    // ---- Recovery escrow (forgot-password) --------------------------------903904    /** True at rest if this vault has any recovery escrow the recovery flow can use. */905    fun hasRecoveryEscrow(): Boolean =906        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false907908    /**909     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of910     * the master password. On success the slot-A plaintext sealed in the escrow is911     * adopted as the live session (entries, rootKey, settings), but no Argon2id912     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a913     * new password and finish unlocking. Returns false when there is no escrow, the914     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).915     *916     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no917     * detached vault key to return here — slot A is wrapped under the rotating918     * Argon2id key — so this opens the escrowed payload directly.919     */920    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {921        val slotA = store.loadSlotA() ?: return false922        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false923924        // Prefer Android's whole-payload escrow: a full offline restore of entries,925        // root key, and settings. Present for Android-created vaults.926        slotA.recoveryEscrow?.let { escrow ->927            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let928            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))929            val keyBytes = recoveryKey.encoded930            try {931                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)932                val payload = VaultCodec.decode(plaintext)933                decoyMode = false934                entryList = payload.entries.toMutableList()935                duressConfigured = payload.duressConfigured936                recoveryWordHash = payload.recoveryWordHash937                recoveryWordsSet = payload.recoveryWordsSet938                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)939                          else generateRecoverableRootKey()940                _settings.value = store.loadSettings()941                VaultCrypto.zero(plaintext)942                return true943            } catch (_: Exception) {944                // Wrong words against this escrow — fall through to the root-key escrow.945            } finally {946                VaultCrypto.zero(material)947                VaultCrypto.zero(keyBytes)948            }949        }950951        // Browser-format vault (only the root-key escrow): recover the 32-byte root952        // key (sync identity) and reset the password; entries return via sync. This953        // is exactly the browser's recovery semantics (`messages.ts:312-347`).954        slotA.rootKeyEscrow?.let { escrow ->955            val rk = unsealRootKeyEscrow(words, escrow) ?: return false956            decoyMode = false957            entryList = mutableListOf()958            duressConfigured = false959            recoveryWordHash = recoveryWordsHash(words)960            recoveryWordsSet = true961            rootKey = rk962            _settings.value = store.loadSettings()963            return true964        }965        return false966    }967968    /**969     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:970     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same971     * words, mark the session fully unlocked, and publish. Returns false if called972     * without a prior successful recovery unlock (no in-memory root key).973     *974     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`975     * into one step, since there is no detached vault key here.976     */977    suspend fun resetPasswordWithRecoveryWords(978        words: List<String>,979        newPassword: String,980    ): Boolean = mutex.withLock {981        if (rootKey == null) return false // must have recovered first982        return try {983            val newSalt = VaultCrypto.generateSalt()984            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)985            val plaintext = VaultCodec.encode(currentPayload())986            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))987            val prior = store.loadSlotA()988            // Rebuild both escrows against the recovered session so their salts rotate989            // with the password reset; fall back to the prior escrows if rebuild fails990            // (e.g. a checksum-invalid phrase). The same 12 words keep working.991            val valid = words.size == 12 && findInvalidWords(words).isEmpty()992            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow993            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow994            val next = StoredVault(995                version = prior?.version ?: 1,996                salt = b64(newSalt),997                blob = blob,998                recoveryEscrow = freshRecovery,999                rootKeyEscrow = freshRootKey,1000            )1001            if (!store.writeSlotA(next)) return false1002            writeMatchingNoiseSlotB(next)1003            store.stampLocalMutation()1004            derivedKey?.let(VaultCrypto::zero)1005            derivedKey = newKey1006            VaultCrypto.zero(plaintext)1007            publish()1008            _isUnlocked.value = true1009            true1010        } catch (_: Exception) {1011            false1012        }1013    }10141015    /**1016     * Seal the current slot-A plaintext under a key derived from the 12 BIP391017     * recovery [words] (Android offline-restore escrow). IKM is the browser's1018     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1019     * Returns null for a checksum-invalid phrase.1020     */1021    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1022        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1023        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1024        val salt = VaultCrypto.generateSalt()1025        val recoveryKey = deriveRecoveryKey(material, salt)1026        val keyBytes = recoveryKey.encoded1027        val plaintext = VaultCodec.encode(currentPayload())1028        return try {1029            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1030            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1031        } finally {1032            VaultCrypto.zero(material)1033            VaultCrypto.zero(keyBytes)1034            VaultCrypto.zero(plaintext)1035        }1036    }10371038    /**1039     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1040     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1041     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1042     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1043     * the same root key on the browser. Returns null with no in-memory root key or1044     * a checksum-invalid phrase.1045     */1046    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1047        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1048        val rk = rootKey ?: return null1049        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1050        val salt = VaultCrypto.generateSalt()1051        val recoveryKey = deriveRecoveryKey(material, salt)1052        val keyBytes = recoveryKey.encoded1053        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1054        return try {1055            // combined = nonce(12) || ciphertext || tag(16)1056            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1057            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1058            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1059            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1060            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1061            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1062        } finally {1063            VaultCrypto.zero(material)1064            VaultCrypto.zero(keyBytes)1065            VaultCrypto.zero(plaintext)1066        }1067    }10681069    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1070    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1071        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1072        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1073        val keyBytes = recoveryKey.encoded1074        return try {1075            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1076            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1077            hexToRootKey(String(pt, Charsets.US_ASCII))1078        } catch (_: Exception) {1079            null1080        } finally {1081            VaultCrypto.zero(material)1082            VaultCrypto.zero(keyBytes)1083        }1084    }10851086    /**1087     * Re-seal slot A from the current in-memory state under the live key, carrying1088     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1089     * in decoy mode (recovery is never offered for the decoy) and persists without1090     * a version bump, like the recovery-words metadata write it replaces.1091     */1092    private fun persistSlotAWithEscrows(1093        recoveryEscrow: RecoveryEscrow?,1094        rootKeyEscrow: RootKeyEscrow?,1095    ): Boolean {1096        val key = derivedKey ?: throw IllegalStateException("vault_locked")1097        if (decoyMode) return false1098        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1099        val plaintext = VaultCodec.encode(currentPayload())1100        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1101        val next = prior.copy(1102            blob = blob,1103            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1104            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1105        )1106        if (!store.writeSlotA(next)) return false1107        try {1108            writeMatchingNoiseSlotB(next)1109        } catch (e: Exception) {1110            store.writeSlotA(prior)1111            throw e1112        }1113        return true1114    }11151116    // ---- Internals --------------------------------------------------------11171118    private fun requireUnlocked() {1119        if (derivedKey == null) throw IllegalStateException("vault_locked")1120    }11211122    private fun countDuplicateEntriesLocked(): Int {1123        val seen = mutableMapOf<String, VaultEntry>()1124        var duplicates = 01125        for (entry in entryList.filter { it.deletedAt == null }) {1126            val key = entry.duplicateKey()1127            val existing = seen[key]1128            if (existing == null) {1129                seen[key] = entry1130            } else {1131                duplicates++1132                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1133                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1134                if (entryStamp > existingStamp) seen[key] = entry1135            }1136        }1137        return duplicates1138    }11391140    private fun VaultEntry.duplicateKey(): String {1141        if (type == "note") return "note|${title.lowercase()}"1142        val passkeyData = passkey1143        if (passkeyData != null) {1144            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1145        }1146        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1147    }11481149    private fun takeVaultSnapshot(reason: String) {1150        runCatching {1151            val slotA = store.loadSlotA() ?: return1152            val vaultA = VaultCodec.encodeSlot(slotA)1153            val vaultB = store.loadSlotB() ?: slotA.blob1154            val history = store.loadVaultHistory()1155            val latest = history.snapshots.firstOrNull()1156            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1157            val entryCount = if (derivedKey != null && !decoyMode) {1158                bucketEntryCount(entryList.count { it.deletedAt == null })1159            } else {1160                null1161            }1162            val snapshot = VaultSnapshot(1163                id = UUID.randomUUID().toString(),1164                savedAt = System.currentTimeMillis(),1165                reason = reason,1166                vault_a = vaultA,1167                vault_b = vaultB,1168                entryCount = entryCount,1169            )1170            store.writeVaultHistory(1171                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1172            )1173        }1174    }11751176    private fun bucketEntryCount(n: Int): Int = when {1177        n <= 0 -> 01178        n <= 10 -> 101179        n <= 25 -> 251180        n <= 50 -> 501181        n <= 100 -> 1001182        n <= 250 -> 2501183        n <= 500 -> 5001184        else -> ((n + 499) / 500) * 5001185    }11861187    /**1188     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1189     * so it is stored explicitly and survives the sync wire format rather than being1190     * re-derived at render time. Non-card entries pass through unchanged.1191     */1192    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1193        if (!entry.isCard()) return entry1194        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1195        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1196    }11971198    /**1199     * On unlock, hard-delete trashed entries older than the retention window.1200     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1201     * (the app's "Never" option), so nothing is purged. Persists without a1202     * version bump and without touching the publish state (caller publishes).1203     */1204    private fun autoPurgeExpiredTrash() {1205        val days = _settings.value.trashRetentionDays ?: return1206        if (days <= 0) return1207        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1208        val before = entryList.size1209        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1210        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1211    }12121213    /** The slot-A plaintext envelope for the current in-memory session. */1214    private fun currentPayload(): VaultPayload =1215        VaultPayload(1216            entries = entryList.toList(),1217            duressConfigured = duressConfigured,1218            rootKeyB64 = rootKey?.let(::b64) ?: "",1219            recoveryWordHash = recoveryWordHash,1220            recoveryWordsSet = recoveryWordsSet,1221        )12221223    /**1224     * Adopt the payload's root key, lazily minting one for legacy payloads.1225     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1226     * a root key is not a mutation paired devices should sync against.1227     */1228    private fun ensureRootKey(payload: VaultPayload) {1229        if (payload.rootKeyB64.isNotBlank()) {1230            rootKey = unb64(payload.rootKeyB64)1231            return1232        }1233        rootKey = generateRecoverableRootKey()1234        runCatching { persistSlotA(bumpVersion = false) }1235    }12361237    private fun publish() {1238        _entries.value = entryList.filter { it.deletedAt == null }1239    }12401241    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1242        val plaintext = VaultCodec.encode(currentPayload())1243        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1244        return StoredVault(version = 1, salt = b64(salt), blob = blob)1245    }12461247    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1248        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1249        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1250        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1251        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1252        val noise = ByteArray(approxBytes).also(rng::nextBytes)1253        store.writeSlotB(b64(noise))1254    }12551256    /** Re-seal slot A from the current in-memory state under the live key. */1257    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1258        val key = derivedKey ?: throw IllegalStateException("vault_locked")1259        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1260        if (decoyMode) {1261            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1262            val plaintext = VaultCodec.encode(currentPayload())1263            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1264            return store.writeDecoySlot(prior.copy(blob = blob))1265        }1266        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1267        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1268        val plaintext = VaultCodec.encode(currentPayload())1269        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1270        val next = prior.copy(blob = blob)1271        if (!store.writeSlotA(next)) return false1272        // Slot A committed — keep slot B size-matched, then advance the version1273        // for local writes only. Restore slot A if a later step fails.1274        try {1275            writeMatchingNoiseSlotB(next)1276            if (bumpVersion) store.stampLocalMutation()1277        } catch (e: Exception) {1278            store.writeSlotA(prior)1279            throw e1280        }1281        return true1282    }12831284    /** Persist a local mutation, rolling the in-memory list back on failure. */1285    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1286        try {1287            if (!persistSlotA(bumpVersion = true)) {1288                entryList = rollback1289                throw IllegalStateException("storage_write_failed")1290            }1291        } catch (e: Exception) {1292            entryList = rollback1293            throw e1294        }1295        publish()1296    }1297}