VaultRepository.kt

59.1 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            // A Codex printed from the browser carries a browser-shaped slot,126            // which is the common case — most Codexes are printed there. It is127            // translated to Android's shape (see BrowserSlot) rather than128            // rejected. A translation failure is a wrong password, not a bad129            // blob: the codes decoded fine, the password just didn't open them.130            val slotA = VaultCodec.decodeSlot(slotJson)131                ?: BrowserSlot.parse(slotJson)?.let {132                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword133                }134                ?: return UnlockResult.Error("invalid_codex_blob")135            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")136            // Carry the real slot B across when the Codex has one — it holds a137            // duress vault, which noise would silently destroy.138            val slotB = wrapper["b"].asSlotJson()139            if (slotB != null) {140                store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))141            } else {142                writeMatchingNoiseSlotB(slotA)143            }144            store.stampLocalMutation()145            unlockLocked(password)146        } catch (e: Exception) {147            UnlockResult.Error(e.message ?: "restore_failed")148        }149    }150151    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {152        return try {153            val salt = VaultCrypto.generateSalt()154            val key = VaultCrypto.deriveKey(password, salt)155            entryList = mutableListOf()156            duressConfigured = false157            recoveryWordHash = null158            recoveryWordsSet = false159            decoyMode = false160            rootKey = generateRecoverableRootKey()161            val slotA = sealSlotA(key, salt)162            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")163            writeMatchingNoiseSlotB(slotA)164            store.stampLocalMutation()165            store.writeSettings(_settings.value)166            derivedKey = key167            publish()168            _isUnlocked.value = true169            CreateVaultResult.Success170        } catch (e: Exception) {171            CreateVaultResult.Error(e.message ?: "create_failed")172        }173    }174175    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {176        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")177        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {178            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")179        }180        var adoptedRootKey: ByteArray? = null181        var nextDerivedKey: ByteArray? = null182        var sessionRootKey: ByteArray? = null183        return try {184            adoptedRootKey = hexToRootKey(rootKeyHex)185            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {186                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")187            }188            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)189            val salt = VaultCrypto.generateSalt()190            nextDerivedKey = VaultCrypto.deriveKey(password, salt)191            sessionRootKey = adoptedRootKey.copyOf()192            val payload = VaultPayload(193                entries = emptyList(),194                duressConfigured = false,195                rootKeyB64 = b64(sessionRootKey),196                recoveryWordHash = null,197                recoveryWordsSet = false,198            )199            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))200            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)201            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")202            writeMatchingNoiseSlotB(slotA)203            store.stampLocalMutation()204            store.writeSettings(_settings.value)205206            val previousDerivedKey = derivedKey207            val previousRootKey = rootKey208            derivedKey = nextDerivedKey209            rootKey = sessionRootKey210            nextDerivedKey = null211            sessionRootKey = null212            entryList = mutableListOf()213            duressConfigured = false214            recoveryWordHash = null215            recoveryWordsSet = false216            decoyMode = false217            publish()218            _isUnlocked.value = true219            previousDerivedKey?.let(VaultCrypto::zero)220            previousRootKey?.let(VaultCrypto::zero)221            AdoptVaultResult.Success(vaultId)222        } catch (e: Exception) {223            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")224        } finally {225            adoptedRootKey?.let(VaultCrypto::zero)226            nextDerivedKey?.let(VaultCrypto::zero)227            sessionRootKey?.let(VaultCrypto::zero)228        }229    }230231    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }232233    /** Unlock body without acquiring the mutex — callers must already hold it. */234    private fun unlockLocked(password: String): UnlockResult {235        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")236        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))237        val payload = try {238            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))239        } catch (_: Exception) {240            VaultCrypto.zero(key)241            // LV-1: a vault sealed before NFC normalization with a non-NFC242            // password derives a different key now. If the supplied password is243            // not already in NFC form, retry with the raw (un-normalized) bytes.244            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {245                val legacy = tryLegacyRawUnlock(password, slotA)246                if (legacy != null) return legacy247            }248            return UnlockResult.WrongPassword249        }250        return adoptSlotA(key, payload)251    }252253    /**254     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw255     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed256     * under the NFC-normalized key — so every future unlock takes the normal257     * path. Returns null when the raw key also fails (genuinely wrong password).258     */259    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {260        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))261        val payload = try {262            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))263        } catch (_: Exception) {264            VaultCrypto.zero(rawKey)265            return null266        }267        // Heal: re-seal slot A under the NFC key (mirrors changePassword's268        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.269        val newSalt = VaultCrypto.generateSalt()270        val newKey = VaultCrypto.deriveKey(password, newSalt)271        val healed = try {272            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))273            val next = StoredVault(274                version = slotA.version,275                salt = b64(newSalt),276                blob = blob,277                recoveryEscrow = slotA.recoveryEscrow,278                rootKeyEscrow = slotA.rootKeyEscrow,279            )280            if (store.writeSlotA(next)) {281                writeMatchingNoiseSlotB(next)282                store.stampLocalMutation()283                true284            } else {285                false286            }287        } catch (_: Exception) {288            false289        }290        return if (healed) {291            VaultCrypto.zero(rawKey)292            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot293        } else {294            VaultCrypto.zero(newKey)295            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key296        }297    }298299    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */300    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {301        return try {302            derivedKey = key303            decoyMode = false304            entryList = payload.entries.toMutableList()305            duressConfigured = payload.duressConfigured306            recoveryWordHash = payload.recoveryWordHash307            recoveryWordsSet = payload.recoveryWordsSet308            _settings.value = store.loadSettings()309            ensureRootKey(payload)310            autoPurgeExpiredTrash()311            publish()312            _isUnlocked.value = true313            UnlockResult.Success314        } catch (e: Exception) {315            UnlockResult.Error(e.message ?: "unlock_failed")316        }317    }318319    // ---- Constant-time unlock (MF-5) --------------------------------------320321    /**322     * A slot-A unlock that has been derived but not yet adopted. Holds the live323     * Argon2id key and decoded payload so the caller can derive several candidates324     * concurrently and commit only the winner. The key is owned by this token until325     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.326     */327    class SlotAUnlock internal constructor(328        internal val key: ByteArray,329        internal val payload: VaultPayload,330    )331332    /**333     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.334     * Pure computation (no mutex), so it can run concurrently with other candidate335     * derivations before the caller decides which to commit (MF-5). Returns null on336     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).337     */338    fun tryUnlockSlotA(password: String): SlotAUnlock? {339        val slotA = store.loadSlotA() ?: return null340        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))341        return try {342            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))343            SlotAUnlock(key, payload)344        } catch (_: Exception) {345            VaultCrypto.zero(key)346            null347        }348    }349350    /** Adopt a previously-derived [SlotAUnlock] as the live session. */351    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {352        adoptSlotA(unlock.key, unlock.payload)353    }354355    /** Zero a derived [SlotAUnlock] that will not be adopted. */356    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)357358    // ---- Duress slot B (MF-6) ---------------------------------------------359360    /**361     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key362     * and decoded decoy payload so it can be derived concurrently with slot A and363     * only committed if the duress passphrase was the one entered (MF-5/MF-6).364     */365    class DuressUnlock internal constructor(366        internal val key: ByteArray,367        internal val payload: VaultPayload,368    )369370    /**371     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the372     * session. Success means the entered password is the duress passphrase that keys373     * slot B — indistinguishable from the real slot A at the lock screen. Pure374     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].375     * Returns null when no duress slot exists or the password is not the duress one.376     */377    fun tryUnlockDuress(password: String): DuressUnlock? {378        val slot = store.loadDecoySlot() ?: return null379        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))380        return try {381            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))382            DuressUnlock(key, payload)383        } catch (_: Exception) {384            VaultCrypto.zero(key)385            null386        }387    }388389    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */390    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {391        return try {392            derivedKey = unlock.key393            decoyMode = true394            entryList = unlock.payload.entries.toMutableList()395            duressConfigured = false396            recoveryWordHash = unlock.payload.recoveryWordHash397            recoveryWordsSet = unlock.payload.recoveryWordsSet398            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)399                      else VaultCrypto.generateRootKey()400            _settings.value = store.loadSettings()401            publish()402            _isUnlocked.value = true403            UnlockResult.Success404        } catch (e: Exception) {405            UnlockResult.Error(e.message ?: "decoy_unlock_failed")406        }407    }408409    /** Zero a derived [DuressUnlock] that will not be adopted. */410    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)411412    /**413     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an414     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The415     * passphrase is never stored — only its ability to decrypt slot B proves it.416     * Requires the real vault to be unlocked and not itself a decoy session.417     */418    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {419        if (derivedKey == null || decoyMode) return false420        // Reject a duress passphrase that also opens the real vault: it would make the421        // lock screen ambiguous (both slots would decrypt for the same input).422        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }423        return try {424            val salt = VaultCrypto.generateSalt()425            val key = VaultCrypto.deriveKey(duressPassphrase, salt)426            // An empty decoy vault with its own fresh sync root key — nothing real leaks.427            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))428            val plaintext = VaultCodec.encode(decoyPayload)429            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))430            VaultCrypto.zero(key)431            VaultCrypto.zero(plaintext)432            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))433            if (ok) {434                duressConfigured = true435                runCatching { persistSlotA(bumpVersion = false) }436            }437            ok438        } catch (_: Exception) {439            false440        }441    }442443    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */444    suspend fun clearDuress(): Boolean = mutex.withLock {445        store.clearDecoySlot()446        if (derivedKey != null && !decoyMode) {447            duressConfigured = false448            runCatching { persistSlotA(bumpVersion = false) }449        }450        true451    }452453    /** Unlock with an already-derived AES-256 key (biometric path). */454    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {455        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")456        return try {457            val payload = VaultCodec.decode(458                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),459            )460            derivedKey = key.copyOf()461            decoyMode = false462            entryList = payload.entries.toMutableList()463            duressConfigured = payload.duressConfigured464            recoveryWordHash = payload.recoveryWordHash465            recoveryWordsSet = payload.recoveryWordsSet466            _settings.value = store.loadSettings()467            ensureRootKey(payload)468            autoPurgeExpiredTrash()469            publish()470            _isUnlocked.value = true471            UnlockResult.Success472        } catch (_: Exception) {473            UnlockResult.WrongPassword474        }475    }476477    fun lock() {478        derivedKey?.let(VaultCrypto::zero)479        rootKey?.let(VaultCrypto::zero)480        derivedKey = null481        rootKey = null482        entryList = mutableListOf()483        duressConfigured = false484        recoveryWordHash = null485        recoveryWordsSet = false486        decoyMode = false487        _entries.value = emptyList()488        _isUnlocked.value = false489    }490491    @Volatile private var suppressNextBackgroundLock = false492493    /**494     * Call immediately before launching a system Activity (a SAF file495     * picker/save dialog, a share sheet) that briefly takes the whole process496     * out of the foreground as an expected part of an in-app flow — not a497     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the498     * two apart on its own (it fires for both), and without this, exporting or499     * importing a file silently locked and wiped the vault mid-flow the moment500     * the picker took over, before its result ever reached the app.501     */502    fun expectTransientBackground() {503        suppressNextBackgroundLock = true504    }505506    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */507    fun consumeTransientBackgroundSuppression(): Boolean {508        val suppressed = suppressNextBackgroundLock509        suppressNextBackgroundLock = false510        return suppressed511    }512513    // ---- Sync identity ----------------------------------------------------514515    /**516     * The vault's stable sync master key (a copy). Caller must zero it. Backs517     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry518     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].519     */520    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()521522    /** Stable per-vault id two paired devices must share to sync. Null when locked. */523    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }524525    /**526     * Browser parity (MV-8): rotate the sync identity by replacing the stable527     * root key. Entries remain under the master-password key, while sync528     * credentials, vault_id, and recovery words all change.529     */530    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {531        val key = derivedKey ?: return false532        if (decoyMode) return false533        val priorSlot = store.loadSlotA() ?: return false534        val priorRootKey = rootKey ?: return false535        val priorRecoveryHash = recoveryWordHash536        val priorRecoverySet = recoveryWordsSet537        val nextRootKey = generateRecoverableRootKey()538        val nextWords = rootKeyToWords(nextRootKey)539540        try {541            takeVaultSnapshot("local_save")542            rootKey = nextRootKey543            recoveryWordHash = recoveryWordsHash(nextWords)544            recoveryWordsSet = false545546            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)547                ?: throw IllegalStateException("recovery_escrow_failed")548            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)549                ?: throw IllegalStateException("root_escrow_failed")550            val plaintext = VaultCodec.encode(currentPayload())551            val blob = try {552                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))553            } finally {554                VaultCrypto.zero(plaintext)555            }556            val nextSlot = priorSlot.copy(557                blob = blob,558                recoveryEscrow = nextRecoveryEscrow,559                rootKeyEscrow = nextRootKeyEscrow,560            )561            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")562            try {563                writeMatchingNoiseSlotB(nextSlot)564                store.stampLocalMutation()565            } catch (e: Exception) {566                store.writeSlotA(priorSlot)567                throw e568            }569            VaultCrypto.zero(priorRootKey)570            publish()571            true572        } catch (_: Exception) {573            rootKey = priorRootKey574            recoveryWordHash = priorRecoveryHash575            recoveryWordsSet = priorRecoverySet576            VaultCrypto.zero(nextRootKey)577            store.writeSlotA(priorSlot)578            false579        }580    }581582    /**583     * A copy of the live Argon2id-derived vault key, for biometric escrow only584     * (wrapped under a hardware KeyStore key on biometric enable). Null when585     * locked. Caller must [VaultCrypto.zero] it after use.586     */587    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()588589    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {590        val slotA = store.loadSlotA() ?: return false591        return try {592            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))593            val payload = VaultCodec.decode(594                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),595            )596            val newSalt = VaultCrypto.generateSalt()597            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)598            // Re-seal the existing payload under the new key/salt.599            val plaintext = VaultCodec.encode(payload)600            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))601            // The escrow seals the slot-A PLAINTEXT, which a password change does not602            // alter — only the wrapping changes — so carry it forward; rebuilding the603            // StoredVault from scratch would silently drop it.604            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key605            // content that a password change does not alter — only the slot-A606            // wrapping rotates. The root-key escrow staying stable across password607            // changes matches the browser (`vault.ts:363-364`).608            val next = StoredVault(609                version = slotA.version,610                salt = b64(newSalt),611                blob = blob,612                recoveryEscrow = slotA.recoveryEscrow,613                rootKeyEscrow = slotA.rootKeyEscrow,614            )615            if (!store.writeSlotA(next)) return false616            writeMatchingNoiseSlotB(next)617            store.stampLocalMutation()618            VaultCrypto.zero(oldKey)619            derivedKey?.let(VaultCrypto::zero)620            derivedKey = newKey621            true622        } catch (_: Exception) {623            false624        }625    }626627    /**628     * Irreversible wipe. Locks the session and clears every vault key. The Pro629     * license is in a separate store and is preserved by construction.630     */631    suspend fun destroyVault() = mutex.withLock {632        lock()633        store.clearVault()634        store.clearDecoySlot()635    }636637    // ---- Entry CRUD -------------------------------------------------------638639    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {640        requireUnlocked()641        val stamp = VaultCrypto.monotonicEntryTimestamp()642        val now = System.currentTimeMillis()643        val entry = withDerivedLast4(draft.copy(644            id = draft.id.ifBlank { UUID.randomUUID().toString() },645            createdAt = if (draft.createdAt > 0) draft.createdAt else now,646            modifiedAt = stamp,647            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,648        ))649        val rollback = entryList.toMutableList()650        entryList.add(entry)651        persistOrRollback(rollback)652        entry653    }654655    /**656     * Insert many entries with a single encrypt-and-write at the end.657     *658     * Importing a real vault through [addEntry] was quadratic: every entry took659     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry660     * restore did 806 full-vault writes and hung the app long enough to ANR.661     * Rolls back to the pre-import list if the one write fails, matching662     * [addEntry]'s all-or-nothing behaviour.663     */664    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {665        requireUnlocked()666        if (drafts.isEmpty()) return 0667        val rollback = entryList.toMutableList()668        val now = System.currentTimeMillis()669        for (draft in drafts) {670            val stamp = VaultCrypto.monotonicEntryTimestamp()671            entryList.add(672                withDerivedLast4(673                    draft.copy(674                        id = draft.id.ifBlank { UUID.randomUUID().toString() },675                        createdAt = if (draft.createdAt > 0) draft.createdAt else now,676                        modifiedAt = stamp,677                        passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,678                    ),679                ),680            )681        }682        persistOrRollback(rollback)683        return drafts.size684    }685686    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {687        requireUnlocked()688        val idx = entryList.indexOfFirst { it.id == patch.id }689        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")690        val current = entryList[idx]691        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)692        // Only refresh passwordChangedAt when the password value actually changes.693        val passwordChanged = patch.password != null && patch.password != current.password694        // Capture the outgoing password (newest first) so the user can reveal/copy/695        // restore it later. History is computed authoritatively from `current`, not696        // the patch — updateEntry is a full-entry replace, so trusting patch's value697        // would let a stale form clobber the real history. Record the age the old698        // password carried, skip an empty prior, and skip an immediate exact dupe699        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.700        val priorPassword = current.password701        val shouldCapture = passwordChanged &&702            !priorPassword.isNullOrEmpty() &&703            current.passwordHistory?.firstOrNull()?.password != priorPassword704        val nextHistory = if (shouldCapture) {705            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +706                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)707        } else {708            current.passwordHistory709        }710        val updated = withDerivedLast4(patch.copy(711            createdAt = current.createdAt,712            modifiedAt = stamp,713            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,714            passwordHistory = nextHistory,715        ))716        val rollback = entryList.toMutableList()717        entryList[idx] = updated718        persistOrRollback(rollback)719        updated720    }721722    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */723    suspend fun deleteEntry(id: String) = mutex.withLock {724        requireUnlocked()725        val idx = entryList.indexOfFirst { it.id == id }726        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")727        val current = entryList[idx]728        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)729        val rollback = entryList.toMutableList()730        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)731        persistOrRollback(rollback)732    }733734    suspend fun restoreEntry(id: String) = mutex.withLock {735        requireUnlocked()736        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }737        if (idx == -1) throw IllegalStateException("deleted entry not found")738        val current = entryList[idx]739        val rollback = entryList.toMutableList()740        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))741        persistOrRollback(rollback)742    }743744    /** Permanent delete — no tombstone, no recovery. */745    suspend fun purgeEntry(id: String) = mutex.withLock {746        requireUnlocked()747        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }748        if (idx == -1) throw IllegalStateException("deleted entry not found")749        val rollback = entryList.toMutableList()750        entryList.removeAt(idx)751        persistOrRollback(rollback)752    }753754    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */755    suspend fun purgeDeletedEntries(): Int = mutex.withLock {756        requireUnlocked()757        val rollback = entryList.toMutableList()758        val before = entryList.size759        entryList.removeAll { it.deletedAt != null }760        val removed = before - entryList.size761        if (removed > 0) persistOrRollback(rollback)762        removed763    }764765    /** Browser parity: number of active entries that dedupe would move to trash. */766    suspend fun countDuplicateEntries(): Int = mutex.withLock {767        requireUnlocked()768        countDuplicateEntriesLocked()769    }770771    /**772     * Browser parity: soft-delete duplicate entries, keeping the most recently773     * modified member of each group. Refuse in decoy mode so the duress vault is774     * not reshaped into a forensically distinct state.775     */776    suspend fun deduplicateEntries(): Int = mutex.withLock {777        requireUnlocked()778        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")779        val active = entryList.filter { it.deletedAt == null }780        val keepIds = mutableSetOf<String>()781        active.groupBy { it.duplicateKey() }.values.forEach { group ->782            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id783        }784785        val rollback = entryList.toMutableList()786        var removed = 0787        entryList = entryList.map { entry ->788            if (entry.deletedAt == null && entry.id !in keepIds) {789                removed++790                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)791                entry.copy(deletedAt = stamp, modifiedAt = stamp)792            } else {793                entry794            }795        }.toMutableList()796        if (removed > 0) persistOrRollback(rollback)797        removed798    }799800    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }801    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }802    fun getDeletedEntries(): List<VaultEntry> =803        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }804805    /** Active entries, optionally narrowed to those carrying [tagFilter]. */806    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {807        val active = entryList.filter { it.deletedAt == null }808        if (tagFilter == null) return active809        val needle = tagFilter.trim().lowercase()810        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }811    }812813    /** Sorted, de-duplicated tags across all active entries. */814    fun getDistinctTags(): List<String> =815        entryList.asSequence()816            .filter { it.deletedAt == null }817            .flatMap { it.tags?.asSequence() ?: emptySequence() }818            .map { it.trim().lowercase() }819            .filter { it.isNotEmpty() }820            .distinct()821            .sorted()822            .toList()823824    /**825     * Distinct non-blank values for an identity field across active entries,826     * most-frequently-used first. Supports "username" and "email"; unknown field827     * names return an empty list. Values keep original casing (deduped828     * case-insensitively) so a suggestion matches what the user typed.829     */830    fun getDistinctFieldValues(field: String): List<String> {831        val select: (VaultEntry) -> String? = when (field) {832            "username" -> { e -> e.username }833            "email" -> { e -> e.email }834            else -> return emptyList()835        }836        return entryList.asSequence()837            .filter { it.deletedAt == null }838            .mapNotNull { select(it)?.trim() }839            .filter { it.isNotEmpty() }840            .groupingBy { it.lowercase() }841            .aggregate { _, acc: Pair<String, Int>?, value, _ ->842                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)843            }844            .values845            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })846            .map { it.first }847    }848849    fun getTombstones(): List<Tombstone> =850        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }851    fun getVaultVersion(): Long = store.version()852    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots853854    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {855        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false856        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false857        takeVaultSnapshot("restore")858        if (!store.writeSlotA(restoredA)) return false859        if (!store.writeSlotB(snapshot.vault_b)) return false860        store.stampLocalMutation()861        lock()862        true863    }864865    // ---- Sync apply (does NOT advance the version) ------------------------866867    /**868     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists869     * WITHOUT bumping the version counter, so a sync round cannot cascade into870     * another outbound sync. Returns the number of locally-applied changes.871     */872    suspend fun applyMergedRemoteEntries(873        remoteEntries: List<VaultEntry>,874        remoteTombstones: List<Tombstone>,875    ): Int = mutex.withLock {876        requireUnlocked()877        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }878        var applied = 0879        val byId = entryList.associateBy { it.id }.toMutableMap()880881        for (remote in remoteEntries) {882            val tomb = tombMap[remote.id]883            if (tomb != null && tomb >= remote.modifiedAt) continue884            val local = byId[remote.id]885            if (local == null) {886                entryList.add(remote); byId[remote.id] = remote; applied++887            } else if (remote.modifiedAt > local.modifiedAt) {888                val i = entryList.indexOfFirst { it.id == local.id }889                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }890            }891        }892893        for ((id, deletedAt) in tombMap) {894            val local = byId[id] ?: continue895            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue896            if (deletedAt < local.modifiedAt) continue897            val i = entryList.indexOfFirst { it.id == id }898            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }899        }900901        if (applied > 0) {902            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")903            publish()904        }905        applied906    }907908    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {909        _settings.value = settings910        store.writeSettings(settings)911    }912913    // ---- Recovery words (vault metadata) ----------------------------------914915    /** True once the user has confirmed their recovery words for this vault. */916    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet917918    /**919     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.920     * Persisted without bumping the version — recovery metadata is local-only and921     * is not an entry mutation paired devices should sync against.922     */923    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {924        requireUnlocked()925        recoveryWordHash = recoveryWordsHash(words)926        recoveryWordsSet = true927        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow928        // (cross-platform recovery of the sync root key) and Android's whole-payload929        // escrow (offline forgot-password restore of entries). Both derive their key930        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.931        runCatching {932            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))933        }.getOrDefault(false)934    }935936    /** Verify entered words against the stored recovery hash. */937    fun verifyRecoveryWords(words: List<String>): Boolean {938        val stored = recoveryWordHash ?: return false939        return stored == recoveryWordsHash(words)940    }941942    // ---- Recovery escrow (forgot-password) --------------------------------943944    /** True at rest if this vault has any recovery escrow the recovery flow can use. */945    fun hasRecoveryEscrow(): Boolean =946        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false947948    /**949     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of950     * the master password. On success the slot-A plaintext sealed in the escrow is951     * adopted as the live session (entries, rootKey, settings), but no Argon2id952     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a953     * new password and finish unlocking. Returns false when there is no escrow, the954     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).955     *956     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no957     * detached vault key to return here — slot A is wrapped under the rotating958     * Argon2id key — so this opens the escrowed payload directly.959     */960    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {961        val slotA = store.loadSlotA() ?: return false962        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false963964        // Prefer Android's whole-payload escrow: a full offline restore of entries,965        // root key, and settings. Present for Android-created vaults.966        slotA.recoveryEscrow?.let { escrow ->967            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let968            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))969            val keyBytes = recoveryKey.encoded970            try {971                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)972                val payload = VaultCodec.decode(plaintext)973                decoyMode = false974                entryList = payload.entries.toMutableList()975                duressConfigured = payload.duressConfigured976                recoveryWordHash = payload.recoveryWordHash977                recoveryWordsSet = payload.recoveryWordsSet978                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)979                          else generateRecoverableRootKey()980                _settings.value = store.loadSettings()981                VaultCrypto.zero(plaintext)982                return true983            } catch (_: Exception) {984                // Wrong words against this escrow — fall through to the root-key escrow.985            } finally {986                VaultCrypto.zero(material)987                VaultCrypto.zero(keyBytes)988            }989        }990991        // Browser-format vault (only the root-key escrow): recover the 32-byte root992        // key (sync identity) and reset the password; entries return via sync. This993        // is exactly the browser's recovery semantics (`messages.ts:312-347`).994        slotA.rootKeyEscrow?.let { escrow ->995            val rk = unsealRootKeyEscrow(words, escrow) ?: return false996            decoyMode = false997            entryList = mutableListOf()998            duressConfigured = false999            recoveryWordHash = recoveryWordsHash(words)1000            recoveryWordsSet = true1001            rootKey = rk1002            _settings.value = store.loadSettings()1003            return true1004        }1005        return false1006    }10071008    /**1009     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1010     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1011     * words, mark the session fully unlocked, and publish. Returns false if called1012     * without a prior successful recovery unlock (no in-memory root key).1013     *1014     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1015     * into one step, since there is no detached vault key here.1016     */1017    suspend fun resetPasswordWithRecoveryWords(1018        words: List<String>,1019        newPassword: String,1020    ): Boolean = mutex.withLock {1021        if (rootKey == null) return false // must have recovered first1022        return try {1023            val newSalt = VaultCrypto.generateSalt()1024            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1025            val plaintext = VaultCodec.encode(currentPayload())1026            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1027            val prior = store.loadSlotA()1028            // Rebuild both escrows against the recovered session so their salts rotate1029            // with the password reset; fall back to the prior escrows if rebuild fails1030            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1031            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1032            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1033            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1034            val next = StoredVault(1035                version = prior?.version ?: 1,1036                salt = b64(newSalt),1037                blob = blob,1038                recoveryEscrow = freshRecovery,1039                rootKeyEscrow = freshRootKey,1040            )1041            if (!store.writeSlotA(next)) return false1042            writeMatchingNoiseSlotB(next)1043            store.stampLocalMutation()1044            derivedKey?.let(VaultCrypto::zero)1045            derivedKey = newKey1046            VaultCrypto.zero(plaintext)1047            publish()1048            _isUnlocked.value = true1049            true1050        } catch (_: Exception) {1051            false1052        }1053    }10541055    /**1056     * Seal the current slot-A plaintext under a key derived from the 12 BIP391057     * recovery [words] (Android offline-restore escrow). IKM is the browser's1058     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1059     * Returns null for a checksum-invalid phrase.1060     */1061    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1062        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1063        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1064        val salt = VaultCrypto.generateSalt()1065        val recoveryKey = deriveRecoveryKey(material, salt)1066        val keyBytes = recoveryKey.encoded1067        val plaintext = VaultCodec.encode(currentPayload())1068        return try {1069            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1070            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1071        } finally {1072            VaultCrypto.zero(material)1073            VaultCrypto.zero(keyBytes)1074            VaultCrypto.zero(plaintext)1075        }1076    }10771078    /**1079     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1080     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1081     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1082     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1083     * the same root key on the browser. Returns null with no in-memory root key or1084     * a checksum-invalid phrase.1085     */1086    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1087        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1088        val rk = rootKey ?: return null1089        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1090        val salt = VaultCrypto.generateSalt()1091        val recoveryKey = deriveRecoveryKey(material, salt)1092        val keyBytes = recoveryKey.encoded1093        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1094        return try {1095            // combined = nonce(12) || ciphertext || tag(16)1096            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1097            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1098            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1099            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1100            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1101            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1102        } finally {1103            VaultCrypto.zero(material)1104            VaultCrypto.zero(keyBytes)1105            VaultCrypto.zero(plaintext)1106        }1107    }11081109    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1110    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1111        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1112        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1113        val keyBytes = recoveryKey.encoded1114        return try {1115            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1116            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1117            hexToRootKey(String(pt, Charsets.US_ASCII))1118        } catch (_: Exception) {1119            null1120        } finally {1121            VaultCrypto.zero(material)1122            VaultCrypto.zero(keyBytes)1123        }1124    }11251126    /**1127     * Re-seal slot A from the current in-memory state under the live key, carrying1128     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1129     * in decoy mode (recovery is never offered for the decoy) and persists without1130     * a version bump, like the recovery-words metadata write it replaces.1131     */1132    private fun persistSlotAWithEscrows(1133        recoveryEscrow: RecoveryEscrow?,1134        rootKeyEscrow: RootKeyEscrow?,1135    ): Boolean {1136        val key = derivedKey ?: throw IllegalStateException("vault_locked")1137        if (decoyMode) return false1138        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1139        val plaintext = VaultCodec.encode(currentPayload())1140        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1141        val next = prior.copy(1142            blob = blob,1143            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1144            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1145        )1146        if (!store.writeSlotA(next)) return false1147        try {1148            writeMatchingNoiseSlotB(next)1149        } catch (e: Exception) {1150            store.writeSlotA(prior)1151            throw e1152        }1153        return true1154    }11551156    // ---- Internals --------------------------------------------------------11571158    private fun requireUnlocked() {1159        if (derivedKey == null) throw IllegalStateException("vault_locked")1160    }11611162    private fun countDuplicateEntriesLocked(): Int {1163        val seen = mutableMapOf<String, VaultEntry>()1164        var duplicates = 01165        for (entry in entryList.filter { it.deletedAt == null }) {1166            val key = entry.duplicateKey()1167            val existing = seen[key]1168            if (existing == null) {1169                seen[key] = entry1170            } else {1171                duplicates++1172                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1173                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1174                if (entryStamp > existingStamp) seen[key] = entry1175            }1176        }1177        return duplicates1178    }11791180    private fun VaultEntry.duplicateKey(): String {1181        if (type == "note") return "note|${title.lowercase()}"1182        val passkeyData = passkey1183        if (passkeyData != null) {1184            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1185        }1186        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1187    }11881189    private fun takeVaultSnapshot(reason: String) {1190        runCatching {1191            val slotA = store.loadSlotA() ?: return1192            val vaultA = VaultCodec.encodeSlot(slotA)1193            val vaultB = store.loadSlotB() ?: slotA.blob1194            val history = store.loadVaultHistory()1195            val latest = history.snapshots.firstOrNull()1196            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1197            val entryCount = if (derivedKey != null && !decoyMode) {1198                bucketEntryCount(entryList.count { it.deletedAt == null })1199            } else {1200                null1201            }1202            val snapshot = VaultSnapshot(1203                id = UUID.randomUUID().toString(),1204                savedAt = System.currentTimeMillis(),1205                reason = reason,1206                vault_a = vaultA,1207                vault_b = vaultB,1208                entryCount = entryCount,1209            )1210            store.writeVaultHistory(1211                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1212            )1213        }1214    }12151216    private fun bucketEntryCount(n: Int): Int = when {1217        n <= 0 -> 01218        n <= 10 -> 101219        n <= 25 -> 251220        n <= 50 -> 501221        n <= 100 -> 1001222        n <= 250 -> 2501223        n <= 500 -> 5001224        else -> ((n + 499) / 500) * 5001225    }12261227    /**1228     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1229     * so it is stored explicitly and survives the sync wire format rather than being1230     * re-derived at render time. Non-card entries pass through unchanged.1231     */1232    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1233        if (!entry.isCard()) return entry1234        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1235        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1236    }12371238    /**1239     * On unlock, hard-delete trashed entries older than the retention window.1240     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1241     * (the app's "Never" option), so nothing is purged. Persists without a1242     * version bump and without touching the publish state (caller publishes).1243     */1244    private fun autoPurgeExpiredTrash() {1245        val days = _settings.value.trashRetentionDays ?: return1246        if (days <= 0) return1247        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1248        val before = entryList.size1249        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1250        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1251    }12521253    /** The slot-A plaintext envelope for the current in-memory session. */1254    private fun currentPayload(): VaultPayload =1255        VaultPayload(1256            entries = entryList.toList(),1257            duressConfigured = duressConfigured,1258            rootKeyB64 = rootKey?.let(::b64) ?: "",1259            recoveryWordHash = recoveryWordHash,1260            recoveryWordsSet = recoveryWordsSet,1261        )12621263    /**1264     * Adopt the payload's root key, lazily minting one for legacy payloads.1265     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1266     * a root key is not a mutation paired devices should sync against.1267     */1268    private fun ensureRootKey(payload: VaultPayload) {1269        if (payload.rootKeyB64.isNotBlank()) {1270            rootKey = unb64(payload.rootKeyB64)1271            return1272        }1273        rootKey = generateRecoverableRootKey()1274        runCatching { persistSlotA(bumpVersion = false) }1275    }12761277    private fun publish() {1278        _entries.value = entryList.filter { it.deletedAt == null }1279    }12801281    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1282        val plaintext = VaultCodec.encode(currentPayload())1283        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1284        return StoredVault(version = 1, salt = b64(salt), blob = blob)1285    }12861287    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1288        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1289        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1290        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1291        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1292        val noise = ByteArray(approxBytes).also(rng::nextBytes)1293        store.writeSlotB(b64(noise))1294    }12951296    /** Re-seal slot A from the current in-memory state under the live key. */1297    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1298        val key = derivedKey ?: throw IllegalStateException("vault_locked")1299        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1300        if (decoyMode) {1301            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1302            val plaintext = VaultCodec.encode(currentPayload())1303            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1304            return store.writeDecoySlot(prior.copy(blob = blob))1305        }1306        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1307        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1308        val plaintext = VaultCodec.encode(currentPayload())1309        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1310        val next = prior.copy(blob = blob)1311        if (!store.writeSlotA(next)) return false1312        // Slot A committed — keep slot B size-matched, then advance the version1313        // for local writes only. Restore slot A if a later step fails.1314        try {1315            writeMatchingNoiseSlotB(next)1316            if (bumpVersion) store.stampLocalMutation()1317        } catch (e: Exception) {1318            store.writeSlotA(prior)1319            throw e1320        }1321        return true1322    }13231324    /** Persist a local mutation, rolling the in-memory list back on failure. */1325    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1326        try {1327            if (!persistSlotA(bumpVersion = true)) {1328                entryList = rollback1329                throw IllegalStateException("storage_write_failed")1330            }1331        } catch (e: Exception) {1332            entryList = rollback1333            throw e1334        }1335        publish()1336    }1337}