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.Singleton1920private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)21private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)2223/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */24private const val PASSWORD_HISTORY_MAX = 5025private const val VAULT_HISTORY_MAX = 52627sealed interface UnlockResult {28    data object Success : UnlockResult29    data object WrongPassword : UnlockResult30    data class Error(val reason: String) : UnlockResult31}3233sealed interface CreateVaultResult {34    data object Success : CreateVaultResult35    data class Error(val reason: String) : CreateVaultResult36}3738sealed interface AdoptVaultResult {39    data class Success(val vaultId: String) : AdoptVaultResult40    data class Error(val reason: String) : AdoptVaultResult41}4243/**44 * In-memory vault session backed by [VaultStore]. Faithful port of the browser45 * `vault.ts` semantics that matter for correctness:46 *47 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched48 *    noise blob so a fresh install shows two similarly-shaped records.49 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a50 *    deletion always wins last-write-wins against an in-flight edit.51 *  - Local writes bump the version counter; sync-applied writes do NOT (so a52 *    sync round never cascades into another outbound sync).53 *  - add/update/delete roll back in-memory state if the disk write fails.54 *  - destroyVault never touches the Pro license (separate store).55 */56@Singleton57private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }5859class VaultRepository @Inject constructor(60    private val store: VaultStorage,61) {62    private val mutex = Mutex()63    private val rng = SecureRandom()6465    private var derivedKey: ByteArray? = null66    private var rootKey: ByteArray? = null67    private var entryList: MutableList<VaultEntry> = mutableListOf()68    private var duressConfigured: Boolean = false69    private var recoveryWordHash: String? = null70    private var recoveryWordsSet: Boolean = false71    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */72    private var decoyMode: Boolean = false7374    private val _isUnlocked = MutableStateFlow(false)75    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()7677    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())78    /** Active (non-deleted) entryList. */79    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8081    private val _settings = MutableStateFlow(VaultSettings())82    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8384    fun hasVault(): Boolean = store.hasVault()8586    // ---- Codex (printed paper backup) -------------------------------------8788    /**89     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the90     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable91     * on any device that has the master password. Null when no vault exists.92     */93    fun codexVaultBlobBase64(): String? {94        val slotA = store.loadSlotA() ?: return null95        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""96        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))97    }9899    /**100     * Replace the local vault from a Codex blob, then unlock with [password].101     * Writes slot A (and a size-matched noise slot B) before unlocking so the102     * restored vault is at rest exactly as a freshly-created one would be.103     */104    /** A slot is stored either as a JSON string (browser) or inline object (Android). */105    private fun JsonElement?.asSlotJson(): String? = when {106        this == null || this is JsonNull -> null107        this is JsonPrimitive && isString -> content108        else -> toString()109    }110111    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {112        return try {113            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)114            // Parsed as JSON, not matched with a regex. The wrapper is115            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which116            // generates most real Codexes — writes each slot as a JSON117            // *string*, with "b" present. A regex looking for an object118            // literal under "a" at the very end of the wrapper matches119            // neither of those, so every browser-generated Codex failed here120            // with "invalid_codex_blob" after decoding perfectly.121            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject122                ?: return UnlockResult.Error("invalid_codex_blob")123            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")124            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")125            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")126            // Carry the real slot B across when the Codex has one — it holds a127            // duress vault, which noise would silently destroy.128            val slotB = wrapper["b"].asSlotJson()129            if (slotB != null) {130                store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))131            } else {132                writeMatchingNoiseSlotB(slotA)133            }134            store.stampLocalMutation()135            unlockLocked(password)136        } catch (e: Exception) {137            UnlockResult.Error(e.message ?: "restore_failed")138        }139    }140141    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {142        return try {143            val salt = VaultCrypto.generateSalt()144            val key = VaultCrypto.deriveKey(password, salt)145            entryList = mutableListOf()146            duressConfigured = false147            recoveryWordHash = null148            recoveryWordsSet = false149            decoyMode = false150            rootKey = generateRecoverableRootKey()151            val slotA = sealSlotA(key, salt)152            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")153            writeMatchingNoiseSlotB(slotA)154            store.stampLocalMutation()155            store.writeSettings(_settings.value)156            derivedKey = key157            publish()158            _isUnlocked.value = true159            CreateVaultResult.Success160        } catch (e: Exception) {161            CreateVaultResult.Error(e.message ?: "create_failed")162        }163    }164165    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {166        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")167        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {168            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")169        }170        var adoptedRootKey: ByteArray? = null171        var nextDerivedKey: ByteArray? = null172        var sessionRootKey: ByteArray? = null173        return try {174            adoptedRootKey = hexToRootKey(rootKeyHex)175            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {176                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")177            }178            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)179            val salt = VaultCrypto.generateSalt()180            nextDerivedKey = VaultCrypto.deriveKey(password, salt)181            sessionRootKey = adoptedRootKey.copyOf()182            val payload = VaultPayload(183                entries = emptyList(),184                duressConfigured = false,185                rootKeyB64 = b64(sessionRootKey),186                recoveryWordHash = null,187                recoveryWordsSet = false,188            )189            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))190            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)191            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")192            writeMatchingNoiseSlotB(slotA)193            store.stampLocalMutation()194            store.writeSettings(_settings.value)195196            val previousDerivedKey = derivedKey197            val previousRootKey = rootKey198            derivedKey = nextDerivedKey199            rootKey = sessionRootKey200            nextDerivedKey = null201            sessionRootKey = null202            entryList = mutableListOf()203            duressConfigured = false204            recoveryWordHash = null205            recoveryWordsSet = false206            decoyMode = false207            publish()208            _isUnlocked.value = true209            previousDerivedKey?.let(VaultCrypto::zero)210            previousRootKey?.let(VaultCrypto::zero)211            AdoptVaultResult.Success(vaultId)212        } catch (e: Exception) {213            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")214        } finally {215            adoptedRootKey?.let(VaultCrypto::zero)216            nextDerivedKey?.let(VaultCrypto::zero)217            sessionRootKey?.let(VaultCrypto::zero)218        }219    }220221    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }222223    /** Unlock body without acquiring the mutex — callers must already hold it. */224    private fun unlockLocked(password: String): UnlockResult {225        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")226        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))227        val payload = try {228            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))229        } catch (_: Exception) {230            VaultCrypto.zero(key)231            // LV-1: a vault sealed before NFC normalization with a non-NFC232            // password derives a different key now. If the supplied password is233            // not already in NFC form, retry with the raw (un-normalized) bytes.234            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {235                val legacy = tryLegacyRawUnlock(password, slotA)236                if (legacy != null) return legacy237            }238            return UnlockResult.WrongPassword239        }240        return adoptSlotA(key, payload)241    }242243    /**244     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw245     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed246     * under the NFC-normalized key — so every future unlock takes the normal247     * path. Returns null when the raw key also fails (genuinely wrong password).248     */249    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {250        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))251        val payload = try {252            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))253        } catch (_: Exception) {254            VaultCrypto.zero(rawKey)255            return null256        }257        // Heal: re-seal slot A under the NFC key (mirrors changePassword's258        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.259        val newSalt = VaultCrypto.generateSalt()260        val newKey = VaultCrypto.deriveKey(password, newSalt)261        val healed = try {262            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))263            val next = StoredVault(264                version = slotA.version,265                salt = b64(newSalt),266                blob = blob,267                recoveryEscrow = slotA.recoveryEscrow,268                rootKeyEscrow = slotA.rootKeyEscrow,269            )270            if (store.writeSlotA(next)) {271                writeMatchingNoiseSlotB(next)272                store.stampLocalMutation()273                true274            } else {275                false276            }277        } catch (_: Exception) {278            false279        }280        return if (healed) {281            VaultCrypto.zero(rawKey)282            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot283        } else {284            VaultCrypto.zero(newKey)285            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key286        }287    }288289    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */290    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {291        return try {292            derivedKey = key293            decoyMode = false294            entryList = payload.entries.toMutableList()295            duressConfigured = payload.duressConfigured296            recoveryWordHash = payload.recoveryWordHash297            recoveryWordsSet = payload.recoveryWordsSet298            _settings.value = store.loadSettings()299            ensureRootKey(payload)300            autoPurgeExpiredTrash()301            publish()302            _isUnlocked.value = true303            UnlockResult.Success304        } catch (e: Exception) {305            UnlockResult.Error(e.message ?: "unlock_failed")306        }307    }308309    // ---- Constant-time unlock (MF-5) --------------------------------------310311    /**312     * A slot-A unlock that has been derived but not yet adopted. Holds the live313     * Argon2id key and decoded payload so the caller can derive several candidates314     * concurrently and commit only the winner. The key is owned by this token until315     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.316     */317    class SlotAUnlock internal constructor(318        internal val key: ByteArray,319        internal val payload: VaultPayload,320    )321322    /**323     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.324     * Pure computation (no mutex), so it can run concurrently with other candidate325     * derivations before the caller decides which to commit (MF-5). Returns null on326     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).327     */328    fun tryUnlockSlotA(password: String): SlotAUnlock? {329        val slotA = store.loadSlotA() ?: return null330        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))331        return try {332            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))333            SlotAUnlock(key, payload)334        } catch (_: Exception) {335            VaultCrypto.zero(key)336            null337        }338    }339340    /** Adopt a previously-derived [SlotAUnlock] as the live session. */341    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {342        adoptSlotA(unlock.key, unlock.payload)343    }344345    /** Zero a derived [SlotAUnlock] that will not be adopted. */346    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)347348    // ---- Duress slot B (MF-6) ---------------------------------------------349350    /**351     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key352     * and decoded decoy payload so it can be derived concurrently with slot A and353     * only committed if the duress passphrase was the one entered (MF-5/MF-6).354     */355    class DuressUnlock internal constructor(356        internal val key: ByteArray,357        internal val payload: VaultPayload,358    )359360    /**361     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the362     * session. Success means the entered password is the duress passphrase that keys363     * slot B — indistinguishable from the real slot A at the lock screen. Pure364     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].365     * Returns null when no duress slot exists or the password is not the duress one.366     */367    fun tryUnlockDuress(password: String): DuressUnlock? {368        val slot = store.loadDecoySlot() ?: return null369        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))370        return try {371            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))372            DuressUnlock(key, payload)373        } catch (_: Exception) {374            VaultCrypto.zero(key)375            null376        }377    }378379    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */380    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {381        return try {382            derivedKey = unlock.key383            decoyMode = true384            entryList = unlock.payload.entries.toMutableList()385            duressConfigured = false386            recoveryWordHash = unlock.payload.recoveryWordHash387            recoveryWordsSet = unlock.payload.recoveryWordsSet388            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)389                      else VaultCrypto.generateRootKey()390            _settings.value = store.loadSettings()391            publish()392            _isUnlocked.value = true393            UnlockResult.Success394        } catch (e: Exception) {395            UnlockResult.Error(e.message ?: "decoy_unlock_failed")396        }397    }398399    /** Zero a derived [DuressUnlock] that will not be adopted. */400    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)401402    /**403     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an404     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The405     * passphrase is never stored — only its ability to decrypt slot B proves it.406     * Requires the real vault to be unlocked and not itself a decoy session.407     */408    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {409        if (derivedKey == null || decoyMode) return false410        // Reject a duress passphrase that also opens the real vault: it would make the411        // lock screen ambiguous (both slots would decrypt for the same input).412        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }413        return try {414            val salt = VaultCrypto.generateSalt()415            val key = VaultCrypto.deriveKey(duressPassphrase, salt)416            // An empty decoy vault with its own fresh sync root key — nothing real leaks.417            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))418            val plaintext = VaultCodec.encode(decoyPayload)419            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))420            VaultCrypto.zero(key)421            VaultCrypto.zero(plaintext)422            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))423            if (ok) {424                duressConfigured = true425                runCatching { persistSlotA(bumpVersion = false) }426            }427            ok428        } catch (_: Exception) {429            false430        }431    }432433    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */434    suspend fun clearDuress(): Boolean = mutex.withLock {435        store.clearDecoySlot()436        if (derivedKey != null && !decoyMode) {437            duressConfigured = false438            runCatching { persistSlotA(bumpVersion = false) }439        }440        true441    }442443    /** Unlock with an already-derived AES-256 key (biometric path). */444    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {445        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")446        return try {447            val payload = VaultCodec.decode(448                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),449            )450            derivedKey = key.copyOf()451            decoyMode = false452            entryList = payload.entries.toMutableList()453            duressConfigured = payload.duressConfigured454            recoveryWordHash = payload.recoveryWordHash455            recoveryWordsSet = payload.recoveryWordsSet456            _settings.value = store.loadSettings()457            ensureRootKey(payload)458            autoPurgeExpiredTrash()459            publish()460            _isUnlocked.value = true461            UnlockResult.Success462        } catch (_: Exception) {463            UnlockResult.WrongPassword464        }465    }466467    fun lock() {468        derivedKey?.let(VaultCrypto::zero)469        rootKey?.let(VaultCrypto::zero)470        derivedKey = null471        rootKey = null472        entryList = mutableListOf()473        duressConfigured = false474        recoveryWordHash = null475        recoveryWordsSet = false476        decoyMode = false477        _entries.value = emptyList()478        _isUnlocked.value = false479    }480481    @Volatile private var suppressNextBackgroundLock = false482483    /**484     * Call immediately before launching a system Activity (a SAF file485     * picker/save dialog, a share sheet) that briefly takes the whole process486     * out of the foreground as an expected part of an in-app flow — not a487     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the488     * two apart on its own (it fires for both), and without this, exporting or489     * importing a file silently locked and wiped the vault mid-flow the moment490     * the picker took over, before its result ever reached the app.491     */492    fun expectTransientBackground() {493        suppressNextBackgroundLock = true494    }495496    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */497    fun consumeTransientBackgroundSuppression(): Boolean {498        val suppressed = suppressNextBackgroundLock499        suppressNextBackgroundLock = false500        return suppressed501    }502503    // ---- Sync identity ----------------------------------------------------504505    /**506     * The vault's stable sync master key (a copy). Caller must zero it. Backs507     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry508     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].509     */510    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()511512    /** Stable per-vault id two paired devices must share to sync. Null when locked. */513    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }514515    /**516     * Browser parity (MV-8): rotate the sync identity by replacing the stable517     * root key. Entries remain under the master-password key, while sync518     * credentials, vault_id, and recovery words all change.519     */520    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {521        val key = derivedKey ?: return false522        if (decoyMode) return false523        val priorSlot = store.loadSlotA() ?: return false524        val priorRootKey = rootKey ?: return false525        val priorRecoveryHash = recoveryWordHash526        val priorRecoverySet = recoveryWordsSet527        val nextRootKey = generateRecoverableRootKey()528        val nextWords = rootKeyToWords(nextRootKey)529530        try {531            takeVaultSnapshot("local_save")532            rootKey = nextRootKey533            recoveryWordHash = recoveryWordsHash(nextWords)534            recoveryWordsSet = false535536            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)537                ?: throw IllegalStateException("recovery_escrow_failed")538            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)539                ?: throw IllegalStateException("root_escrow_failed")540            val plaintext = VaultCodec.encode(currentPayload())541            val blob = try {542                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))543            } finally {544                VaultCrypto.zero(plaintext)545            }546            val nextSlot = priorSlot.copy(547                blob = blob,548                recoveryEscrow = nextRecoveryEscrow,549                rootKeyEscrow = nextRootKeyEscrow,550            )551            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")552            try {553                writeMatchingNoiseSlotB(nextSlot)554                store.stampLocalMutation()555            } catch (e: Exception) {556                store.writeSlotA(priorSlot)557                throw e558            }559            VaultCrypto.zero(priorRootKey)560            publish()561            true562        } catch (_: Exception) {563            rootKey = priorRootKey564            recoveryWordHash = priorRecoveryHash565            recoveryWordsSet = priorRecoverySet566            VaultCrypto.zero(nextRootKey)567            store.writeSlotA(priorSlot)568            false569        }570    }571572    /**573     * A copy of the live Argon2id-derived vault key, for biometric escrow only574     * (wrapped under a hardware KeyStore key on biometric enable). Null when575     * locked. Caller must [VaultCrypto.zero] it after use.576     */577    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()578579    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {580        val slotA = store.loadSlotA() ?: return false581        return try {582            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))583            val payload = VaultCodec.decode(584                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),585            )586            val newSalt = VaultCrypto.generateSalt()587            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)588            // Re-seal the existing payload under the new key/salt.589            val plaintext = VaultCodec.encode(payload)590            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))591            // The escrow seals the slot-A PLAINTEXT, which a password change does not592            // alter — only the wrapping changes — so carry it forward; rebuilding the593            // StoredVault from scratch would silently drop it.594            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key595            // content that a password change does not alter — only the slot-A596            // wrapping rotates. The root-key escrow staying stable across password597            // changes matches the browser (`vault.ts:363-364`).598            val next = StoredVault(599                version = slotA.version,600                salt = b64(newSalt),601                blob = blob,602                recoveryEscrow = slotA.recoveryEscrow,603                rootKeyEscrow = slotA.rootKeyEscrow,604            )605            if (!store.writeSlotA(next)) return false606            writeMatchingNoiseSlotB(next)607            store.stampLocalMutation()608            VaultCrypto.zero(oldKey)609            derivedKey?.let(VaultCrypto::zero)610            derivedKey = newKey611            true612        } catch (_: Exception) {613            false614        }615    }616617    /**618     * Irreversible wipe. Locks the session and clears every vault key. The Pro619     * license is in a separate store and is preserved by construction.620     */621    suspend fun destroyVault() = mutex.withLock {622        lock()623        store.clearVault()624        store.clearDecoySlot()625    }626627    // ---- Entry CRUD -------------------------------------------------------628629    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {630        requireUnlocked()631        val stamp = VaultCrypto.monotonicEntryTimestamp()632        val now = System.currentTimeMillis()633        val entry = withDerivedLast4(draft.copy(634            id = draft.id.ifBlank { UUID.randomUUID().toString() },635            createdAt = if (draft.createdAt > 0) draft.createdAt else now,636            modifiedAt = stamp,637            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,638        ))639        val rollback = entryList.toMutableList()640        entryList.add(entry)641        persistOrRollback(rollback)642        entry643    }644645    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {646        requireUnlocked()647        val idx = entryList.indexOfFirst { it.id == patch.id }648        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")649        val current = entryList[idx]650        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)651        // Only refresh passwordChangedAt when the password value actually changes.652        val passwordChanged = patch.password != null && patch.password != current.password653        // Capture the outgoing password (newest first) so the user can reveal/copy/654        // restore it later. History is computed authoritatively from `current`, not655        // the patch — updateEntry is a full-entry replace, so trusting patch's value656        // would let a stale form clobber the real history. Record the age the old657        // password carried, skip an empty prior, and skip an immediate exact dupe658        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.659        val priorPassword = current.password660        val shouldCapture = passwordChanged &&661            !priorPassword.isNullOrEmpty() &&662            current.passwordHistory?.firstOrNull()?.password != priorPassword663        val nextHistory = if (shouldCapture) {664            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +665                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)666        } else {667            current.passwordHistory668        }669        val updated = withDerivedLast4(patch.copy(670            createdAt = current.createdAt,671            modifiedAt = stamp,672            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,673            passwordHistory = nextHistory,674        ))675        val rollback = entryList.toMutableList()676        entryList[idx] = updated677        persistOrRollback(rollback)678        updated679    }680681    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */682    suspend fun deleteEntry(id: String) = mutex.withLock {683        requireUnlocked()684        val idx = entryList.indexOfFirst { it.id == id }685        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")686        val current = entryList[idx]687        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)688        val rollback = entryList.toMutableList()689        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)690        persistOrRollback(rollback)691    }692693    suspend fun restoreEntry(id: String) = mutex.withLock {694        requireUnlocked()695        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }696        if (idx == -1) throw IllegalStateException("deleted entry not found")697        val current = entryList[idx]698        val rollback = entryList.toMutableList()699        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))700        persistOrRollback(rollback)701    }702703    /** Permanent delete — no tombstone, no recovery. */704    suspend fun purgeEntry(id: String) = mutex.withLock {705        requireUnlocked()706        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }707        if (idx == -1) throw IllegalStateException("deleted entry not found")708        val rollback = entryList.toMutableList()709        entryList.removeAt(idx)710        persistOrRollback(rollback)711    }712713    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */714    suspend fun purgeDeletedEntries(): Int = mutex.withLock {715        requireUnlocked()716        val rollback = entryList.toMutableList()717        val before = entryList.size718        entryList.removeAll { it.deletedAt != null }719        val removed = before - entryList.size720        if (removed > 0) persistOrRollback(rollback)721        removed722    }723724    /** Browser parity: number of active entries that dedupe would move to trash. */725    suspend fun countDuplicateEntries(): Int = mutex.withLock {726        requireUnlocked()727        countDuplicateEntriesLocked()728    }729730    /**731     * Browser parity: soft-delete duplicate entries, keeping the most recently732     * modified member of each group. Refuse in decoy mode so the duress vault is733     * not reshaped into a forensically distinct state.734     */735    suspend fun deduplicateEntries(): Int = mutex.withLock {736        requireUnlocked()737        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")738        val active = entryList.filter { it.deletedAt == null }739        val keepIds = mutableSetOf<String>()740        active.groupBy { it.duplicateKey() }.values.forEach { group ->741            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id742        }743744        val rollback = entryList.toMutableList()745        var removed = 0746        entryList = entryList.map { entry ->747            if (entry.deletedAt == null && entry.id !in keepIds) {748                removed++749                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)750                entry.copy(deletedAt = stamp, modifiedAt = stamp)751            } else {752                entry753            }754        }.toMutableList()755        if (removed > 0) persistOrRollback(rollback)756        removed757    }758759    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }760    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }761    fun getDeletedEntries(): List<VaultEntry> =762        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }763764    /** Active entries, optionally narrowed to those carrying [tagFilter]. */765    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {766        val active = entryList.filter { it.deletedAt == null }767        if (tagFilter == null) return active768        val needle = tagFilter.trim().lowercase()769        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }770    }771772    /** Sorted, de-duplicated tags across all active entries. */773    fun getDistinctTags(): List<String> =774        entryList.asSequence()775            .filter { it.deletedAt == null }776            .flatMap { it.tags?.asSequence() ?: emptySequence() }777            .map { it.trim().lowercase() }778            .filter { it.isNotEmpty() }779            .distinct()780            .sorted()781            .toList()782783    /**784     * Distinct non-blank values for an identity field across active entries,785     * most-frequently-used first. Supports "username" and "email"; unknown field786     * names return an empty list. Values keep original casing (deduped787     * case-insensitively) so a suggestion matches what the user typed.788     */789    fun getDistinctFieldValues(field: String): List<String> {790        val select: (VaultEntry) -> String? = when (field) {791            "username" -> { e -> e.username }792            "email" -> { e -> e.email }793            else -> return emptyList()794        }795        return entryList.asSequence()796            .filter { it.deletedAt == null }797            .mapNotNull { select(it)?.trim() }798            .filter { it.isNotEmpty() }799            .groupingBy { it.lowercase() }800            .aggregate { _, acc: Pair<String, Int>?, value, _ ->801                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)802            }803            .values804            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })805            .map { it.first }806    }807808    fun getTombstones(): List<Tombstone> =809        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }810    fun getVaultVersion(): Long = store.version()811    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots812813    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {814        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false815        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false816        takeVaultSnapshot("restore")817        if (!store.writeSlotA(restoredA)) return false818        if (!store.writeSlotB(snapshot.vault_b)) return false819        store.stampLocalMutation()820        lock()821        true822    }823824    // ---- Sync apply (does NOT advance the version) ------------------------825826    /**827     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists828     * WITHOUT bumping the version counter, so a sync round cannot cascade into829     * another outbound sync. Returns the number of locally-applied changes.830     */831    suspend fun applyMergedRemoteEntries(832        remoteEntries: List<VaultEntry>,833        remoteTombstones: List<Tombstone>,834    ): Int = mutex.withLock {835        requireUnlocked()836        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }837        var applied = 0838        val byId = entryList.associateBy { it.id }.toMutableMap()839840        for (remote in remoteEntries) {841            val tomb = tombMap[remote.id]842            if (tomb != null && tomb >= remote.modifiedAt) continue843            val local = byId[remote.id]844            if (local == null) {845                entryList.add(remote); byId[remote.id] = remote; applied++846            } else if (remote.modifiedAt > local.modifiedAt) {847                val i = entryList.indexOfFirst { it.id == local.id }848                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }849            }850        }851852        for ((id, deletedAt) in tombMap) {853            val local = byId[id] ?: continue854            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue855            if (deletedAt < local.modifiedAt) continue856            val i = entryList.indexOfFirst { it.id == id }857            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }858        }859860        if (applied > 0) {861            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")862            publish()863        }864        applied865    }866867    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {868        _settings.value = settings869        store.writeSettings(settings)870    }871872    // ---- Recovery words (vault metadata) ----------------------------------873874    /** True once the user has confirmed their recovery words for this vault. */875    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet876877    /**878     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.879     * Persisted without bumping the version — recovery metadata is local-only and880     * is not an entry mutation paired devices should sync against.881     */882    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {883        requireUnlocked()884        recoveryWordHash = recoveryWordsHash(words)885        recoveryWordsSet = true886        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow887        // (cross-platform recovery of the sync root key) and Android's whole-payload888        // escrow (offline forgot-password restore of entries). Both derive their key889        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.890        runCatching {891            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))892        }.getOrDefault(false)893    }894895    /** Verify entered words against the stored recovery hash. */896    fun verifyRecoveryWords(words: List<String>): Boolean {897        val stored = recoveryWordHash ?: return false898        return stored == recoveryWordsHash(words)899    }900901    // ---- Recovery escrow (forgot-password) --------------------------------902903    /** True at rest if this vault has any recovery escrow the recovery flow can use. */904    fun hasRecoveryEscrow(): Boolean =905        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false906907    /**908     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of909     * the master password. On success the slot-A plaintext sealed in the escrow is910     * adopted as the live session (entries, rootKey, settings), but no Argon2id911     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a912     * new password and finish unlocking. Returns false when there is no escrow, the913     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).914     *915     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no916     * detached vault key to return here — slot A is wrapped under the rotating917     * Argon2id key — so this opens the escrowed payload directly.918     */919    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {920        val slotA = store.loadSlotA() ?: return false921        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false922923        // Prefer Android's whole-payload escrow: a full offline restore of entries,924        // root key, and settings. Present for Android-created vaults.925        slotA.recoveryEscrow?.let { escrow ->926            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let927            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))928            val keyBytes = recoveryKey.encoded929            try {930                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)931                val payload = VaultCodec.decode(plaintext)932                decoyMode = false933                entryList = payload.entries.toMutableList()934                duressConfigured = payload.duressConfigured935                recoveryWordHash = payload.recoveryWordHash936                recoveryWordsSet = payload.recoveryWordsSet937                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)938                          else generateRecoverableRootKey()939                _settings.value = store.loadSettings()940                VaultCrypto.zero(plaintext)941                return true942            } catch (_: Exception) {943                // Wrong words against this escrow — fall through to the root-key escrow.944            } finally {945                VaultCrypto.zero(material)946                VaultCrypto.zero(keyBytes)947            }948        }949950        // Browser-format vault (only the root-key escrow): recover the 32-byte root951        // key (sync identity) and reset the password; entries return via sync. This952        // is exactly the browser's recovery semantics (`messages.ts:312-347`).953        slotA.rootKeyEscrow?.let { escrow ->954            val rk = unsealRootKeyEscrow(words, escrow) ?: return false955            decoyMode = false956            entryList = mutableListOf()957            duressConfigured = false958            recoveryWordHash = recoveryWordsHash(words)959            recoveryWordsSet = true960            rootKey = rk961            _settings.value = store.loadSettings()962            return true963        }964        return false965    }966967    /**968     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:969     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same970     * words, mark the session fully unlocked, and publish. Returns false if called971     * without a prior successful recovery unlock (no in-memory root key).972     *973     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`974     * into one step, since there is no detached vault key here.975     */976    suspend fun resetPasswordWithRecoveryWords(977        words: List<String>,978        newPassword: String,979    ): Boolean = mutex.withLock {980        if (rootKey == null) return false // must have recovered first981        return try {982            val newSalt = VaultCrypto.generateSalt()983            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)984            val plaintext = VaultCodec.encode(currentPayload())985            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))986            val prior = store.loadSlotA()987            // Rebuild both escrows against the recovered session so their salts rotate988            // with the password reset; fall back to the prior escrows if rebuild fails989            // (e.g. a checksum-invalid phrase). The same 12 words keep working.990            val valid = words.size == 12 && findInvalidWords(words).isEmpty()991            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow992            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow993            val next = StoredVault(994                version = prior?.version ?: 1,995                salt = b64(newSalt),996                blob = blob,997                recoveryEscrow = freshRecovery,998                rootKeyEscrow = freshRootKey,999            )1000            if (!store.writeSlotA(next)) return false1001            writeMatchingNoiseSlotB(next)1002            store.stampLocalMutation()1003            derivedKey?.let(VaultCrypto::zero)1004            derivedKey = newKey1005            VaultCrypto.zero(plaintext)1006            publish()1007            _isUnlocked.value = true1008            true1009        } catch (_: Exception) {1010            false1011        }1012    }10131014    /**1015     * Seal the current slot-A plaintext under a key derived from the 12 BIP391016     * recovery [words] (Android offline-restore escrow). IKM is the browser's1017     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1018     * Returns null for a checksum-invalid phrase.1019     */1020    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1021        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1022        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1023        val salt = VaultCrypto.generateSalt()1024        val recoveryKey = deriveRecoveryKey(material, salt)1025        val keyBytes = recoveryKey.encoded1026        val plaintext = VaultCodec.encode(currentPayload())1027        return try {1028            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1029            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1030        } finally {1031            VaultCrypto.zero(material)1032            VaultCrypto.zero(keyBytes)1033            VaultCrypto.zero(plaintext)1034        }1035    }10361037    /**1038     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1039     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1040     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1041     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1042     * the same root key on the browser. Returns null with no in-memory root key or1043     * a checksum-invalid phrase.1044     */1045    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1046        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1047        val rk = rootKey ?: return null1048        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1049        val salt = VaultCrypto.generateSalt()1050        val recoveryKey = deriveRecoveryKey(material, salt)1051        val keyBytes = recoveryKey.encoded1052        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1053        return try {1054            // combined = nonce(12) || ciphertext || tag(16)1055            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1056            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1057            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1058            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1059            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1060            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1061        } finally {1062            VaultCrypto.zero(material)1063            VaultCrypto.zero(keyBytes)1064            VaultCrypto.zero(plaintext)1065        }1066    }10671068    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1069    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1070        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1071        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1072        val keyBytes = recoveryKey.encoded1073        return try {1074            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1075            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1076            hexToRootKey(String(pt, Charsets.US_ASCII))1077        } catch (_: Exception) {1078            null1079        } finally {1080            VaultCrypto.zero(material)1081            VaultCrypto.zero(keyBytes)1082        }1083    }10841085    /**1086     * Re-seal slot A from the current in-memory state under the live key, carrying1087     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1088     * in decoy mode (recovery is never offered for the decoy) and persists without1089     * a version bump, like the recovery-words metadata write it replaces.1090     */1091    private fun persistSlotAWithEscrows(1092        recoveryEscrow: RecoveryEscrow?,1093        rootKeyEscrow: RootKeyEscrow?,1094    ): Boolean {1095        val key = derivedKey ?: throw IllegalStateException("vault_locked")1096        if (decoyMode) return false1097        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1098        val plaintext = VaultCodec.encode(currentPayload())1099        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1100        val next = prior.copy(1101            blob = blob,1102            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1103            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1104        )1105        if (!store.writeSlotA(next)) return false1106        try {1107            writeMatchingNoiseSlotB(next)1108        } catch (e: Exception) {1109            store.writeSlotA(prior)1110            throw e1111        }1112        return true1113    }11141115    // ---- Internals --------------------------------------------------------11161117    private fun requireUnlocked() {1118        if (derivedKey == null) throw IllegalStateException("vault_locked")1119    }11201121    private fun countDuplicateEntriesLocked(): Int {1122        val seen = mutableMapOf<String, VaultEntry>()1123        var duplicates = 01124        for (entry in entryList.filter { it.deletedAt == null }) {1125            val key = entry.duplicateKey()1126            val existing = seen[key]1127            if (existing == null) {1128                seen[key] = entry1129            } else {1130                duplicates++1131                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1132                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1133                if (entryStamp > existingStamp) seen[key] = entry1134            }1135        }1136        return duplicates1137    }11381139    private fun VaultEntry.duplicateKey(): String {1140        if (type == "note") return "note|${title.lowercase()}"1141        val passkeyData = passkey1142        if (passkeyData != null) {1143            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1144        }1145        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1146    }11471148    private fun takeVaultSnapshot(reason: String) {1149        runCatching {1150            val slotA = store.loadSlotA() ?: return1151            val vaultA = VaultCodec.encodeSlot(slotA)1152            val vaultB = store.loadSlotB() ?: slotA.blob1153            val history = store.loadVaultHistory()1154            val latest = history.snapshots.firstOrNull()1155            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1156            val entryCount = if (derivedKey != null && !decoyMode) {1157                bucketEntryCount(entryList.count { it.deletedAt == null })1158            } else {1159                null1160            }1161            val snapshot = VaultSnapshot(1162                id = UUID.randomUUID().toString(),1163                savedAt = System.currentTimeMillis(),1164                reason = reason,1165                vault_a = vaultA,1166                vault_b = vaultB,1167                entryCount = entryCount,1168            )1169            store.writeVaultHistory(1170                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1171            )1172        }1173    }11741175    private fun bucketEntryCount(n: Int): Int = when {1176        n <= 0 -> 01177        n <= 10 -> 101178        n <= 25 -> 251179        n <= 50 -> 501180        n <= 100 -> 1001181        n <= 250 -> 2501182        n <= 500 -> 5001183        else -> ((n + 499) / 500) * 5001184    }11851186    /**1187     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1188     * so it is stored explicitly and survives the sync wire format rather than being1189     * re-derived at render time. Non-card entries pass through unchanged.1190     */1191    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1192        if (!entry.isCard()) return entry1193        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1194        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1195    }11961197    /**1198     * On unlock, hard-delete trashed entries older than the retention window.1199     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1200     * (the app's "Never" option), so nothing is purged. Persists without a1201     * version bump and without touching the publish state (caller publishes).1202     */1203    private fun autoPurgeExpiredTrash() {1204        val days = _settings.value.trashRetentionDays ?: return1205        if (days <= 0) return1206        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1207        val before = entryList.size1208        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1209        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1210    }12111212    /** The slot-A plaintext envelope for the current in-memory session. */1213    private fun currentPayload(): VaultPayload =1214        VaultPayload(1215            entries = entryList.toList(),1216            duressConfigured = duressConfigured,1217            rootKeyB64 = rootKey?.let(::b64) ?: "",1218            recoveryWordHash = recoveryWordHash,1219            recoveryWordsSet = recoveryWordsSet,1220        )12211222    /**1223     * Adopt the payload's root key, lazily minting one for legacy payloads.1224     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1225     * a root key is not a mutation paired devices should sync against.1226     */1227    private fun ensureRootKey(payload: VaultPayload) {1228        if (payload.rootKeyB64.isNotBlank()) {1229            rootKey = unb64(payload.rootKeyB64)1230            return1231        }1232        rootKey = generateRecoverableRootKey()1233        runCatching { persistSlotA(bumpVersion = false) }1234    }12351236    private fun publish() {1237        _entries.value = entryList.filter { it.deletedAt == null }1238    }12391240    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1241        val plaintext = VaultCodec.encode(currentPayload())1242        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1243        return StoredVault(version = 1, salt = b64(salt), blob = blob)1244    }12451246    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1247        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1248        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1249        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1250        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1251        val noise = ByteArray(approxBytes).also(rng::nextBytes)1252        store.writeSlotB(b64(noise))1253    }12541255    /** Re-seal slot A from the current in-memory state under the live key. */1256    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1257        val key = derivedKey ?: throw IllegalStateException("vault_locked")1258        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1259        if (decoyMode) {1260            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1261            val plaintext = VaultCodec.encode(currentPayload())1262            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1263            return store.writeDecoySlot(prior.copy(blob = blob))1264        }1265        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1266        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1267        val plaintext = VaultCodec.encode(currentPayload())1268        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1269        val next = prior.copy(blob = blob)1270        if (!store.writeSlotA(next)) return false1271        // Slot A committed — keep slot B size-matched, then advance the version1272        // for local writes only. Restore slot A if a later step fails.1273        try {1274            writeMatchingNoiseSlotB(next)1275            if (bumpVersion) store.stampLocalMutation()1276        } catch (e: Exception) {1277            store.writeSlotA(prior)1278            throw e1279        }1280        return true1281    }12821283    /** Persist a local mutation, rolling the in-memory list back on failure. */1284    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1285        try {1286            if (!persistSlotA(bumpVersion = true)) {1287                entryList = rollback1288                throw IllegalStateException("storage_write_failed")1289            }1290        } catch (e: Exception) {1291            entryList = rollback1292            throw e1293        }1294        publish()1295    }1296}