VaultRepository.kt

66.7 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 com.peachpasswords.sync.SyncLimits14import kotlinx.serialization.json.JsonElement15import kotlinx.serialization.json.JsonNull16import kotlinx.serialization.json.JsonObject17import kotlinx.serialization.json.JsonPrimitive18import java.util.Base6419import java.util.UUID20import javax.inject.Inject21import javax.inject.Singleton2223/** Lenient parser for the Codex vault-blob wrapper only. */24private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2526private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)27private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)2829/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */30private const val PASSWORD_HISTORY_MAX = 5031private const val VAULT_HISTORY_MAX = 53233sealed interface UnlockResult {34    data object Success : UnlockResult35    data object WrongPassword : UnlockResult36    data class Error(val reason: String) : UnlockResult37}3839sealed interface CreateVaultResult {40    data object Success : CreateVaultResult41    data class Error(val reason: String) : CreateVaultResult42}4344sealed interface AdoptVaultResult {45    data class Success(val vaultId: String) : AdoptVaultResult46    data class Error(val reason: String) : AdoptVaultResult47}4849/**50 * In-memory vault session backed by [VaultStore]. Faithful port of the browser51 * `vault.ts` semantics that matter for correctness:52 *53 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched54 *    noise blob so a fresh install shows two similarly-shaped records.55 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a56 *    deletion always wins last-write-wins against an in-flight edit.57 *  - Local writes bump the version counter; sync-applied writes do NOT (so a58 *    sync round never cascades into another outbound sync).59 *  - add/update/delete roll back in-memory state if the disk write fails.60 *  - destroyVault never touches the Pro license (separate store).61 */62@Singleton63class VaultRepository @Inject constructor(64    private val store: VaultStorage,65) {66    private val mutex = Mutex()67    private val rng = SecureRandom()6869    private var derivedKey: ByteArray? = null70    private var rootKey: ByteArray? = null71    private var entryList: MutableList<VaultEntry> = mutableListOf()72    private var duressConfigured: Boolean = false73    private var recoveryWordHash: String? = null74    private var recoveryWordsSet: Boolean = false75    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */76    private var decoyMode: Boolean = false7778    private val _isUnlocked = MutableStateFlow(false)79    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()8081    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())82    /** Active (non-deleted) entryList. */83    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8485    private val _settings = MutableStateFlow(VaultSettings())86    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8788    fun hasVault(): Boolean = store.hasVault()8990    // ---- Codex (printed paper backup) -------------------------------------9192    /**93     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the94     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable95     * on any device that has the master password. Null when no vault exists.96     */97    fun codexVaultBlobBase64(): String? {98        val slotA = store.loadSlotA() ?: return null99        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""100        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))101    }102103    /**104     * Replace the local vault from a Codex blob, then unlock with [password].105     * Writes slot A (and a size-matched noise slot B) before unlocking so the106     * restored vault is at rest exactly as a freshly-created one would be.107     */108    /** A slot is stored either as a JSON string (browser) or inline object (Android). */109    private fun JsonElement?.asSlotJson(): String? = when {110        this == null || this is JsonNull -> null111        this is JsonPrimitive && isString -> content112        else -> toString()113    }114115    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {116        return try {117            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)118            // Parsed as JSON, not matched with a regex. The wrapper is119            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which120            // generates most real Codexes — writes each slot as a JSON121            // *string*, with "b" present. A regex looking for an object122            // literal under "a" at the very end of the wrapper matches123            // neither of those, so every browser-generated Codex failed here124            // with "invalid_codex_blob" after decoding perfectly.125            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject126                ?: return UnlockResult.Error("invalid_codex_blob")127            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")128            // A Codex printed from the browser carries a browser-shaped slot,129            // which is the common case — most Codexes are printed there. It is130            // translated to Android's shape (see BrowserSlot) rather than131            // rejected. A translation failure is a wrong password, not a bad132            // blob: the codes decoded fine, the password just didn't open them.133            val slotA = VaultCodec.decodeSlot(slotJson)134                ?: BrowserSlot.parse(slotJson)?.let {135                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword136                }137                ?: return UnlockResult.Error("invalid_codex_blob")138            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")139            // Carry the real slot B across when the Codex has one — it holds a140            // duress vault, which noise would silently destroy.141            val slotB = wrapper["b"].asSlotJson()142            if (slotB != null) {143                store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))144            } else {145                writeMatchingNoiseSlotB(slotA)146            }147            store.stampLocalMutation()148            unlockLocked(password)149        } catch (e: Exception) {150            // A raw kotlinx parse error ("Unexpected JSON token at offset N")151            // reaching the user says nothing about WHICH json failed — the152            // wrapper, or the payload decrypted out of it. Codex screens are153            // FLAG_SECURE so this can't be screenshotted either. Debug builds154            // log the structure around the failure; alphanumerics are masked so155            // no ciphertext or vault content lands in a log.156            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))157            UnlockResult.Error(e.message ?: "restore_failed")158        }159    }160161    /** Debug-only: structure of a failed Codex restore, with content masked. */162    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {163        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)164        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()165        val window = offset?.let {166            val from = (it - 40).coerceAtLeast(0)167            val to = (it + 40).coerceAtMost(wrapperJson.length)168            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")169        }170        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()171        val slot = wrapper?.get("a")172        buildString {173            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)174            append(" | blobBase64=").append(vaultBlobBase64.length)175            append(" wrapperChars=").append(wrapperJson.length)176            append(" wrapperParses=").append(wrapper != null)177            append(" keys=").append(wrapper?.keys)178            append(" slotAKind=").append(179                when {180                    slot == null -> "absent"181                    slot is JsonPrimitive && slot.isString -> "string"182                    slot is JsonObject -> "object:" + slot.keys183                    else -> slot::class.simpleName184                },185            )186            if (window != null) append(" masked@").append(offset).append("=").append(window)187        }188    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }189190    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {191        return try {192            val salt = VaultCrypto.generateSalt()193            val key = VaultCrypto.deriveKey(password, salt)194            entryList = mutableListOf()195            duressConfigured = false196            recoveryWordHash = null197            recoveryWordsSet = false198            decoyMode = false199            rootKey = generateRecoverableRootKey()200            val slotA = sealSlotA(key, salt)201            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")202            writeMatchingNoiseSlotB(slotA)203            store.stampLocalMutation()204            store.writeSettings(_settings.value)205            derivedKey = key206            publish()207            _isUnlocked.value = true208            CreateVaultResult.Success209        } catch (e: Exception) {210            CreateVaultResult.Error(e.message ?: "create_failed")211        }212    }213214    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {215        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")216        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")217        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {218            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")219        }220        var adoptedRootKey: ByteArray? = null221        var nextDerivedKey: ByteArray? = null222        var sessionRootKey: ByteArray? = null223        return try {224            adoptedRootKey = hexToRootKey(rootKeyHex)225            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {226                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")227            }228            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)229            val salt = VaultCrypto.generateSalt()230            nextDerivedKey = VaultCrypto.deriveKey(password, salt)231            sessionRootKey = adoptedRootKey.copyOf()232            val payload = VaultPayload(233                entries = emptyList(),234                duressConfigured = false,235                rootKeyB64 = b64(sessionRootKey),236                recoveryWordHash = null,237                recoveryWordsSet = false,238            )239            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))240            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)241            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")242            writeMatchingNoiseSlotB(slotA)243            store.stampLocalMutation()244            store.writeSettings(_settings.value)245246            val previousDerivedKey = derivedKey247            val previousRootKey = rootKey248            derivedKey = nextDerivedKey249            rootKey = sessionRootKey250            nextDerivedKey = null251            sessionRootKey = null252            entryList = mutableListOf()253            duressConfigured = false254            recoveryWordHash = null255            recoveryWordsSet = false256            decoyMode = false257            publish()258            _isUnlocked.value = true259            previousDerivedKey?.let(VaultCrypto::zero)260            previousRootKey?.let(VaultCrypto::zero)261            AdoptVaultResult.Success(vaultId)262        } catch (e: Exception) {263            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")264        } finally {265            adoptedRootKey?.let(VaultCrypto::zero)266            nextDerivedKey?.let(VaultCrypto::zero)267            sessionRootKey?.let(VaultCrypto::zero)268        }269    }270271    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }272273    /** Unlock body without acquiring the mutex — callers must already hold it. */274    private fun unlockLocked(password: String): UnlockResult {275        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")276        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))277        val payload = try {278            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))279        } catch (_: Exception) {280            VaultCrypto.zero(key)281            // LV-1: a vault sealed before NFC normalization with a non-NFC282            // password derives a different key now. If the supplied password is283            // not already in NFC form, retry with the raw (un-normalized) bytes.284            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {285                val legacy = tryLegacyRawUnlock(password, slotA)286                if (legacy != null) return legacy287            }288            return UnlockResult.WrongPassword289        }290        return adoptSlotA(key, payload)291    }292293    /**294     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw295     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed296     * under the NFC-normalized key — so every future unlock takes the normal297     * path. Returns null when the raw key also fails (genuinely wrong password).298     */299    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {300        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))301        val payload = try {302            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))303        } catch (_: Exception) {304            VaultCrypto.zero(rawKey)305            return null306        }307        // Heal: re-seal slot A under the NFC key (mirrors changePassword's308        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.309        val newSalt = VaultCrypto.generateSalt()310        val newKey = VaultCrypto.deriveKey(password, newSalt)311        val healed = try {312            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))313            val next = StoredVault(314                version = slotA.version,315                salt = b64(newSalt),316                blob = blob,317                recoveryEscrow = slotA.recoveryEscrow,318                rootKeyEscrow = slotA.rootKeyEscrow,319            )320            if (store.writeSlotA(next)) {321                writeMatchingNoiseSlotB(next)322                store.stampLocalMutation()323                true324            } else {325                false326            }327        } catch (_: Exception) {328            false329        }330        return if (healed) {331            VaultCrypto.zero(rawKey)332            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot333        } else {334            VaultCrypto.zero(newKey)335            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key336        }337    }338339    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */340    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {341        return try {342            derivedKey = key343            decoyMode = false344            entryList = payload.entries.toMutableList()345            duressConfigured = payload.duressConfigured346            recoveryWordHash = payload.recoveryWordHash347            recoveryWordsSet = payload.recoveryWordsSet348            _settings.value = store.loadSettings()349            ensureRootKey(payload)350            autoPurgeExpiredTrash()351            publish()352            _isUnlocked.value = true353            UnlockResult.Success354        } catch (e: Exception) {355            UnlockResult.Error(e.message ?: "unlock_failed")356        }357    }358359    // ---- Constant-time unlock (MF-5) --------------------------------------360361    /**362     * A slot-A unlock that has been derived but not yet adopted. Holds the live363     * Argon2id key and decoded payload so the caller can derive several candidates364     * concurrently and commit only the winner. The key is owned by this token until365     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.366     */367    class SlotAUnlock internal constructor(368        internal val key: ByteArray,369        internal val payload: VaultPayload,370    )371372    /**373     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.374     * Pure computation (no mutex), so it can run concurrently with other candidate375     * derivations before the caller decides which to commit (MF-5). Returns null on376     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).377     */378    fun tryUnlockSlotA(password: String): SlotAUnlock? {379        val slotA = store.loadSlotA() ?: return null380        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))381        return try {382            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))383            SlotAUnlock(key, payload)384        } catch (_: Exception) {385            VaultCrypto.zero(key)386            null387        }388    }389390    /** Adopt a previously-derived [SlotAUnlock] as the live session. */391    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {392        adoptSlotA(unlock.key, unlock.payload)393    }394395    /** Zero a derived [SlotAUnlock] that will not be adopted. */396    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)397398    // ---- Duress slot B (MF-6) ---------------------------------------------399400    /**401     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key402     * and decoded decoy payload so it can be derived concurrently with slot A and403     * only committed if the duress passphrase was the one entered (MF-5/MF-6).404     */405    class DuressUnlock internal constructor(406        internal val key: ByteArray,407        internal val payload: VaultPayload,408    )409410    /**411     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the412     * session. Success means the entered password is the duress passphrase that keys413     * slot B — indistinguishable from the real slot A at the lock screen. Pure414     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].415     * Returns null when no duress slot exists or the password is not the duress one.416     */417    fun tryUnlockDuress(password: String): DuressUnlock? {418        val slot = store.loadDecoySlot() ?: return null419        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))420        return try {421            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))422            DuressUnlock(key, payload)423        } catch (_: Exception) {424            VaultCrypto.zero(key)425            null426        }427    }428429    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */430    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {431        return try {432            derivedKey = unlock.key433            decoyMode = true434            entryList = unlock.payload.entries.toMutableList()435            duressConfigured = false436            recoveryWordHash = unlock.payload.recoveryWordHash437            recoveryWordsSet = unlock.payload.recoveryWordsSet438            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)439                      else VaultCrypto.generateRootKey()440            _settings.value = store.loadSettings()441            publish()442            _isUnlocked.value = true443            UnlockResult.Success444        } catch (e: Exception) {445            UnlockResult.Error(e.message ?: "decoy_unlock_failed")446        }447    }448449    /** Zero a derived [DuressUnlock] that will not be adopted. */450    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)451452    /**453     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an454     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The455     * passphrase is never stored — only its ability to decrypt slot B proves it.456     * Requires the real vault to be unlocked and not itself a decoy session.457     */458    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {459        if (derivedKey == null || decoyMode) return false460        // Reject a duress passphrase that also opens the real vault: it would make the461        // lock screen ambiguous (both slots would decrypt for the same input).462        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }463        return try {464            val salt = VaultCrypto.generateSalt()465            val key = VaultCrypto.deriveKey(duressPassphrase, salt)466            // An empty decoy vault with its own fresh sync root key — nothing real leaks.467            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))468            val plaintext = VaultCodec.encode(decoyPayload)469            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))470            VaultCrypto.zero(key)471            VaultCrypto.zero(plaintext)472            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))473            if (ok) {474                duressConfigured = true475                runCatching { persistSlotA(bumpVersion = false) }476            }477            ok478        } catch (_: Exception) {479            false480        }481    }482483    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */484    suspend fun clearDuress(): Boolean = mutex.withLock {485        store.clearDecoySlot()486        if (derivedKey != null && !decoyMode) {487            duressConfigured = false488            runCatching { persistSlotA(bumpVersion = false) }489        }490        true491    }492493    /** Unlock with an already-derived AES-256 key (biometric path). */494    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {495        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")496        return try {497            val payload = VaultCodec.decode(498                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),499            )500            derivedKey = key.copyOf()501            decoyMode = false502            entryList = payload.entries.toMutableList()503            duressConfigured = payload.duressConfigured504            recoveryWordHash = payload.recoveryWordHash505            recoveryWordsSet = payload.recoveryWordsSet506            _settings.value = store.loadSettings()507            ensureRootKey(payload)508            autoPurgeExpiredTrash()509            publish()510            _isUnlocked.value = true511            UnlockResult.Success512        } catch (_: Exception) {513            UnlockResult.WrongPassword514        }515    }516517    fun lock() {518        derivedKey?.let(VaultCrypto::zero)519        rootKey?.let(VaultCrypto::zero)520        derivedKey = null521        rootKey = null522        entryList = mutableListOf()523        duressConfigured = false524        recoveryWordHash = null525        recoveryWordsSet = false526        decoyMode = false527        _entries.value = emptyList()528        _isUnlocked.value = false529    }530531    @Volatile private var suppressNextBackgroundLock = false532533    /**534     * Call immediately before launching a system Activity (a SAF file535     * picker/save dialog, a share sheet) that briefly takes the whole process536     * out of the foreground as an expected part of an in-app flow — not a537     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the538     * two apart on its own (it fires for both), and without this, exporting or539     * importing a file silently locked and wiped the vault mid-flow the moment540     * the picker took over, before its result ever reached the app.541     */542    fun expectTransientBackground() {543        suppressNextBackgroundLock = true544    }545546    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */547    fun consumeTransientBackgroundSuppression(): Boolean {548        val suppressed = suppressNextBackgroundLock549        suppressNextBackgroundLock = false550        return suppressed551    }552553    // ---- Sync identity ----------------------------------------------------554555    /**556     * The vault's stable sync master key (a copy). Caller must zero it. Backs557     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry558     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].559     */560    /**561     * HOLE 5 — the duress vault never syncs.562     *563     * [commitDuressUnlock] gives the decoy session a root key so its own codex564     * and recovery paths work, which means (unlike the browser, where the decoy565     * path simply has no root key) the sync surface has to refuse explicitly.566     * Without this the decoy vault would present a second, different vault_id567     * to a paired device, and a coerced user opening it would make sync visibly568     * fail on their other devices — a duress indicator, which is the one thing569     * a duress vault must never be.570     */571    fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf()572573    /**574     * The current session's root key, decoy included. For local-only uses that575     * must behave identically in a decoy session — Codex backup and recovery576     * words — because a feature that errors under duress is itself a duress577     * tell. In a decoy session this is the DECOY's root key (see578     * [commitDuressUnlock]), never the real vault's, so neither surface can579     * leak the real vault. Anything that leaves the device must use580     * [syncMasterKey] instead.581     */582    fun sessionRootKey(): ByteArray? = rootKey?.copyOf()583584    /** Stable per-vault id two paired devices must share to sync. Null when585     *  locked, and never present for the decoy vault (see [syncMasterKey]). */586    fun syncVaultId(): String? =587        if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }588589    /**590     * Browser parity (MV-8): rotate the sync identity by replacing the stable591     * root key. Entries remain under the master-password key, while sync592     * credentials, vault_id, and recovery words all change.593     */594    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {595        installRootKey(generateRecoverableRootKey())596    }597598    /**599     * Join the vault behind [rootKeyHex]: adopt that vault's identity while600     * keeping every local entry.601     *602     * Sync identity (vault id, signing keys) derives from the root key, and603     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently604     * created vaults can never reconcile. Taking the other side's root key is605     * what makes them one vault; from the next sync on, both entry sets merge.606     *607     * Entries survive because they are sealed under the password-derived key,608     * not the root key — the same property [rotateSyncIdentity] relies on.609     *610     * CONSEQUENCE, which the caller must have disclosed and had confirmed:611     * this device's previous recovery words stop working for good, and its new612     * words are the source vault's words, because words are a deterministic613     * encoding of the root key.614     */615    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {616        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {617            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")618        }619        if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")620        if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")621        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")622623        val incoming = hexToRootKey(rootKeyHex)624        if (incoming.size != VaultCrypto.KEY_SIZE) {625            VaultCrypto.zero(incoming)626            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")627        }628        // Refuse before committing rather than leave this device holding a vault629        // whose recovery words cannot be typed back. generateRecoverableRootKey630        // screens its own keys for this; a key arriving from a peer has not been.631        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess632        if (!roundTrips) {633            VaultCrypto.zero(incoming)634            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")635        }636637        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)638        return if (installRootKey(incoming)) {639            AdoptVaultResult.Success(vaultId)640        } else {641            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")642        }643    }644645    /**646     * The recovery words [rootKeyHex] will produce once joined, or null if it is647     * not a usable root key. Lets the join flow show the user the words that are648     * about to replace theirs *before* anything is committed.649     */650    fun previewRecoveryWords(rootKeyHex: String): List<String>? {651        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null652        val bytes = hexToRootKey(rootKeyHex)653        return try {654            val words = rootKeyToWords(bytes)655            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null656        } catch (_: Exception) {657            null658        } finally {659            VaultCrypto.zero(bytes)660        }661    }662663    /**664     * Swap the vault's root key, keeping every entry, and re-escrow under the665     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds666     * [mutex].667     */668    private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {669        val key = derivedKey ?: return false670        if (decoyMode) return false671        val priorSlot = store.loadSlotA() ?: return false672        val priorRootKey = rootKey ?: return false673        val priorRecoveryHash = recoveryWordHash674        val priorRecoverySet = recoveryWordsSet675        val nextWords = rootKeyToWords(nextRootKey)676677        return try {678            takeVaultSnapshot("local_save")679            rootKey = nextRootKey680            recoveryWordHash = recoveryWordsHash(nextWords)681            recoveryWordsSet = false682683            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)684                ?: throw IllegalStateException("recovery_escrow_failed")685            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)686                ?: throw IllegalStateException("root_escrow_failed")687            val plaintext = VaultCodec.encode(currentPayload())688            val blob = try {689                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))690            } finally {691                VaultCrypto.zero(plaintext)692            }693            val nextSlot = priorSlot.copy(694                blob = blob,695                recoveryEscrow = nextRecoveryEscrow,696                rootKeyEscrow = nextRootKeyEscrow,697            )698            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")699            try {700                writeMatchingNoiseSlotB(nextSlot)701                store.stampLocalMutation()702            } catch (e: Exception) {703                store.writeSlotA(priorSlot)704                throw e705            }706            VaultCrypto.zero(priorRootKey)707            publish()708            true709        } catch (_: Exception) {710            rootKey = priorRootKey711            recoveryWordHash = priorRecoveryHash712            recoveryWordsSet = priorRecoverySet713            VaultCrypto.zero(nextRootKey)714            store.writeSlotA(priorSlot)715            false716        }717    }718719    /**720     * A copy of the live Argon2id-derived vault key, for biometric escrow only721     * (wrapped under a hardware KeyStore key on biometric enable). Null when722     * locked. Caller must [VaultCrypto.zero] it after use.723     */724    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()725726    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {727        val slotA = store.loadSlotA() ?: return false728        return try {729            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))730            val payload = VaultCodec.decode(731                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),732            )733            val newSalt = VaultCrypto.generateSalt()734            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)735            // Re-seal the existing payload under the new key/salt.736            val plaintext = VaultCodec.encode(payload)737            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))738            // The escrow seals the slot-A PLAINTEXT, which a password change does not739            // alter — only the wrapping changes — so carry it forward; rebuilding the740            // StoredVault from scratch would silently drop it.741            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key742            // content that a password change does not alter — only the slot-A743            // wrapping rotates. The root-key escrow staying stable across password744            // changes matches the browser (`vault.ts:363-364`).745            val next = StoredVault(746                version = slotA.version,747                salt = b64(newSalt),748                blob = blob,749                recoveryEscrow = slotA.recoveryEscrow,750                rootKeyEscrow = slotA.rootKeyEscrow,751            )752            if (!store.writeSlotA(next)) return false753            writeMatchingNoiseSlotB(next)754            store.stampLocalMutation()755            VaultCrypto.zero(oldKey)756            derivedKey?.let(VaultCrypto::zero)757            derivedKey = newKey758            true759        } catch (_: Exception) {760            false761        }762    }763764    /**765     * Irreversible wipe. Locks the session and clears every vault key. The Pro766     * license is in a separate store and is preserved by construction.767     */768    suspend fun destroyVault() = mutex.withLock {769        lock()770        store.clearVault()771        store.clearDecoySlot()772    }773774    // ---- Entry CRUD -------------------------------------------------------775776    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {777        requireUnlocked()778        val stamp = VaultCrypto.monotonicEntryTimestamp()779        val now = System.currentTimeMillis()780        val entry = withDerivedLast4(draft.copy(781            id = draft.id.ifBlank { UUID.randomUUID().toString() },782            createdAt = if (draft.createdAt > 0) draft.createdAt else now,783            modifiedAt = stamp,784            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,785        ))786        val rollback = entryList.toMutableList()787        entryList.add(entry)788        persistOrRollback(rollback)789        entry790    }791792    /**793     * Insert many entries with a single encrypt-and-write at the end.794     *795     * Importing a real vault through [addEntry] was quadratic: every entry took796     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry797     * restore did 806 full-vault writes and hung the app long enough to ANR.798     * Rolls back to the pre-import list if the one write fails, matching799     * [addEntry]'s all-or-nothing behaviour.800     */801    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {802        requireUnlocked()803        if (drafts.isEmpty()) return 0804        val rollback = entryList.toMutableList()805        val now = System.currentTimeMillis()806        for (draft in drafts) {807            val stamp = VaultCrypto.monotonicEntryTimestamp()808            entryList.add(809                withDerivedLast4(810                    draft.copy(811                        id = draft.id.ifBlank { UUID.randomUUID().toString() },812                        createdAt = if (draft.createdAt > 0) draft.createdAt else now,813                        modifiedAt = stamp,814                        passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,815                    ),816                ),817            )818        }819        persistOrRollback(rollback)820        return drafts.size821    }822823    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {824        requireUnlocked()825        val idx = entryList.indexOfFirst { it.id == patch.id }826        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")827        val current = entryList[idx]828        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)829        // Only refresh passwordChangedAt when the password value actually changes.830        val passwordChanged = patch.password != null && patch.password != current.password831        // Capture the outgoing password (newest first) so the user can reveal/copy/832        // restore it later. History is computed authoritatively from `current`, not833        // the patch — updateEntry is a full-entry replace, so trusting patch's value834        // would let a stale form clobber the real history. Record the age the old835        // password carried, skip an empty prior, and skip an immediate exact dupe836        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.837        val priorPassword = current.password838        val shouldCapture = passwordChanged &&839            !priorPassword.isNullOrEmpty() &&840            current.passwordHistory?.firstOrNull()?.password != priorPassword841        val nextHistory = if (shouldCapture) {842            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +843                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)844        } else {845            current.passwordHistory846        }847        val updated = withDerivedLast4(patch.copy(848            createdAt = current.createdAt,849            modifiedAt = stamp,850            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,851            passwordHistory = nextHistory,852        ))853        val rollback = entryList.toMutableList()854        entryList[idx] = updated855        persistOrRollback(rollback)856        updated857    }858859    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */860    suspend fun deleteEntry(id: String) = mutex.withLock {861        requireUnlocked()862        val idx = entryList.indexOfFirst { it.id == id }863        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")864        val current = entryList[idx]865        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)866        val rollback = entryList.toMutableList()867        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)868        persistOrRollback(rollback)869    }870871    suspend fun restoreEntry(id: String) = mutex.withLock {872        requireUnlocked()873        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }874        if (idx == -1) throw IllegalStateException("deleted entry not found")875        val current = entryList[idx]876        val rollback = entryList.toMutableList()877        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))878        persistOrRollback(rollback)879    }880881    /** Permanent delete — no tombstone, no recovery. */882    suspend fun purgeEntry(id: String) = mutex.withLock {883        requireUnlocked()884        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }885        if (idx == -1) throw IllegalStateException("deleted entry not found")886        val rollback = entryList.toMutableList()887        entryList.removeAt(idx)888        persistOrRollback(rollback)889    }890891    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */892    suspend fun purgeDeletedEntries(): Int = mutex.withLock {893        requireUnlocked()894        val rollback = entryList.toMutableList()895        val before = entryList.size896        entryList.removeAll { it.deletedAt != null }897        val removed = before - entryList.size898        if (removed > 0) persistOrRollback(rollback)899        removed900    }901902    /** Browser parity: number of active entries that dedupe would move to trash. */903    suspend fun countDuplicateEntries(): Int = mutex.withLock {904        requireUnlocked()905        countDuplicateEntriesLocked()906    }907908    /**909     * Browser parity: soft-delete duplicate entries, keeping the most recently910     * modified member of each group. Refuse in decoy mode so the duress vault is911     * not reshaped into a forensically distinct state.912     */913    suspend fun deduplicateEntries(): Int = mutex.withLock {914        requireUnlocked()915        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")916        val active = entryList.filter { it.deletedAt == null }917        val keepIds = mutableSetOf<String>()918        active.groupBy { it.duplicateKey() }.values.forEach { group ->919            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id920        }921922        val rollback = entryList.toMutableList()923        var removed = 0924        entryList = entryList.map { entry ->925            if (entry.deletedAt == null && entry.id !in keepIds) {926                removed++927                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)928                entry.copy(deletedAt = stamp, modifiedAt = stamp)929            } else {930                entry931            }932        }.toMutableList()933        if (removed > 0) persistOrRollback(rollback)934        removed935    }936937    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }938    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }939    fun getDeletedEntries(): List<VaultEntry> =940        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }941942    /** Active entries, optionally narrowed to those carrying [tagFilter]. */943    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {944        val active = entryList.filter { it.deletedAt == null }945        if (tagFilter == null) return active946        val needle = tagFilter.trim().lowercase()947        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }948    }949950    /** Sorted, de-duplicated tags across all active entries. */951    fun getDistinctTags(): List<String> =952        entryList.asSequence()953            .filter { it.deletedAt == null }954            .flatMap { it.tags?.asSequence() ?: emptySequence() }955            .map { it.trim().lowercase() }956            .filter { it.isNotEmpty() }957            .distinct()958            .sorted()959            .toList()960961    /**962     * Distinct non-blank values for an identity field across active entries,963     * most-frequently-used first. Supports "username" and "email"; unknown field964     * names return an empty list. Values keep original casing (deduped965     * case-insensitively) so a suggestion matches what the user typed.966     */967    fun getDistinctFieldValues(field: String): List<String> {968        val select: (VaultEntry) -> String? = when (field) {969            "username" -> { e -> e.username }970            "email" -> { e -> e.email }971            else -> return emptyList()972        }973        return entryList.asSequence()974            .filter { it.deletedAt == null }975            .mapNotNull { select(it)?.trim() }976            .filter { it.isNotEmpty() }977            .groupingBy { it.lowercase() }978            .aggregate { _, acc: Pair<String, Int>?, value, _ ->979                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)980            }981            .values982            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })983            .map { it.first }984    }985986    fun getTombstones(): List<Tombstone> =987        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }988    fun getVaultVersion(): Long = store.version()989    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots990991    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {992        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false993        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false994        takeVaultSnapshot("restore")995        if (!store.writeSlotA(restoredA)) return false996        if (!store.writeSlotB(snapshot.vault_b)) return false997        store.stampLocalMutation()998        lock()999        true1000    }10011002    // ---- Sync apply (does NOT advance the version) ------------------------10031004    /**1005     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1006     * WITHOUT bumping the version counter, so a sync round cannot cascade into1007     * another outbound sync. Returns the number of locally-applied changes.1008     */1009    suspend fun applyMergedRemoteEntries(1010        remoteEntries: List<VaultEntry>,1011        remoteTombstones: List<Tombstone>,1012    ): Int = mutex.withLock {1013        requireUnlocked()1014        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1015        // BEFORE any comparison against a local one. Without this a peer can1016        // send a far-future modifiedAt and permanently pin its version of an1017        // entry against every honest local edit. The browser clamps in the1018        // matching place (vault.ts applyMergedRemoteEntries).1019        val mergeNow = System.currentTimeMillis()1020        val clamped = remoteEntries.map { entry ->1021            entry.copy(1022                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1023                deletedAt = entry.deletedAt?.let {1024                    SyncLimits.clampSyncTimestamp(it, mergeNow)1025                },1026            )1027        }1028        val tombMap = remoteTombstones.associate {1029            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1030        }1031        var applied = 01032        val byId = entryList.associateBy { it.id }.toMutableMap()10331034        for (remote in clamped) {1035            val tomb = tombMap[remote.id]1036            if (tomb != null && tomb >= remote.modifiedAt) continue1037            val local = byId[remote.id]1038            if (local == null) {1039                entryList.add(remote); byId[remote.id] = remote; applied++1040            } else if (remote.modifiedAt > local.modifiedAt) {1041                val i = entryList.indexOfFirst { it.id == local.id }1042                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }1043            }1044        }10451046        for ((id, deletedAt) in tombMap) {1047            val local = byId[id] ?: continue1048            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1049            if (deletedAt < local.modifiedAt) continue1050            val i = entryList.indexOfFirst { it.id == id }1051            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }1052        }10531054        if (applied > 0) {1055            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1056            publish()1057        }1058        applied1059    }10601061    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1062        _settings.value = settings1063        store.writeSettings(settings)1064    }10651066    // ---- Recovery words (vault metadata) ----------------------------------10671068    /** True once the user has confirmed their recovery words for this vault. */1069    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet10701071    /**1072     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1073     * Persisted without bumping the version — recovery metadata is local-only and1074     * is not an entry mutation paired devices should sync against.1075     */1076    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1077        requireUnlocked()1078        recoveryWordHash = recoveryWordsHash(words)1079        recoveryWordsSet = true1080        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow1081        // (cross-platform recovery of the sync root key) and Android's whole-payload1082        // escrow (offline forgot-password restore of entries). Both derive their key1083        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.1084        runCatching {1085            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1086        }.getOrDefault(false)1087    }10881089    /** Verify entered words against the stored recovery hash. */1090    fun verifyRecoveryWords(words: List<String>): Boolean {1091        val stored = recoveryWordHash ?: return false1092        return stored == recoveryWordsHash(words)1093    }10941095    // ---- Recovery escrow (forgot-password) --------------------------------10961097    /** True at rest if this vault has any recovery escrow the recovery flow can use. */1098    fun hasRecoveryEscrow(): Boolean =1099        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false11001101    /**1102     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of1103     * the master password. On success the slot-A plaintext sealed in the escrow is1104     * adopted as the live session (entries, rootKey, settings), but no Argon2id1105     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a1106     * new password and finish unlocking. Returns false when there is no escrow, the1107     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).1108     *1109     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no1110     * detached vault key to return here — slot A is wrapped under the rotating1111     * Argon2id key — so this opens the escrowed payload directly.1112     */1113    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1114        val slotA = store.loadSlotA() ?: return false1115        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false11161117        // Prefer Android's whole-payload escrow: a full offline restore of entries,1118        // root key, and settings. Present for Android-created vaults.1119        slotA.recoveryEscrow?.let { escrow ->1120            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1121            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1122            val keyBytes = recoveryKey.encoded1123            try {1124                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1125                val payload = VaultCodec.decode(plaintext)1126                decoyMode = false1127                entryList = payload.entries.toMutableList()1128                duressConfigured = payload.duressConfigured1129                recoveryWordHash = payload.recoveryWordHash1130                recoveryWordsSet = payload.recoveryWordsSet1131                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1132                          else generateRecoverableRootKey()1133                _settings.value = store.loadSettings()1134                VaultCrypto.zero(plaintext)1135                return true1136            } catch (_: Exception) {1137                // Wrong words against this escrow — fall through to the root-key escrow.1138            } finally {1139                VaultCrypto.zero(material)1140                VaultCrypto.zero(keyBytes)1141            }1142        }11431144        // Browser-format vault (only the root-key escrow): recover the 32-byte root1145        // key (sync identity) and reset the password; entries return via sync. This1146        // is exactly the browser's recovery semantics (`messages.ts:312-347`).1147        slotA.rootKeyEscrow?.let { escrow ->1148            val rk = unsealRootKeyEscrow(words, escrow) ?: return false1149            decoyMode = false1150            entryList = mutableListOf()1151            duressConfigured = false1152            recoveryWordHash = recoveryWordsHash(words)1153            recoveryWordsSet = true1154            rootKey = rk1155            _settings.value = store.loadSettings()1156            return true1157        }1158        return false1159    }11601161    /**1162     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1163     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1164     * words, mark the session fully unlocked, and publish. Returns false if called1165     * without a prior successful recovery unlock (no in-memory root key).1166     *1167     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1168     * into one step, since there is no detached vault key here.1169     */1170    suspend fun resetPasswordWithRecoveryWords(1171        words: List<String>,1172        newPassword: String,1173    ): Boolean = mutex.withLock {1174        if (rootKey == null) return false // must have recovered first1175        return try {1176            val newSalt = VaultCrypto.generateSalt()1177            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1178            val plaintext = VaultCodec.encode(currentPayload())1179            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1180            val prior = store.loadSlotA()1181            // Rebuild both escrows against the recovered session so their salts rotate1182            // with the password reset; fall back to the prior escrows if rebuild fails1183            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1184            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1185            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1186            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1187            val next = StoredVault(1188                version = prior?.version ?: 1,1189                salt = b64(newSalt),1190                blob = blob,1191                recoveryEscrow = freshRecovery,1192                rootKeyEscrow = freshRootKey,1193            )1194            if (!store.writeSlotA(next)) return false1195            writeMatchingNoiseSlotB(next)1196            store.stampLocalMutation()1197            derivedKey?.let(VaultCrypto::zero)1198            derivedKey = newKey1199            VaultCrypto.zero(plaintext)1200            publish()1201            _isUnlocked.value = true1202            true1203        } catch (_: Exception) {1204            false1205        }1206    }12071208    /**1209     * Seal the current slot-A plaintext under a key derived from the 12 BIP391210     * recovery [words] (Android offline-restore escrow). IKM is the browser's1211     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1212     * Returns null for a checksum-invalid phrase.1213     */1214    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1215        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1216        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1217        val salt = VaultCrypto.generateSalt()1218        val recoveryKey = deriveRecoveryKey(material, salt)1219        val keyBytes = recoveryKey.encoded1220        val plaintext = VaultCodec.encode(currentPayload())1221        return try {1222            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1223            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1224        } finally {1225            VaultCrypto.zero(material)1226            VaultCrypto.zero(keyBytes)1227            VaultCrypto.zero(plaintext)1228        }1229    }12301231    /**1232     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1233     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1234     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1235     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1236     * the same root key on the browser. Returns null with no in-memory root key or1237     * a checksum-invalid phrase.1238     */1239    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1240        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1241        val rk = rootKey ?: return null1242        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1243        val salt = VaultCrypto.generateSalt()1244        val recoveryKey = deriveRecoveryKey(material, salt)1245        val keyBytes = recoveryKey.encoded1246        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1247        return try {1248            // combined = nonce(12) || ciphertext || tag(16)1249            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1250            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1251            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1252            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1253            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1254            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1255        } finally {1256            VaultCrypto.zero(material)1257            VaultCrypto.zero(keyBytes)1258            VaultCrypto.zero(plaintext)1259        }1260    }12611262    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1263    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1264        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1265        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1266        val keyBytes = recoveryKey.encoded1267        return try {1268            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1269            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1270            hexToRootKey(String(pt, Charsets.US_ASCII))1271        } catch (_: Exception) {1272            null1273        } finally {1274            VaultCrypto.zero(material)1275            VaultCrypto.zero(keyBytes)1276        }1277    }12781279    /**1280     * Re-seal slot A from the current in-memory state under the live key, carrying1281     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1282     * in decoy mode (recovery is never offered for the decoy) and persists without1283     * a version bump, like the recovery-words metadata write it replaces.1284     */1285    private fun persistSlotAWithEscrows(1286        recoveryEscrow: RecoveryEscrow?,1287        rootKeyEscrow: RootKeyEscrow?,1288    ): Boolean {1289        val key = derivedKey ?: throw IllegalStateException("vault_locked")1290        if (decoyMode) return false1291        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1292        val plaintext = VaultCodec.encode(currentPayload())1293        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1294        val next = prior.copy(1295            blob = blob,1296            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1297            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1298        )1299        if (!store.writeSlotA(next)) return false1300        try {1301            writeMatchingNoiseSlotB(next)1302        } catch (e: Exception) {1303            store.writeSlotA(prior)1304            throw e1305        }1306        return true1307    }13081309    // ---- Internals --------------------------------------------------------13101311    private fun requireUnlocked() {1312        if (derivedKey == null) throw IllegalStateException("vault_locked")1313    }13141315    private fun countDuplicateEntriesLocked(): Int {1316        val seen = mutableMapOf<String, VaultEntry>()1317        var duplicates = 01318        for (entry in entryList.filter { it.deletedAt == null }) {1319            val key = entry.duplicateKey()1320            val existing = seen[key]1321            if (existing == null) {1322                seen[key] = entry1323            } else {1324                duplicates++1325                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1326                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1327                if (entryStamp > existingStamp) seen[key] = entry1328            }1329        }1330        return duplicates1331    }13321333    private fun VaultEntry.duplicateKey(): String {1334        if (type == "note") return "note|${title.lowercase()}"1335        val passkeyData = passkey1336        if (passkeyData != null) {1337            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1338        }1339        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1340    }13411342    private fun takeVaultSnapshot(reason: String) {1343        runCatching {1344            val slotA = store.loadSlotA() ?: return1345            val vaultA = VaultCodec.encodeSlot(slotA)1346            val vaultB = store.loadSlotB() ?: slotA.blob1347            val history = store.loadVaultHistory()1348            val latest = history.snapshots.firstOrNull()1349            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1350            val entryCount = if (derivedKey != null && !decoyMode) {1351                bucketEntryCount(entryList.count { it.deletedAt == null })1352            } else {1353                null1354            }1355            val snapshot = VaultSnapshot(1356                id = UUID.randomUUID().toString(),1357                savedAt = System.currentTimeMillis(),1358                reason = reason,1359                vault_a = vaultA,1360                vault_b = vaultB,1361                entryCount = entryCount,1362            )1363            store.writeVaultHistory(1364                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1365            )1366        }1367    }13681369    private fun bucketEntryCount(n: Int): Int = when {1370        n <= 0 -> 01371        n <= 10 -> 101372        n <= 25 -> 251373        n <= 50 -> 501374        n <= 100 -> 1001375        n <= 250 -> 2501376        n <= 500 -> 5001377        else -> ((n + 499) / 500) * 5001378    }13791380    /**1381     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1382     * so it is stored explicitly and survives the sync wire format rather than being1383     * re-derived at render time. Non-card entries pass through unchanged.1384     */1385    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1386        if (!entry.isCard()) return entry1387        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1388        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1389    }13901391    /**1392     * On unlock, hard-delete trashed entries older than the retention window.1393     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1394     * (the app's "Never" option), so nothing is purged. Persists without a1395     * version bump and without touching the publish state (caller publishes).1396     */1397    private fun autoPurgeExpiredTrash() {1398        val days = _settings.value.trashRetentionDays ?: return1399        if (days <= 0) return1400        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1401        val before = entryList.size1402        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1403        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1404    }14051406    /** The slot-A plaintext envelope for the current in-memory session. */1407    private fun currentPayload(): VaultPayload =1408        VaultPayload(1409            entries = entryList.toList(),1410            duressConfigured = duressConfigured,1411            rootKeyB64 = rootKey?.let(::b64) ?: "",1412            recoveryWordHash = recoveryWordHash,1413            recoveryWordsSet = recoveryWordsSet,1414        )14151416    /**1417     * Adopt the payload's root key, lazily minting one for legacy payloads.1418     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1419     * a root key is not a mutation paired devices should sync against.1420     */1421    private fun ensureRootKey(payload: VaultPayload) {1422        if (payload.rootKeyB64.isNotBlank()) {1423            rootKey = unb64(payload.rootKeyB64)1424            return1425        }1426        rootKey = generateRecoverableRootKey()1427        runCatching { persistSlotA(bumpVersion = false) }1428    }14291430    private fun publish() {1431        _entries.value = entryList.filter { it.deletedAt == null }1432    }14331434    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1435        val plaintext = VaultCodec.encode(currentPayload())1436        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1437        return StoredVault(version = 1, salt = b64(salt), blob = blob)1438    }14391440    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1441        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1442        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1443        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1444        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1445        val noise = ByteArray(approxBytes).also(rng::nextBytes)1446        store.writeSlotB(b64(noise))1447    }14481449    /** Re-seal slot A from the current in-memory state under the live key. */1450    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1451        val key = derivedKey ?: throw IllegalStateException("vault_locked")1452        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1453        if (decoyMode) {1454            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1455            val plaintext = VaultCodec.encode(currentPayload())1456            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1457            return store.writeDecoySlot(prior.copy(blob = blob))1458        }1459        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1460        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1461        val plaintext = VaultCodec.encode(currentPayload())1462        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1463        val next = prior.copy(blob = blob)1464        if (!store.writeSlotA(next)) return false1465        // Slot A committed — keep slot B size-matched, then advance the version1466        // for local writes only. Restore slot A if a later step fails.1467        try {1468            writeMatchingNoiseSlotB(next)1469            if (bumpVersion) store.stampLocalMutation()1470        } catch (e: Exception) {1471            store.writeSlotA(prior)1472            throw e1473        }1474        return true1475    }14761477    /** Persist a local mutation, rolling the in-memory list back on failure. */1478    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1479        try {1480            if (!persistSlotA(bumpVersion = true)) {1481                entryList = rollback1482                throw IllegalStateException("storage_write_failed")1483            }1484        } catch (e: Exception) {1485            entryList = rollback1486            throw e1487        }1488        publish()1489    }1490}