VaultRepository.kt

58.5 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    /**647     * Insert many entries with a single encrypt-and-write at the end.648     *649     * Importing a real vault through [addEntry] was quadratic: every entry took650     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry651     * restore did 806 full-vault writes and hung the app long enough to ANR.652     * Rolls back to the pre-import list if the one write fails, matching653     * [addEntry]'s all-or-nothing behaviour.654     */655    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {656        requireUnlocked()657        if (drafts.isEmpty()) return 0658        val rollback = entryList.toMutableList()659        val now = System.currentTimeMillis()660        for (draft in drafts) {661            val stamp = VaultCrypto.monotonicEntryTimestamp()662            entryList.add(663                withDerivedLast4(664                    draft.copy(665                        id = draft.id.ifBlank { UUID.randomUUID().toString() },666                        createdAt = if (draft.createdAt > 0) draft.createdAt else now,667                        modifiedAt = stamp,668                        passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,669                    ),670                ),671            )672        }673        persistOrRollback(rollback)674        return drafts.size675    }676677    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {678        requireUnlocked()679        val idx = entryList.indexOfFirst { it.id == patch.id }680        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")681        val current = entryList[idx]682        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)683        // Only refresh passwordChangedAt when the password value actually changes.684        val passwordChanged = patch.password != null && patch.password != current.password685        // Capture the outgoing password (newest first) so the user can reveal/copy/686        // restore it later. History is computed authoritatively from `current`, not687        // the patch — updateEntry is a full-entry replace, so trusting patch's value688        // would let a stale form clobber the real history. Record the age the old689        // password carried, skip an empty prior, and skip an immediate exact dupe690        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.691        val priorPassword = current.password692        val shouldCapture = passwordChanged &&693            !priorPassword.isNullOrEmpty() &&694            current.passwordHistory?.firstOrNull()?.password != priorPassword695        val nextHistory = if (shouldCapture) {696            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +697                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)698        } else {699            current.passwordHistory700        }701        val updated = withDerivedLast4(patch.copy(702            createdAt = current.createdAt,703            modifiedAt = stamp,704            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,705            passwordHistory = nextHistory,706        ))707        val rollback = entryList.toMutableList()708        entryList[idx] = updated709        persistOrRollback(rollback)710        updated711    }712713    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */714    suspend fun deleteEntry(id: String) = mutex.withLock {715        requireUnlocked()716        val idx = entryList.indexOfFirst { it.id == id }717        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")718        val current = entryList[idx]719        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)720        val rollback = entryList.toMutableList()721        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)722        persistOrRollback(rollback)723    }724725    suspend fun restoreEntry(id: String) = mutex.withLock {726        requireUnlocked()727        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }728        if (idx == -1) throw IllegalStateException("deleted entry not found")729        val current = entryList[idx]730        val rollback = entryList.toMutableList()731        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))732        persistOrRollback(rollback)733    }734735    /** Permanent delete — no tombstone, no recovery. */736    suspend fun purgeEntry(id: String) = mutex.withLock {737        requireUnlocked()738        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }739        if (idx == -1) throw IllegalStateException("deleted entry not found")740        val rollback = entryList.toMutableList()741        entryList.removeAt(idx)742        persistOrRollback(rollback)743    }744745    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */746    suspend fun purgeDeletedEntries(): Int = mutex.withLock {747        requireUnlocked()748        val rollback = entryList.toMutableList()749        val before = entryList.size750        entryList.removeAll { it.deletedAt != null }751        val removed = before - entryList.size752        if (removed > 0) persistOrRollback(rollback)753        removed754    }755756    /** Browser parity: number of active entries that dedupe would move to trash. */757    suspend fun countDuplicateEntries(): Int = mutex.withLock {758        requireUnlocked()759        countDuplicateEntriesLocked()760    }761762    /**763     * Browser parity: soft-delete duplicate entries, keeping the most recently764     * modified member of each group. Refuse in decoy mode so the duress vault is765     * not reshaped into a forensically distinct state.766     */767    suspend fun deduplicateEntries(): Int = mutex.withLock {768        requireUnlocked()769        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")770        val active = entryList.filter { it.deletedAt == null }771        val keepIds = mutableSetOf<String>()772        active.groupBy { it.duplicateKey() }.values.forEach { group ->773            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id774        }775776        val rollback = entryList.toMutableList()777        var removed = 0778        entryList = entryList.map { entry ->779            if (entry.deletedAt == null && entry.id !in keepIds) {780                removed++781                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)782                entry.copy(deletedAt = stamp, modifiedAt = stamp)783            } else {784                entry785            }786        }.toMutableList()787        if (removed > 0) persistOrRollback(rollback)788        removed789    }790791    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }792    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }793    fun getDeletedEntries(): List<VaultEntry> =794        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }795796    /** Active entries, optionally narrowed to those carrying [tagFilter]. */797    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {798        val active = entryList.filter { it.deletedAt == null }799        if (tagFilter == null) return active800        val needle = tagFilter.trim().lowercase()801        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }802    }803804    /** Sorted, de-duplicated tags across all active entries. */805    fun getDistinctTags(): List<String> =806        entryList.asSequence()807            .filter { it.deletedAt == null }808            .flatMap { it.tags?.asSequence() ?: emptySequence() }809            .map { it.trim().lowercase() }810            .filter { it.isNotEmpty() }811            .distinct()812            .sorted()813            .toList()814815    /**816     * Distinct non-blank values for an identity field across active entries,817     * most-frequently-used first. Supports "username" and "email"; unknown field818     * names return an empty list. Values keep original casing (deduped819     * case-insensitively) so a suggestion matches what the user typed.820     */821    fun getDistinctFieldValues(field: String): List<String> {822        val select: (VaultEntry) -> String? = when (field) {823            "username" -> { e -> e.username }824            "email" -> { e -> e.email }825            else -> return emptyList()826        }827        return entryList.asSequence()828            .filter { it.deletedAt == null }829            .mapNotNull { select(it)?.trim() }830            .filter { it.isNotEmpty() }831            .groupingBy { it.lowercase() }832            .aggregate { _, acc: Pair<String, Int>?, value, _ ->833                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)834            }835            .values836            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })837            .map { it.first }838    }839840    fun getTombstones(): List<Tombstone> =841        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }842    fun getVaultVersion(): Long = store.version()843    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots844845    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {846        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false847        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false848        takeVaultSnapshot("restore")849        if (!store.writeSlotA(restoredA)) return false850        if (!store.writeSlotB(snapshot.vault_b)) return false851        store.stampLocalMutation()852        lock()853        true854    }855856    // ---- Sync apply (does NOT advance the version) ------------------------857858    /**859     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists860     * WITHOUT bumping the version counter, so a sync round cannot cascade into861     * another outbound sync. Returns the number of locally-applied changes.862     */863    suspend fun applyMergedRemoteEntries(864        remoteEntries: List<VaultEntry>,865        remoteTombstones: List<Tombstone>,866    ): Int = mutex.withLock {867        requireUnlocked()868        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }869        var applied = 0870        val byId = entryList.associateBy { it.id }.toMutableMap()871872        for (remote in remoteEntries) {873            val tomb = tombMap[remote.id]874            if (tomb != null && tomb >= remote.modifiedAt) continue875            val local = byId[remote.id]876            if (local == null) {877                entryList.add(remote); byId[remote.id] = remote; applied++878            } else if (remote.modifiedAt > local.modifiedAt) {879                val i = entryList.indexOfFirst { it.id == local.id }880                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }881            }882        }883884        for ((id, deletedAt) in tombMap) {885            val local = byId[id] ?: continue886            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue887            if (deletedAt < local.modifiedAt) continue888            val i = entryList.indexOfFirst { it.id == id }889            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }890        }891892        if (applied > 0) {893            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")894            publish()895        }896        applied897    }898899    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {900        _settings.value = settings901        store.writeSettings(settings)902    }903904    // ---- Recovery words (vault metadata) ----------------------------------905906    /** True once the user has confirmed their recovery words for this vault. */907    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet908909    /**910     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.911     * Persisted without bumping the version — recovery metadata is local-only and912     * is not an entry mutation paired devices should sync against.913     */914    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {915        requireUnlocked()916        recoveryWordHash = recoveryWordsHash(words)917        recoveryWordsSet = true918        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow919        // (cross-platform recovery of the sync root key) and Android's whole-payload920        // escrow (offline forgot-password restore of entries). Both derive their key921        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.922        runCatching {923            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))924        }.getOrDefault(false)925    }926927    /** Verify entered words against the stored recovery hash. */928    fun verifyRecoveryWords(words: List<String>): Boolean {929        val stored = recoveryWordHash ?: return false930        return stored == recoveryWordsHash(words)931    }932933    // ---- Recovery escrow (forgot-password) --------------------------------934935    /** True at rest if this vault has any recovery escrow the recovery flow can use. */936    fun hasRecoveryEscrow(): Boolean =937        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false938939    /**940     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of941     * the master password. On success the slot-A plaintext sealed in the escrow is942     * adopted as the live session (entries, rootKey, settings), but no Argon2id943     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a944     * new password and finish unlocking. Returns false when there is no escrow, the945     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).946     *947     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no948     * detached vault key to return here — slot A is wrapped under the rotating949     * Argon2id key — so this opens the escrowed payload directly.950     */951    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {952        val slotA = store.loadSlotA() ?: return false953        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false954955        // Prefer Android's whole-payload escrow: a full offline restore of entries,956        // root key, and settings. Present for Android-created vaults.957        slotA.recoveryEscrow?.let { escrow ->958            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let959            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))960            val keyBytes = recoveryKey.encoded961            try {962                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)963                val payload = VaultCodec.decode(plaintext)964                decoyMode = false965                entryList = payload.entries.toMutableList()966                duressConfigured = payload.duressConfigured967                recoveryWordHash = payload.recoveryWordHash968                recoveryWordsSet = payload.recoveryWordsSet969                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)970                          else generateRecoverableRootKey()971                _settings.value = store.loadSettings()972                VaultCrypto.zero(plaintext)973                return true974            } catch (_: Exception) {975                // Wrong words against this escrow — fall through to the root-key escrow.976            } finally {977                VaultCrypto.zero(material)978                VaultCrypto.zero(keyBytes)979            }980        }981982        // Browser-format vault (only the root-key escrow): recover the 32-byte root983        // key (sync identity) and reset the password; entries return via sync. This984        // is exactly the browser's recovery semantics (`messages.ts:312-347`).985        slotA.rootKeyEscrow?.let { escrow ->986            val rk = unsealRootKeyEscrow(words, escrow) ?: return false987            decoyMode = false988            entryList = mutableListOf()989            duressConfigured = false990            recoveryWordHash = recoveryWordsHash(words)991            recoveryWordsSet = true992            rootKey = rk993            _settings.value = store.loadSettings()994            return true995        }996        return false997    }998999    /**1000     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1001     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1002     * words, mark the session fully unlocked, and publish. Returns false if called1003     * without a prior successful recovery unlock (no in-memory root key).1004     *1005     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1006     * into one step, since there is no detached vault key here.1007     */1008    suspend fun resetPasswordWithRecoveryWords(1009        words: List<String>,1010        newPassword: String,1011    ): Boolean = mutex.withLock {1012        if (rootKey == null) return false // must have recovered first1013        return try {1014            val newSalt = VaultCrypto.generateSalt()1015            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1016            val plaintext = VaultCodec.encode(currentPayload())1017            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1018            val prior = store.loadSlotA()1019            // Rebuild both escrows against the recovered session so their salts rotate1020            // with the password reset; fall back to the prior escrows if rebuild fails1021            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1022            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1023            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1024            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1025            val next = StoredVault(1026                version = prior?.version ?: 1,1027                salt = b64(newSalt),1028                blob = blob,1029                recoveryEscrow = freshRecovery,1030                rootKeyEscrow = freshRootKey,1031            )1032            if (!store.writeSlotA(next)) return false1033            writeMatchingNoiseSlotB(next)1034            store.stampLocalMutation()1035            derivedKey?.let(VaultCrypto::zero)1036            derivedKey = newKey1037            VaultCrypto.zero(plaintext)1038            publish()1039            _isUnlocked.value = true1040            true1041        } catch (_: Exception) {1042            false1043        }1044    }10451046    /**1047     * Seal the current slot-A plaintext under a key derived from the 12 BIP391048     * recovery [words] (Android offline-restore escrow). IKM is the browser's1049     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1050     * Returns null for a checksum-invalid phrase.1051     */1052    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1053        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1054        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1055        val salt = VaultCrypto.generateSalt()1056        val recoveryKey = deriveRecoveryKey(material, salt)1057        val keyBytes = recoveryKey.encoded1058        val plaintext = VaultCodec.encode(currentPayload())1059        return try {1060            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1061            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1062        } finally {1063            VaultCrypto.zero(material)1064            VaultCrypto.zero(keyBytes)1065            VaultCrypto.zero(plaintext)1066        }1067    }10681069    /**1070     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1071     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1072     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1073     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1074     * the same root key on the browser. Returns null with no in-memory root key or1075     * a checksum-invalid phrase.1076     */1077    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1078        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1079        val rk = rootKey ?: return null1080        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1081        val salt = VaultCrypto.generateSalt()1082        val recoveryKey = deriveRecoveryKey(material, salt)1083        val keyBytes = recoveryKey.encoded1084        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1085        return try {1086            // combined = nonce(12) || ciphertext || tag(16)1087            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1088            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1089            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1090            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1091            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1092            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1093        } finally {1094            VaultCrypto.zero(material)1095            VaultCrypto.zero(keyBytes)1096            VaultCrypto.zero(plaintext)1097        }1098    }10991100    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1101    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1102        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1103        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1104        val keyBytes = recoveryKey.encoded1105        return try {1106            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1107            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1108            hexToRootKey(String(pt, Charsets.US_ASCII))1109        } catch (_: Exception) {1110            null1111        } finally {1112            VaultCrypto.zero(material)1113            VaultCrypto.zero(keyBytes)1114        }1115    }11161117    /**1118     * Re-seal slot A from the current in-memory state under the live key, carrying1119     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1120     * in decoy mode (recovery is never offered for the decoy) and persists without1121     * a version bump, like the recovery-words metadata write it replaces.1122     */1123    private fun persistSlotAWithEscrows(1124        recoveryEscrow: RecoveryEscrow?,1125        rootKeyEscrow: RootKeyEscrow?,1126    ): Boolean {1127        val key = derivedKey ?: throw IllegalStateException("vault_locked")1128        if (decoyMode) return false1129        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1130        val plaintext = VaultCodec.encode(currentPayload())1131        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1132        val next = prior.copy(1133            blob = blob,1134            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1135            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1136        )1137        if (!store.writeSlotA(next)) return false1138        try {1139            writeMatchingNoiseSlotB(next)1140        } catch (e: Exception) {1141            store.writeSlotA(prior)1142            throw e1143        }1144        return true1145    }11461147    // ---- Internals --------------------------------------------------------11481149    private fun requireUnlocked() {1150        if (derivedKey == null) throw IllegalStateException("vault_locked")1151    }11521153    private fun countDuplicateEntriesLocked(): Int {1154        val seen = mutableMapOf<String, VaultEntry>()1155        var duplicates = 01156        for (entry in entryList.filter { it.deletedAt == null }) {1157            val key = entry.duplicateKey()1158            val existing = seen[key]1159            if (existing == null) {1160                seen[key] = entry1161            } else {1162                duplicates++1163                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1164                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1165                if (entryStamp > existingStamp) seen[key] = entry1166            }1167        }1168        return duplicates1169    }11701171    private fun VaultEntry.duplicateKey(): String {1172        if (type == "note") return "note|${title.lowercase()}"1173        val passkeyData = passkey1174        if (passkeyData != null) {1175            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1176        }1177        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1178    }11791180    private fun takeVaultSnapshot(reason: String) {1181        runCatching {1182            val slotA = store.loadSlotA() ?: return1183            val vaultA = VaultCodec.encodeSlot(slotA)1184            val vaultB = store.loadSlotB() ?: slotA.blob1185            val history = store.loadVaultHistory()1186            val latest = history.snapshots.firstOrNull()1187            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1188            val entryCount = if (derivedKey != null && !decoyMode) {1189                bucketEntryCount(entryList.count { it.deletedAt == null })1190            } else {1191                null1192            }1193            val snapshot = VaultSnapshot(1194                id = UUID.randomUUID().toString(),1195                savedAt = System.currentTimeMillis(),1196                reason = reason,1197                vault_a = vaultA,1198                vault_b = vaultB,1199                entryCount = entryCount,1200            )1201            store.writeVaultHistory(1202                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1203            )1204        }1205    }12061207    private fun bucketEntryCount(n: Int): Int = when {1208        n <= 0 -> 01209        n <= 10 -> 101210        n <= 25 -> 251211        n <= 50 -> 501212        n <= 100 -> 1001213        n <= 250 -> 2501214        n <= 500 -> 5001215        else -> ((n + 499) / 500) * 5001216    }12171218    /**1219     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1220     * so it is stored explicitly and survives the sync wire format rather than being1221     * re-derived at render time. Non-card entries pass through unchanged.1222     */1223    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1224        if (!entry.isCard()) return entry1225        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1226        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1227    }12281229    /**1230     * On unlock, hard-delete trashed entries older than the retention window.1231     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1232     * (the app's "Never" option), so nothing is purged. Persists without a1233     * version bump and without touching the publish state (caller publishes).1234     */1235    private fun autoPurgeExpiredTrash() {1236        val days = _settings.value.trashRetentionDays ?: return1237        if (days <= 0) return1238        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1239        val before = entryList.size1240        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1241        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1242    }12431244    /** The slot-A plaintext envelope for the current in-memory session. */1245    private fun currentPayload(): VaultPayload =1246        VaultPayload(1247            entries = entryList.toList(),1248            duressConfigured = duressConfigured,1249            rootKeyB64 = rootKey?.let(::b64) ?: "",1250            recoveryWordHash = recoveryWordHash,1251            recoveryWordsSet = recoveryWordsSet,1252        )12531254    /**1255     * Adopt the payload's root key, lazily minting one for legacy payloads.1256     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1257     * a root key is not a mutation paired devices should sync against.1258     */1259    private fun ensureRootKey(payload: VaultPayload) {1260        if (payload.rootKeyB64.isNotBlank()) {1261            rootKey = unb64(payload.rootKeyB64)1262            return1263        }1264        rootKey = generateRecoverableRootKey()1265        runCatching { persistSlotA(bumpVersion = false) }1266    }12671268    private fun publish() {1269        _entries.value = entryList.filter { it.deletedAt == null }1270    }12711272    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1273        val plaintext = VaultCodec.encode(currentPayload())1274        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1275        return StoredVault(version = 1, salt = b64(salt), blob = blob)1276    }12771278    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1279        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1280        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1281        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1282        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1283        val noise = ByteArray(approxBytes).also(rng::nextBytes)1284        store.writeSlotB(b64(noise))1285    }12861287    /** Re-seal slot A from the current in-memory state under the live key. */1288    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1289        val key = derivedKey ?: throw IllegalStateException("vault_locked")1290        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1291        if (decoyMode) {1292            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1293            val plaintext = VaultCodec.encode(currentPayload())1294            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1295            return store.writeDecoySlot(prior.copy(blob = blob))1296        }1297        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1298        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1299        val plaintext = VaultCodec.encode(currentPayload())1300        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1301        val next = prior.copy(blob = blob)1302        if (!store.writeSlotA(next)) return false1303        // Slot A committed — keep slot B size-matched, then advance the version1304        // for local writes only. Restore slot A if a later step fails.1305        try {1306            writeMatchingNoiseSlotB(next)1307            if (bumpVersion) store.stampLocalMutation()1308        } catch (e: Exception) {1309            store.writeSlotA(prior)1310            throw e1311        }1312        return true1313    }13141315    /** Persist a local mutation, rolling the in-memory list back on failure. */1316    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1317        try {1318            if (!persistSlotA(bumpVersion = true)) {1319                entryList = rollback1320                throw IllegalStateException("storage_write_failed")1321            }1322        } catch (e: Exception) {1323            entryList = rollback1324            throw e1325        }1326        publish()1327    }1328}