VaultRepository.kt

61.4 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 android.util.Log12import com.peachpasswords.BuildConfig13import kotlinx.serialization.json.JsonElement14import kotlinx.serialization.json.JsonNull15import kotlinx.serialization.json.JsonObject16import kotlinx.serialization.json.JsonPrimitive17import java.util.Base6418import java.util.UUID19import javax.inject.Inject20import javax.inject.Singleton2122/** Lenient parser for the Codex vault-blob wrapper only. */23private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2425private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)26private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)2728/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */29private const val PASSWORD_HISTORY_MAX = 5030private const val VAULT_HISTORY_MAX = 53132sealed interface UnlockResult {33    data object Success : UnlockResult34    data object WrongPassword : UnlockResult35    data class Error(val reason: String) : UnlockResult36}3738sealed interface CreateVaultResult {39    data object Success : CreateVaultResult40    data class Error(val reason: String) : CreateVaultResult41}4243sealed interface AdoptVaultResult {44    data class Success(val vaultId: String) : AdoptVaultResult45    data class Error(val reason: String) : AdoptVaultResult46}4748/**49 * In-memory vault session backed by [VaultStore]. Faithful port of the browser50 * `vault.ts` semantics that matter for correctness:51 *52 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched53 *    noise blob so a fresh install shows two similarly-shaped records.54 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a55 *    deletion always wins last-write-wins against an in-flight edit.56 *  - Local writes bump the version counter; sync-applied writes do NOT (so a57 *    sync round never cascades into another outbound sync).58 *  - add/update/delete roll back in-memory state if the disk write fails.59 *  - destroyVault never touches the Pro license (separate store).60 */61@Singleton62class VaultRepository @Inject constructor(63    private val store: VaultStorage,64) {65    private val mutex = Mutex()66    private val rng = SecureRandom()6768    private var derivedKey: ByteArray? = null69    private var rootKey: ByteArray? = null70    private var entryList: MutableList<VaultEntry> = mutableListOf()71    private var duressConfigured: Boolean = false72    private var recoveryWordHash: String? = null73    private var recoveryWordsSet: Boolean = false74    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */75    private var decoyMode: Boolean = false7677    private val _isUnlocked = MutableStateFlow(false)78    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()7980    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())81    /** Active (non-deleted) entryList. */82    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8384    private val _settings = MutableStateFlow(VaultSettings())85    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8687    fun hasVault(): Boolean = store.hasVault()8889    // ---- Codex (printed paper backup) -------------------------------------9091    /**92     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the93     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable94     * on any device that has the master password. Null when no vault exists.95     */96    fun codexVaultBlobBase64(): String? {97        val slotA = store.loadSlotA() ?: return null98        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""99        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))100    }101102    /**103     * Replace the local vault from a Codex blob, then unlock with [password].104     * Writes slot A (and a size-matched noise slot B) before unlocking so the105     * restored vault is at rest exactly as a freshly-created one would be.106     */107    /** A slot is stored either as a JSON string (browser) or inline object (Android). */108    private fun JsonElement?.asSlotJson(): String? = when {109        this == null || this is JsonNull -> null110        this is JsonPrimitive && isString -> content111        else -> toString()112    }113114    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {115        return try {116            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)117            // Parsed as JSON, not matched with a regex. The wrapper is118            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which119            // generates most real Codexes — writes each slot as a JSON120            // *string*, with "b" present. A regex looking for an object121            // literal under "a" at the very end of the wrapper matches122            // neither of those, so every browser-generated Codex failed here123            // with "invalid_codex_blob" after decoding perfectly.124            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject125                ?: return UnlockResult.Error("invalid_codex_blob")126            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")127            // A Codex printed from the browser carries a browser-shaped slot,128            // which is the common case — most Codexes are printed there. It is129            // translated to Android's shape (see BrowserSlot) rather than130            // rejected. A translation failure is a wrong password, not a bad131            // blob: the codes decoded fine, the password just didn't open them.132            val slotA = VaultCodec.decodeSlot(slotJson)133                ?: BrowserSlot.parse(slotJson)?.let {134                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword135                }136                ?: return UnlockResult.Error("invalid_codex_blob")137            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")138            // Carry the real slot B across when the Codex has one — it holds a139            // duress vault, which noise would silently destroy.140            val slotB = wrapper["b"].asSlotJson()141            if (slotB != null) {142                store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))143            } else {144                writeMatchingNoiseSlotB(slotA)145            }146            store.stampLocalMutation()147            unlockLocked(password)148        } catch (e: Exception) {149            // A raw kotlinx parse error ("Unexpected JSON token at offset N")150            // reaching the user says nothing about WHICH json failed — the151            // wrapper, or the payload decrypted out of it. Codex screens are152            // FLAG_SECURE so this can't be screenshotted either. Debug builds153            // log the structure around the failure; alphanumerics are masked so154            // no ciphertext or vault content lands in a log.155            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))156            UnlockResult.Error(e.message ?: "restore_failed")157        }158    }159160    /** Debug-only: structure of a failed Codex restore, with content masked. */161    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {162        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)163        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()164        val window = offset?.let {165            val from = (it - 40).coerceAtLeast(0)166            val to = (it + 40).coerceAtMost(wrapperJson.length)167            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")168        }169        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()170        val slot = wrapper?.get("a")171        buildString {172            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)173            append(" | blobBase64=").append(vaultBlobBase64.length)174            append(" wrapperChars=").append(wrapperJson.length)175            append(" wrapperParses=").append(wrapper != null)176            append(" keys=").append(wrapper?.keys)177            append(" slotAKind=").append(178                when {179                    slot == null -> "absent"180                    slot is JsonPrimitive && slot.isString -> "string"181                    slot is JsonObject -> "object:" + slot.keys182                    else -> slot::class.simpleName183                },184            )185            if (window != null) append(" masked@").append(offset).append("=").append(window)186        }187    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }188189    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {190        return try {191            val salt = VaultCrypto.generateSalt()192            val key = VaultCrypto.deriveKey(password, salt)193            entryList = mutableListOf()194            duressConfigured = false195            recoveryWordHash = null196            recoveryWordsSet = false197            decoyMode = false198            rootKey = generateRecoverableRootKey()199            val slotA = sealSlotA(key, salt)200            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")201            writeMatchingNoiseSlotB(slotA)202            store.stampLocalMutation()203            store.writeSettings(_settings.value)204            derivedKey = key205            publish()206            _isUnlocked.value = true207            CreateVaultResult.Success208        } catch (e: Exception) {209            CreateVaultResult.Error(e.message ?: "create_failed")210        }211    }212213    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {214        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")215        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")216        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {217            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")218        }219        var adoptedRootKey: ByteArray? = null220        var nextDerivedKey: ByteArray? = null221        var sessionRootKey: ByteArray? = null222        return try {223            adoptedRootKey = hexToRootKey(rootKeyHex)224            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {225                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")226            }227            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)228            val salt = VaultCrypto.generateSalt()229            nextDerivedKey = VaultCrypto.deriveKey(password, salt)230            sessionRootKey = adoptedRootKey.copyOf()231            val payload = VaultPayload(232                entries = emptyList(),233                duressConfigured = false,234                rootKeyB64 = b64(sessionRootKey),235                recoveryWordHash = null,236                recoveryWordsSet = false,237            )238            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))239            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)240            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")241            writeMatchingNoiseSlotB(slotA)242            store.stampLocalMutation()243            store.writeSettings(_settings.value)244245            val previousDerivedKey = derivedKey246            val previousRootKey = rootKey247            derivedKey = nextDerivedKey248            rootKey = sessionRootKey249            nextDerivedKey = null250            sessionRootKey = null251            entryList = mutableListOf()252            duressConfigured = false253            recoveryWordHash = null254            recoveryWordsSet = false255            decoyMode = false256            publish()257            _isUnlocked.value = true258            previousDerivedKey?.let(VaultCrypto::zero)259            previousRootKey?.let(VaultCrypto::zero)260            AdoptVaultResult.Success(vaultId)261        } catch (e: Exception) {262            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")263        } finally {264            adoptedRootKey?.let(VaultCrypto::zero)265            nextDerivedKey?.let(VaultCrypto::zero)266            sessionRootKey?.let(VaultCrypto::zero)267        }268    }269270    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }271272    /** Unlock body without acquiring the mutex — callers must already hold it. */273    private fun unlockLocked(password: String): UnlockResult {274        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")275        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))276        val payload = try {277            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))278        } catch (_: Exception) {279            VaultCrypto.zero(key)280            // LV-1: a vault sealed before NFC normalization with a non-NFC281            // password derives a different key now. If the supplied password is282            // not already in NFC form, retry with the raw (un-normalized) bytes.283            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {284                val legacy = tryLegacyRawUnlock(password, slotA)285                if (legacy != null) return legacy286            }287            return UnlockResult.WrongPassword288        }289        return adoptSlotA(key, payload)290    }291292    /**293     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw294     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed295     * under the NFC-normalized key — so every future unlock takes the normal296     * path. Returns null when the raw key also fails (genuinely wrong password).297     */298    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {299        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))300        val payload = try {301            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))302        } catch (_: Exception) {303            VaultCrypto.zero(rawKey)304            return null305        }306        // Heal: re-seal slot A under the NFC key (mirrors changePassword's307        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.308        val newSalt = VaultCrypto.generateSalt()309        val newKey = VaultCrypto.deriveKey(password, newSalt)310        val healed = try {311            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))312            val next = StoredVault(313                version = slotA.version,314                salt = b64(newSalt),315                blob = blob,316                recoveryEscrow = slotA.recoveryEscrow,317                rootKeyEscrow = slotA.rootKeyEscrow,318            )319            if (store.writeSlotA(next)) {320                writeMatchingNoiseSlotB(next)321                store.stampLocalMutation()322                true323            } else {324                false325            }326        } catch (_: Exception) {327            false328        }329        return if (healed) {330            VaultCrypto.zero(rawKey)331            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot332        } else {333            VaultCrypto.zero(newKey)334            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key335        }336    }337338    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */339    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {340        return try {341            derivedKey = key342            decoyMode = false343            entryList = payload.entries.toMutableList()344            duressConfigured = payload.duressConfigured345            recoveryWordHash = payload.recoveryWordHash346            recoveryWordsSet = payload.recoveryWordsSet347            _settings.value = store.loadSettings()348            ensureRootKey(payload)349            autoPurgeExpiredTrash()350            publish()351            _isUnlocked.value = true352            UnlockResult.Success353        } catch (e: Exception) {354            UnlockResult.Error(e.message ?: "unlock_failed")355        }356    }357358    // ---- Constant-time unlock (MF-5) --------------------------------------359360    /**361     * A slot-A unlock that has been derived but not yet adopted. Holds the live362     * Argon2id key and decoded payload so the caller can derive several candidates363     * concurrently and commit only the winner. The key is owned by this token until364     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.365     */366    class SlotAUnlock internal constructor(367        internal val key: ByteArray,368        internal val payload: VaultPayload,369    )370371    /**372     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.373     * Pure computation (no mutex), so it can run concurrently with other candidate374     * derivations before the caller decides which to commit (MF-5). Returns null on375     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).376     */377    fun tryUnlockSlotA(password: String): SlotAUnlock? {378        val slotA = store.loadSlotA() ?: return null379        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))380        return try {381            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))382            SlotAUnlock(key, payload)383        } catch (_: Exception) {384            VaultCrypto.zero(key)385            null386        }387    }388389    /** Adopt a previously-derived [SlotAUnlock] as the live session. */390    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {391        adoptSlotA(unlock.key, unlock.payload)392    }393394    /** Zero a derived [SlotAUnlock] that will not be adopted. */395    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)396397    // ---- Duress slot B (MF-6) ---------------------------------------------398399    /**400     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key401     * and decoded decoy payload so it can be derived concurrently with slot A and402     * only committed if the duress passphrase was the one entered (MF-5/MF-6).403     */404    class DuressUnlock internal constructor(405        internal val key: ByteArray,406        internal val payload: VaultPayload,407    )408409    /**410     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the411     * session. Success means the entered password is the duress passphrase that keys412     * slot B — indistinguishable from the real slot A at the lock screen. Pure413     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].414     * Returns null when no duress slot exists or the password is not the duress one.415     */416    fun tryUnlockDuress(password: String): DuressUnlock? {417        val slot = store.loadDecoySlot() ?: return null418        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))419        return try {420            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))421            DuressUnlock(key, payload)422        } catch (_: Exception) {423            VaultCrypto.zero(key)424            null425        }426    }427428    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */429    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {430        return try {431            derivedKey = unlock.key432            decoyMode = true433            entryList = unlock.payload.entries.toMutableList()434            duressConfigured = false435            recoveryWordHash = unlock.payload.recoveryWordHash436            recoveryWordsSet = unlock.payload.recoveryWordsSet437            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)438                      else VaultCrypto.generateRootKey()439            _settings.value = store.loadSettings()440            publish()441            _isUnlocked.value = true442            UnlockResult.Success443        } catch (e: Exception) {444            UnlockResult.Error(e.message ?: "decoy_unlock_failed")445        }446    }447448    /** Zero a derived [DuressUnlock] that will not be adopted. */449    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)450451    /**452     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an453     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The454     * passphrase is never stored — only its ability to decrypt slot B proves it.455     * Requires the real vault to be unlocked and not itself a decoy session.456     */457    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {458        if (derivedKey == null || decoyMode) return false459        // Reject a duress passphrase that also opens the real vault: it would make the460        // lock screen ambiguous (both slots would decrypt for the same input).461        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }462        return try {463            val salt = VaultCrypto.generateSalt()464            val key = VaultCrypto.deriveKey(duressPassphrase, salt)465            // An empty decoy vault with its own fresh sync root key — nothing real leaks.466            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))467            val plaintext = VaultCodec.encode(decoyPayload)468            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))469            VaultCrypto.zero(key)470            VaultCrypto.zero(plaintext)471            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))472            if (ok) {473                duressConfigured = true474                runCatching { persistSlotA(bumpVersion = false) }475            }476            ok477        } catch (_: Exception) {478            false479        }480    }481482    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */483    suspend fun clearDuress(): Boolean = mutex.withLock {484        store.clearDecoySlot()485        if (derivedKey != null && !decoyMode) {486            duressConfigured = false487            runCatching { persistSlotA(bumpVersion = false) }488        }489        true490    }491492    /** Unlock with an already-derived AES-256 key (biometric path). */493    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {494        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")495        return try {496            val payload = VaultCodec.decode(497                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),498            )499            derivedKey = key.copyOf()500            decoyMode = false501            entryList = payload.entries.toMutableList()502            duressConfigured = payload.duressConfigured503            recoveryWordHash = payload.recoveryWordHash504            recoveryWordsSet = payload.recoveryWordsSet505            _settings.value = store.loadSettings()506            ensureRootKey(payload)507            autoPurgeExpiredTrash()508            publish()509            _isUnlocked.value = true510            UnlockResult.Success511        } catch (_: Exception) {512            UnlockResult.WrongPassword513        }514    }515516    fun lock() {517        derivedKey?.let(VaultCrypto::zero)518        rootKey?.let(VaultCrypto::zero)519        derivedKey = null520        rootKey = null521        entryList = mutableListOf()522        duressConfigured = false523        recoveryWordHash = null524        recoveryWordsSet = false525        decoyMode = false526        _entries.value = emptyList()527        _isUnlocked.value = false528    }529530    @Volatile private var suppressNextBackgroundLock = false531532    /**533     * Call immediately before launching a system Activity (a SAF file534     * picker/save dialog, a share sheet) that briefly takes the whole process535     * out of the foreground as an expected part of an in-app flow — not a536     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the537     * two apart on its own (it fires for both), and without this, exporting or538     * importing a file silently locked and wiped the vault mid-flow the moment539     * the picker took over, before its result ever reached the app.540     */541    fun expectTransientBackground() {542        suppressNextBackgroundLock = true543    }544545    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */546    fun consumeTransientBackgroundSuppression(): Boolean {547        val suppressed = suppressNextBackgroundLock548        suppressNextBackgroundLock = false549        return suppressed550    }551552    // ---- Sync identity ----------------------------------------------------553554    /**555     * The vault's stable sync master key (a copy). Caller must zero it. Backs556     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry557     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].558     */559    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()560561    /** Stable per-vault id two paired devices must share to sync. Null when locked. */562    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }563564    /**565     * Browser parity (MV-8): rotate the sync identity by replacing the stable566     * root key. Entries remain under the master-password key, while sync567     * credentials, vault_id, and recovery words all change.568     */569    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {570        val key = derivedKey ?: return false571        if (decoyMode) return false572        val priorSlot = store.loadSlotA() ?: return false573        val priorRootKey = rootKey ?: return false574        val priorRecoveryHash = recoveryWordHash575        val priorRecoverySet = recoveryWordsSet576        val nextRootKey = generateRecoverableRootKey()577        val nextWords = rootKeyToWords(nextRootKey)578579        try {580            takeVaultSnapshot("local_save")581            rootKey = nextRootKey582            recoveryWordHash = recoveryWordsHash(nextWords)583            recoveryWordsSet = false584585            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)586                ?: throw IllegalStateException("recovery_escrow_failed")587            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)588                ?: throw IllegalStateException("root_escrow_failed")589            val plaintext = VaultCodec.encode(currentPayload())590            val blob = try {591                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))592            } finally {593                VaultCrypto.zero(plaintext)594            }595            val nextSlot = priorSlot.copy(596                blob = blob,597                recoveryEscrow = nextRecoveryEscrow,598                rootKeyEscrow = nextRootKeyEscrow,599            )600            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")601            try {602                writeMatchingNoiseSlotB(nextSlot)603                store.stampLocalMutation()604            } catch (e: Exception) {605                store.writeSlotA(priorSlot)606                throw e607            }608            VaultCrypto.zero(priorRootKey)609            publish()610            true611        } catch (_: Exception) {612            rootKey = priorRootKey613            recoveryWordHash = priorRecoveryHash614            recoveryWordsSet = priorRecoverySet615            VaultCrypto.zero(nextRootKey)616            store.writeSlotA(priorSlot)617            false618        }619    }620621    /**622     * A copy of the live Argon2id-derived vault key, for biometric escrow only623     * (wrapped under a hardware KeyStore key on biometric enable). Null when624     * locked. Caller must [VaultCrypto.zero] it after use.625     */626    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()627628    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {629        val slotA = store.loadSlotA() ?: return false630        return try {631            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))632            val payload = VaultCodec.decode(633                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),634            )635            val newSalt = VaultCrypto.generateSalt()636            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)637            // Re-seal the existing payload under the new key/salt.638            val plaintext = VaultCodec.encode(payload)639            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))640            // The escrow seals the slot-A PLAINTEXT, which a password change does not641            // alter — only the wrapping changes — so carry it forward; rebuilding the642            // StoredVault from scratch would silently drop it.643            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key644            // content that a password change does not alter — only the slot-A645            // wrapping rotates. The root-key escrow staying stable across password646            // changes matches the browser (`vault.ts:363-364`).647            val next = StoredVault(648                version = slotA.version,649                salt = b64(newSalt),650                blob = blob,651                recoveryEscrow = slotA.recoveryEscrow,652                rootKeyEscrow = slotA.rootKeyEscrow,653            )654            if (!store.writeSlotA(next)) return false655            writeMatchingNoiseSlotB(next)656            store.stampLocalMutation()657            VaultCrypto.zero(oldKey)658            derivedKey?.let(VaultCrypto::zero)659            derivedKey = newKey660            true661        } catch (_: Exception) {662            false663        }664    }665666    /**667     * Irreversible wipe. Locks the session and clears every vault key. The Pro668     * license is in a separate store and is preserved by construction.669     */670    suspend fun destroyVault() = mutex.withLock {671        lock()672        store.clearVault()673        store.clearDecoySlot()674    }675676    // ---- Entry CRUD -------------------------------------------------------677678    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {679        requireUnlocked()680        val stamp = VaultCrypto.monotonicEntryTimestamp()681        val now = System.currentTimeMillis()682        val entry = withDerivedLast4(draft.copy(683            id = draft.id.ifBlank { UUID.randomUUID().toString() },684            createdAt = if (draft.createdAt > 0) draft.createdAt else now,685            modifiedAt = stamp,686            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,687        ))688        val rollback = entryList.toMutableList()689        entryList.add(entry)690        persistOrRollback(rollback)691        entry692    }693694    /**695     * Insert many entries with a single encrypt-and-write at the end.696     *697     * Importing a real vault through [addEntry] was quadratic: every entry took698     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry699     * restore did 806 full-vault writes and hung the app long enough to ANR.700     * Rolls back to the pre-import list if the one write fails, matching701     * [addEntry]'s all-or-nothing behaviour.702     */703    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {704        requireUnlocked()705        if (drafts.isEmpty()) return 0706        val rollback = entryList.toMutableList()707        val now = System.currentTimeMillis()708        for (draft in drafts) {709            val stamp = VaultCrypto.monotonicEntryTimestamp()710            entryList.add(711                withDerivedLast4(712                    draft.copy(713                        id = draft.id.ifBlank { UUID.randomUUID().toString() },714                        createdAt = if (draft.createdAt > 0) draft.createdAt else now,715                        modifiedAt = stamp,716                        passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,717                    ),718                ),719            )720        }721        persistOrRollback(rollback)722        return drafts.size723    }724725    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {726        requireUnlocked()727        val idx = entryList.indexOfFirst { it.id == patch.id }728        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")729        val current = entryList[idx]730        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)731        // Only refresh passwordChangedAt when the password value actually changes.732        val passwordChanged = patch.password != null && patch.password != current.password733        // Capture the outgoing password (newest first) so the user can reveal/copy/734        // restore it later. History is computed authoritatively from `current`, not735        // the patch — updateEntry is a full-entry replace, so trusting patch's value736        // would let a stale form clobber the real history. Record the age the old737        // password carried, skip an empty prior, and skip an immediate exact dupe738        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.739        val priorPassword = current.password740        val shouldCapture = passwordChanged &&741            !priorPassword.isNullOrEmpty() &&742            current.passwordHistory?.firstOrNull()?.password != priorPassword743        val nextHistory = if (shouldCapture) {744            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +745                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)746        } else {747            current.passwordHistory748        }749        val updated = withDerivedLast4(patch.copy(750            createdAt = current.createdAt,751            modifiedAt = stamp,752            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,753            passwordHistory = nextHistory,754        ))755        val rollback = entryList.toMutableList()756        entryList[idx] = updated757        persistOrRollback(rollback)758        updated759    }760761    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */762    suspend fun deleteEntry(id: String) = mutex.withLock {763        requireUnlocked()764        val idx = entryList.indexOfFirst { it.id == id }765        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")766        val current = entryList[idx]767        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)768        val rollback = entryList.toMutableList()769        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)770        persistOrRollback(rollback)771    }772773    suspend fun restoreEntry(id: String) = mutex.withLock {774        requireUnlocked()775        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }776        if (idx == -1) throw IllegalStateException("deleted entry not found")777        val current = entryList[idx]778        val rollback = entryList.toMutableList()779        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))780        persistOrRollback(rollback)781    }782783    /** Permanent delete — no tombstone, no recovery. */784    suspend fun purgeEntry(id: String) = mutex.withLock {785        requireUnlocked()786        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }787        if (idx == -1) throw IllegalStateException("deleted entry not found")788        val rollback = entryList.toMutableList()789        entryList.removeAt(idx)790        persistOrRollback(rollback)791    }792793    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */794    suspend fun purgeDeletedEntries(): Int = mutex.withLock {795        requireUnlocked()796        val rollback = entryList.toMutableList()797        val before = entryList.size798        entryList.removeAll { it.deletedAt != null }799        val removed = before - entryList.size800        if (removed > 0) persistOrRollback(rollback)801        removed802    }803804    /** Browser parity: number of active entries that dedupe would move to trash. */805    suspend fun countDuplicateEntries(): Int = mutex.withLock {806        requireUnlocked()807        countDuplicateEntriesLocked()808    }809810    /**811     * Browser parity: soft-delete duplicate entries, keeping the most recently812     * modified member of each group. Refuse in decoy mode so the duress vault is813     * not reshaped into a forensically distinct state.814     */815    suspend fun deduplicateEntries(): Int = mutex.withLock {816        requireUnlocked()817        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")818        val active = entryList.filter { it.deletedAt == null }819        val keepIds = mutableSetOf<String>()820        active.groupBy { it.duplicateKey() }.values.forEach { group ->821            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id822        }823824        val rollback = entryList.toMutableList()825        var removed = 0826        entryList = entryList.map { entry ->827            if (entry.deletedAt == null && entry.id !in keepIds) {828                removed++829                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)830                entry.copy(deletedAt = stamp, modifiedAt = stamp)831            } else {832                entry833            }834        }.toMutableList()835        if (removed > 0) persistOrRollback(rollback)836        removed837    }838839    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }840    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }841    fun getDeletedEntries(): List<VaultEntry> =842        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }843844    /** Active entries, optionally narrowed to those carrying [tagFilter]. */845    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {846        val active = entryList.filter { it.deletedAt == null }847        if (tagFilter == null) return active848        val needle = tagFilter.trim().lowercase()849        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }850    }851852    /** Sorted, de-duplicated tags across all active entries. */853    fun getDistinctTags(): List<String> =854        entryList.asSequence()855            .filter { it.deletedAt == null }856            .flatMap { it.tags?.asSequence() ?: emptySequence() }857            .map { it.trim().lowercase() }858            .filter { it.isNotEmpty() }859            .distinct()860            .sorted()861            .toList()862863    /**864     * Distinct non-blank values for an identity field across active entries,865     * most-frequently-used first. Supports "username" and "email"; unknown field866     * names return an empty list. Values keep original casing (deduped867     * case-insensitively) so a suggestion matches what the user typed.868     */869    fun getDistinctFieldValues(field: String): List<String> {870        val select: (VaultEntry) -> String? = when (field) {871            "username" -> { e -> e.username }872            "email" -> { e -> e.email }873            else -> return emptyList()874        }875        return entryList.asSequence()876            .filter { it.deletedAt == null }877            .mapNotNull { select(it)?.trim() }878            .filter { it.isNotEmpty() }879            .groupingBy { it.lowercase() }880            .aggregate { _, acc: Pair<String, Int>?, value, _ ->881                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)882            }883            .values884            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })885            .map { it.first }886    }887888    fun getTombstones(): List<Tombstone> =889        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }890    fun getVaultVersion(): Long = store.version()891    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots892893    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {894        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false895        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false896        takeVaultSnapshot("restore")897        if (!store.writeSlotA(restoredA)) return false898        if (!store.writeSlotB(snapshot.vault_b)) return false899        store.stampLocalMutation()900        lock()901        true902    }903904    // ---- Sync apply (does NOT advance the version) ------------------------905906    /**907     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists908     * WITHOUT bumping the version counter, so a sync round cannot cascade into909     * another outbound sync. Returns the number of locally-applied changes.910     */911    suspend fun applyMergedRemoteEntries(912        remoteEntries: List<VaultEntry>,913        remoteTombstones: List<Tombstone>,914    ): Int = mutex.withLock {915        requireUnlocked()916        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }917        var applied = 0918        val byId = entryList.associateBy { it.id }.toMutableMap()919920        for (remote in remoteEntries) {921            val tomb = tombMap[remote.id]922            if (tomb != null && tomb >= remote.modifiedAt) continue923            val local = byId[remote.id]924            if (local == null) {925                entryList.add(remote); byId[remote.id] = remote; applied++926            } else if (remote.modifiedAt > local.modifiedAt) {927                val i = entryList.indexOfFirst { it.id == local.id }928                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }929            }930        }931932        for ((id, deletedAt) in tombMap) {933            val local = byId[id] ?: continue934            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue935            if (deletedAt < local.modifiedAt) continue936            val i = entryList.indexOfFirst { it.id == id }937            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }938        }939940        if (applied > 0) {941            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")942            publish()943        }944        applied945    }946947    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {948        _settings.value = settings949        store.writeSettings(settings)950    }951952    // ---- Recovery words (vault metadata) ----------------------------------953954    /** True once the user has confirmed their recovery words for this vault. */955    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet956957    /**958     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.959     * Persisted without bumping the version — recovery metadata is local-only and960     * is not an entry mutation paired devices should sync against.961     */962    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {963        requireUnlocked()964        recoveryWordHash = recoveryWordsHash(words)965        recoveryWordsSet = true966        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow967        // (cross-platform recovery of the sync root key) and Android's whole-payload968        // escrow (offline forgot-password restore of entries). Both derive their key969        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.970        runCatching {971            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))972        }.getOrDefault(false)973    }974975    /** Verify entered words against the stored recovery hash. */976    fun verifyRecoveryWords(words: List<String>): Boolean {977        val stored = recoveryWordHash ?: return false978        return stored == recoveryWordsHash(words)979    }980981    // ---- Recovery escrow (forgot-password) --------------------------------982983    /** True at rest if this vault has any recovery escrow the recovery flow can use. */984    fun hasRecoveryEscrow(): Boolean =985        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false986987    /**988     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of989     * the master password. On success the slot-A plaintext sealed in the escrow is990     * adopted as the live session (entries, rootKey, settings), but no Argon2id991     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a992     * new password and finish unlocking. Returns false when there is no escrow, the993     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).994     *995     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no996     * detached vault key to return here — slot A is wrapped under the rotating997     * Argon2id key — so this opens the escrowed payload directly.998     */999    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1000        val slotA = store.loadSlotA() ?: return false1001        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false10021003        // Prefer Android's whole-payload escrow: a full offline restore of entries,1004        // root key, and settings. Present for Android-created vaults.1005        slotA.recoveryEscrow?.let { escrow ->1006            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1007            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1008            val keyBytes = recoveryKey.encoded1009            try {1010                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1011                val payload = VaultCodec.decode(plaintext)1012                decoyMode = false1013                entryList = payload.entries.toMutableList()1014                duressConfigured = payload.duressConfigured1015                recoveryWordHash = payload.recoveryWordHash1016                recoveryWordsSet = payload.recoveryWordsSet1017                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1018                          else generateRecoverableRootKey()1019                _settings.value = store.loadSettings()1020                VaultCrypto.zero(plaintext)1021                return true1022            } catch (_: Exception) {1023                // Wrong words against this escrow — fall through to the root-key escrow.1024            } finally {1025                VaultCrypto.zero(material)1026                VaultCrypto.zero(keyBytes)1027            }1028        }10291030        // Browser-format vault (only the root-key escrow): recover the 32-byte root1031        // key (sync identity) and reset the password; entries return via sync. This1032        // is exactly the browser's recovery semantics (`messages.ts:312-347`).1033        slotA.rootKeyEscrow?.let { escrow ->1034            val rk = unsealRootKeyEscrow(words, escrow) ?: return false1035            decoyMode = false1036            entryList = mutableListOf()1037            duressConfigured = false1038            recoveryWordHash = recoveryWordsHash(words)1039            recoveryWordsSet = true1040            rootKey = rk1041            _settings.value = store.loadSettings()1042            return true1043        }1044        return false1045    }10461047    /**1048     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1049     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1050     * words, mark the session fully unlocked, and publish. Returns false if called1051     * without a prior successful recovery unlock (no in-memory root key).1052     *1053     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1054     * into one step, since there is no detached vault key here.1055     */1056    suspend fun resetPasswordWithRecoveryWords(1057        words: List<String>,1058        newPassword: String,1059    ): Boolean = mutex.withLock {1060        if (rootKey == null) return false // must have recovered first1061        return try {1062            val newSalt = VaultCrypto.generateSalt()1063            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1064            val plaintext = VaultCodec.encode(currentPayload())1065            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1066            val prior = store.loadSlotA()1067            // Rebuild both escrows against the recovered session so their salts rotate1068            // with the password reset; fall back to the prior escrows if rebuild fails1069            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1070            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1071            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1072            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1073            val next = StoredVault(1074                version = prior?.version ?: 1,1075                salt = b64(newSalt),1076                blob = blob,1077                recoveryEscrow = freshRecovery,1078                rootKeyEscrow = freshRootKey,1079            )1080            if (!store.writeSlotA(next)) return false1081            writeMatchingNoiseSlotB(next)1082            store.stampLocalMutation()1083            derivedKey?.let(VaultCrypto::zero)1084            derivedKey = newKey1085            VaultCrypto.zero(plaintext)1086            publish()1087            _isUnlocked.value = true1088            true1089        } catch (_: Exception) {1090            false1091        }1092    }10931094    /**1095     * Seal the current slot-A plaintext under a key derived from the 12 BIP391096     * recovery [words] (Android offline-restore escrow). IKM is the browser's1097     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1098     * Returns null for a checksum-invalid phrase.1099     */1100    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1101        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1102        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1103        val salt = VaultCrypto.generateSalt()1104        val recoveryKey = deriveRecoveryKey(material, salt)1105        val keyBytes = recoveryKey.encoded1106        val plaintext = VaultCodec.encode(currentPayload())1107        return try {1108            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1109            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1110        } finally {1111            VaultCrypto.zero(material)1112            VaultCrypto.zero(keyBytes)1113            VaultCrypto.zero(plaintext)1114        }1115    }11161117    /**1118     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1119     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1120     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1121     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1122     * the same root key on the browser. Returns null with no in-memory root key or1123     * a checksum-invalid phrase.1124     */1125    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1126        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1127        val rk = rootKey ?: return null1128        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1129        val salt = VaultCrypto.generateSalt()1130        val recoveryKey = deriveRecoveryKey(material, salt)1131        val keyBytes = recoveryKey.encoded1132        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1133        return try {1134            // combined = nonce(12) || ciphertext || tag(16)1135            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1136            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1137            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1138            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1139            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1140            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1141        } finally {1142            VaultCrypto.zero(material)1143            VaultCrypto.zero(keyBytes)1144            VaultCrypto.zero(plaintext)1145        }1146    }11471148    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1149    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1150        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1151        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1152        val keyBytes = recoveryKey.encoded1153        return try {1154            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1155            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1156            hexToRootKey(String(pt, Charsets.US_ASCII))1157        } catch (_: Exception) {1158            null1159        } finally {1160            VaultCrypto.zero(material)1161            VaultCrypto.zero(keyBytes)1162        }1163    }11641165    /**1166     * Re-seal slot A from the current in-memory state under the live key, carrying1167     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1168     * in decoy mode (recovery is never offered for the decoy) and persists without1169     * a version bump, like the recovery-words metadata write it replaces.1170     */1171    private fun persistSlotAWithEscrows(1172        recoveryEscrow: RecoveryEscrow?,1173        rootKeyEscrow: RootKeyEscrow?,1174    ): Boolean {1175        val key = derivedKey ?: throw IllegalStateException("vault_locked")1176        if (decoyMode) return false1177        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1178        val plaintext = VaultCodec.encode(currentPayload())1179        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1180        val next = prior.copy(1181            blob = blob,1182            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1183            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1184        )1185        if (!store.writeSlotA(next)) return false1186        try {1187            writeMatchingNoiseSlotB(next)1188        } catch (e: Exception) {1189            store.writeSlotA(prior)1190            throw e1191        }1192        return true1193    }11941195    // ---- Internals --------------------------------------------------------11961197    private fun requireUnlocked() {1198        if (derivedKey == null) throw IllegalStateException("vault_locked")1199    }12001201    private fun countDuplicateEntriesLocked(): Int {1202        val seen = mutableMapOf<String, VaultEntry>()1203        var duplicates = 01204        for (entry in entryList.filter { it.deletedAt == null }) {1205            val key = entry.duplicateKey()1206            val existing = seen[key]1207            if (existing == null) {1208                seen[key] = entry1209            } else {1210                duplicates++1211                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1212                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1213                if (entryStamp > existingStamp) seen[key] = entry1214            }1215        }1216        return duplicates1217    }12181219    private fun VaultEntry.duplicateKey(): String {1220        if (type == "note") return "note|${title.lowercase()}"1221        val passkeyData = passkey1222        if (passkeyData != null) {1223            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1224        }1225        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1226    }12271228    private fun takeVaultSnapshot(reason: String) {1229        runCatching {1230            val slotA = store.loadSlotA() ?: return1231            val vaultA = VaultCodec.encodeSlot(slotA)1232            val vaultB = store.loadSlotB() ?: slotA.blob1233            val history = store.loadVaultHistory()1234            val latest = history.snapshots.firstOrNull()1235            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1236            val entryCount = if (derivedKey != null && !decoyMode) {1237                bucketEntryCount(entryList.count { it.deletedAt == null })1238            } else {1239                null1240            }1241            val snapshot = VaultSnapshot(1242                id = UUID.randomUUID().toString(),1243                savedAt = System.currentTimeMillis(),1244                reason = reason,1245                vault_a = vaultA,1246                vault_b = vaultB,1247                entryCount = entryCount,1248            )1249            store.writeVaultHistory(1250                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1251            )1252        }1253    }12541255    private fun bucketEntryCount(n: Int): Int = when {1256        n <= 0 -> 01257        n <= 10 -> 101258        n <= 25 -> 251259        n <= 50 -> 501260        n <= 100 -> 1001261        n <= 250 -> 2501262        n <= 500 -> 5001263        else -> ((n + 499) / 500) * 5001264    }12651266    /**1267     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1268     * so it is stored explicitly and survives the sync wire format rather than being1269     * re-derived at render time. Non-card entries pass through unchanged.1270     */1271    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1272        if (!entry.isCard()) return entry1273        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1274        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1275    }12761277    /**1278     * On unlock, hard-delete trashed entries older than the retention window.1279     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1280     * (the app's "Never" option), so nothing is purged. Persists without a1281     * version bump and without touching the publish state (caller publishes).1282     */1283    private fun autoPurgeExpiredTrash() {1284        val days = _settings.value.trashRetentionDays ?: return1285        if (days <= 0) return1286        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1287        val before = entryList.size1288        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1289        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1290    }12911292    /** The slot-A plaintext envelope for the current in-memory session. */1293    private fun currentPayload(): VaultPayload =1294        VaultPayload(1295            entries = entryList.toList(),1296            duressConfigured = duressConfigured,1297            rootKeyB64 = rootKey?.let(::b64) ?: "",1298            recoveryWordHash = recoveryWordHash,1299            recoveryWordsSet = recoveryWordsSet,1300        )13011302    /**1303     * Adopt the payload's root key, lazily minting one for legacy payloads.1304     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1305     * a root key is not a mutation paired devices should sync against.1306     */1307    private fun ensureRootKey(payload: VaultPayload) {1308        if (payload.rootKeyB64.isNotBlank()) {1309            rootKey = unb64(payload.rootKeyB64)1310            return1311        }1312        rootKey = generateRecoverableRootKey()1313        runCatching { persistSlotA(bumpVersion = false) }1314    }13151316    private fun publish() {1317        _entries.value = entryList.filter { it.deletedAt == null }1318    }13191320    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1321        val plaintext = VaultCodec.encode(currentPayload())1322        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1323        return StoredVault(version = 1, salt = b64(salt), blob = blob)1324    }13251326    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1327        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1328        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1329        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1330        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1331        val noise = ByteArray(approxBytes).also(rng::nextBytes)1332        store.writeSlotB(b64(noise))1333    }13341335    /** Re-seal slot A from the current in-memory state under the live key. */1336    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1337        val key = derivedKey ?: throw IllegalStateException("vault_locked")1338        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1339        if (decoyMode) {1340            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1341            val plaintext = VaultCodec.encode(currentPayload())1342            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1343            return store.writeDecoySlot(prior.copy(blob = blob))1344        }1345        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1346        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1347        val plaintext = VaultCodec.encode(currentPayload())1348        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1349        val next = prior.copy(blob = blob)1350        if (!store.writeSlotA(next)) return false1351        // Slot A committed — keep slot B size-matched, then advance the version1352        // for local writes only. Restore slot A if a later step fails.1353        try {1354            writeMatchingNoiseSlotB(next)1355            if (bumpVersion) store.stampLocalMutation()1356        } catch (e: Exception) {1357            store.writeSlotA(prior)1358            throw e1359        }1360        return true1361    }13621363    /** Persist a local mutation, rolling the in-memory list back on failure. */1364    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1365        try {1366            if (!persistSlotA(bumpVersion = true)) {1367                entryList = rollback1368                throw IllegalStateException("storage_write_failed")1369            }1370        } catch (e: Exception) {1371            entryList = rollback1372            throw e1373        }1374        publish()1375    }1376}