VaultRepository.kt

70.3 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        var candidateKey: ByteArray? = null117        var plaintext: ByteArray? = null118        return try {119            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)120            // Parsed as JSON, not matched with a regex. The wrapper is121            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which122            // generates most real Codexes — writes each slot as a JSON123            // *string*, with "b" present. A regex looking for an object124            // literal under "a" at the very end of the wrapper matches125            // neither of those, so every browser-generated Codex failed here126            // with "invalid_codex_blob" after decoding perfectly.127            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject128                ?: return UnlockResult.Error("invalid_codex_blob")129            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")130            // A Codex printed from the browser carries a browser-shaped slot,131            // which is the common case — most Codexes are printed there. It is132            // translated to Android's shape (see BrowserSlot) rather than133            // rejected. A translation failure is a wrong password, not a bad134            // blob: the codes decoded fine, the password just didn't open them.135            val slotA = VaultCodec.decodeSlot(slotJson)136                ?: BrowserSlot.parse(slotJson)?.let {137                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword138                }139                ?: return UnlockResult.Error("invalid_codex_blob")140            // C-01: authenticate the candidate ENTIRELY in memory before touching141            // storage. VaultCodec.decodeSlot is structural — an Android-shaped142            // Codex parses under ANY password — so writing slot A first and143            // unlocking afterwards destroyed the device's existing vault every144            // time the user mistyped the password.145            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))146            plaintext = decryptSlotABlob(slotA, candidateKey)147            // LV-1: a Codex printed from a vault sealed before NFC normalization148            // opens only under the raw password bytes. unlockLocked used to supply149            // this fallback; authenticating up front here has to keep it, or those150            // Codexes become unrestorable.151            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {152                VaultCrypto.zero(candidateKey)153                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))154                plaintext = decryptSlotABlob(slotA, candidateKey)155            }156            if (plaintext == null) return UnlockResult.WrongPassword157            val payload = try {158                VaultCodec.decode(plaintext)159            } catch (_: Exception) {160                return UnlockResult.Error("invalid_codex_blob")161            }162163            val priorA = store.loadSlotA()164            val priorB = store.loadSlotB()165            // Carry the real slot B across when the Codex has one — it holds a166            // duress vault, which noise would silently destroy.167            val codexSlotB = wrapper["b"].asSlotJson()168169            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")170            val committed = runCatching {171                val wroteB = if (codexSlotB != null) {172                    store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))173                } else {174                    writeMatchingNoiseSlotB(slotA)175                }176                if (!wroteB) throw IllegalStateException("storage_write_failed")177                store.stampLocalMutation()178            }.isSuccess179            if (!committed) {180                rollbackRestore(priorA, priorB)181                return UnlockResult.Error("storage_write_failed")182            }183184            val ownedKey = candidateKey!!185            candidateKey = null // the session owns the key from here on186            adoptSlotA(ownedKey, payload)187        } catch (e: Exception) {188            // A raw kotlinx parse error ("Unexpected JSON token at offset N")189            // reaching the user says nothing about WHICH json failed — the190            // wrapper, or the payload decrypted out of it. Codex screens are191            // FLAG_SECURE so this can't be screenshotted either. Debug builds192            // log the structure around the failure; alphanumerics are masked so193            // no ciphertext or vault content lands in a log.194            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))195            UnlockResult.Error(e.message ?: "restore_failed")196        } finally {197            plaintext?.let(VaultCrypto::zero)198            candidateKey?.let(VaultCrypto::zero)199        }200    }201202    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */203    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =204        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()205206    /**207     * Undo a partially-committed Codex restore. Every step is attempted208     * independently so one failure cannot strand the others. With no prior vault209     * the accurate inverse is having none at all, rather than a half-written210     * slot A that [hasVault] would report as a real vault.211     */212    private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {213        val failures = mutableListOf<String>()214        if (priorA == null) {215            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }216        } else {217            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }218                .onFailure { failures += "writeSlotA: $it" }219            if (priorB != null) {220                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }221                    .onFailure { failures += "writeSlotB: $it" }222            }223        }224        // Log is a throwing stub off-device, and a failed log must never mask225        // the rollback result the caller is about to report.226        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }227    }228229    /** Debug-only: structure of a failed Codex restore, with content masked. */230    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {231        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)232        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()233        val window = offset?.let {234            val from = (it - 40).coerceAtLeast(0)235            val to = (it + 40).coerceAtMost(wrapperJson.length)236            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")237        }238        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()239        val slot = wrapper?.get("a")240        buildString {241            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)242            append(" | blobBase64=").append(vaultBlobBase64.length)243            append(" wrapperChars=").append(wrapperJson.length)244            append(" wrapperParses=").append(wrapper != null)245            append(" keys=").append(wrapper?.keys)246            append(" slotAKind=").append(247                when {248                    slot == null -> "absent"249                    slot is JsonPrimitive && slot.isString -> "string"250                    slot is JsonObject -> "object:" + slot.keys251                    else -> slot::class.simpleName252                },253            )254            if (window != null) append(" masked@").append(offset).append("=").append(window)255        }256    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }257258    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {259        return try {260            val salt = VaultCrypto.generateSalt()261            val key = VaultCrypto.deriveKey(password, salt)262            entryList = mutableListOf()263            duressConfigured = false264            recoveryWordHash = null265            recoveryWordsSet = false266            decoyMode = false267            rootKey = generateRecoverableRootKey()268            val slotA = sealSlotA(key, salt)269            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")270            writeMatchingNoiseSlotB(slotA)271            store.stampLocalMutation()272            store.writeSettings(_settings.value)273            derivedKey = key274            publish()275            _isUnlocked.value = true276            CreateVaultResult.Success277        } catch (e: Exception) {278            CreateVaultResult.Error(e.message ?: "create_failed")279        }280    }281282    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {283        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")284        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")285        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {286            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")287        }288        var adoptedRootKey: ByteArray? = null289        var nextDerivedKey: ByteArray? = null290        var sessionRootKey: ByteArray? = null291        return try {292            adoptedRootKey = hexToRootKey(rootKeyHex)293            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {294                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")295            }296            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)297            val salt = VaultCrypto.generateSalt()298            nextDerivedKey = VaultCrypto.deriveKey(password, salt)299            sessionRootKey = adoptedRootKey.copyOf()300            val payload = VaultPayload(301                entries = emptyList(),302                duressConfigured = false,303                rootKeyB64 = b64(sessionRootKey),304                recoveryWordHash = null,305                recoveryWordsSet = false,306            )307            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))308            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)309            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")310            writeMatchingNoiseSlotB(slotA)311            store.stampLocalMutation()312            store.writeSettings(_settings.value)313314            val previousDerivedKey = derivedKey315            val previousRootKey = rootKey316            derivedKey = nextDerivedKey317            rootKey = sessionRootKey318            nextDerivedKey = null319            sessionRootKey = null320            entryList = mutableListOf()321            duressConfigured = false322            recoveryWordHash = null323            recoveryWordsSet = false324            decoyMode = false325            publish()326            _isUnlocked.value = true327            previousDerivedKey?.let(VaultCrypto::zero)328            previousRootKey?.let(VaultCrypto::zero)329            AdoptVaultResult.Success(vaultId)330        } catch (e: Exception) {331            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")332        } finally {333            adoptedRootKey?.let(VaultCrypto::zero)334            nextDerivedKey?.let(VaultCrypto::zero)335            sessionRootKey?.let(VaultCrypto::zero)336        }337    }338339    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }340341    /** Unlock body without acquiring the mutex — callers must already hold it. */342    private fun unlockLocked(password: String): UnlockResult {343        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")344        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))345        val payload = try {346            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))347        } catch (_: Exception) {348            VaultCrypto.zero(key)349            // LV-1: a vault sealed before NFC normalization with a non-NFC350            // password derives a different key now. If the supplied password is351            // not already in NFC form, retry with the raw (un-normalized) bytes.352            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {353                val legacy = tryLegacyRawUnlock(password, slotA)354                if (legacy != null) return legacy355            }356            return UnlockResult.WrongPassword357        }358        return adoptSlotA(key, payload)359    }360361    /**362     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw363     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed364     * under the NFC-normalized key — so every future unlock takes the normal365     * path. Returns null when the raw key also fails (genuinely wrong password).366     */367    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {368        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))369        val payload = try {370            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))371        } catch (_: Exception) {372            VaultCrypto.zero(rawKey)373            return null374        }375        // Heal: re-seal slot A under the NFC key (mirrors changePassword's376        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.377        val newSalt = VaultCrypto.generateSalt()378        val newKey = VaultCrypto.deriveKey(password, newSalt)379        val healed = try {380            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))381            val next = StoredVault(382                version = slotA.version,383                salt = b64(newSalt),384                blob = blob,385                recoveryEscrow = slotA.recoveryEscrow,386                rootKeyEscrow = slotA.rootKeyEscrow,387            )388            if (store.writeSlotA(next)) {389                writeMatchingNoiseSlotB(next)390                store.stampLocalMutation()391                true392            } else {393                false394            }395        } catch (_: Exception) {396            false397        }398        return if (healed) {399            VaultCrypto.zero(rawKey)400            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot401        } else {402            VaultCrypto.zero(newKey)403            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key404        }405    }406407    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */408    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {409        return try {410            derivedKey = key411            decoyMode = false412            entryList = payload.entries.toMutableList()413            duressConfigured = payload.duressConfigured414            recoveryWordHash = payload.recoveryWordHash415            recoveryWordsSet = payload.recoveryWordsSet416            _settings.value = store.loadSettings()417            ensureRootKey(payload)418            autoPurgeExpiredTrash()419            publish()420            _isUnlocked.value = true421            UnlockResult.Success422        } catch (e: Exception) {423            UnlockResult.Error(e.message ?: "unlock_failed")424        }425    }426427    // ---- Constant-time unlock (MF-5) --------------------------------------428429    /**430     * A slot-A unlock that has been derived but not yet adopted. Holds the live431     * Argon2id key and decoded payload so the caller can derive several candidates432     * concurrently and commit only the winner. The key is owned by this token until433     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.434     */435    class SlotAUnlock internal constructor(436        internal val key: ByteArray,437        internal val payload: VaultPayload,438    )439440    /**441     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.442     * Pure computation (no mutex), so it can run concurrently with other candidate443     * derivations before the caller decides which to commit (MF-5). Returns null on444     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).445     */446    fun tryUnlockSlotA(password: String): SlotAUnlock? {447        val slotA = store.loadSlotA() ?: return null448        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))449        return try {450            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))451            SlotAUnlock(key, payload)452        } catch (_: Exception) {453            VaultCrypto.zero(key)454            null455        }456    }457458    /** Adopt a previously-derived [SlotAUnlock] as the live session. */459    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {460        adoptSlotA(unlock.key, unlock.payload)461    }462463    /** Zero a derived [SlotAUnlock] that will not be adopted. */464    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)465466    // ---- Duress slot B (MF-6) ---------------------------------------------467468    /**469     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key470     * and decoded decoy payload so it can be derived concurrently with slot A and471     * only committed if the duress passphrase was the one entered (MF-5/MF-6).472     */473    class DuressUnlock internal constructor(474        internal val key: ByteArray,475        internal val payload: VaultPayload,476    )477478    /**479     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the480     * session. Success means the entered password is the duress passphrase that keys481     * slot B — indistinguishable from the real slot A at the lock screen. Pure482     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].483     * Returns null when no duress slot exists or the password is not the duress one.484     */485    fun tryUnlockDuress(password: String): DuressUnlock? {486        val slot = store.loadDecoySlot() ?: return null487        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))488        return try {489            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))490            DuressUnlock(key, payload)491        } catch (_: Exception) {492            VaultCrypto.zero(key)493            null494        }495    }496497    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */498    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {499        return try {500            derivedKey = unlock.key501            decoyMode = true502            entryList = unlock.payload.entries.toMutableList()503            duressConfigured = false504            recoveryWordHash = unlock.payload.recoveryWordHash505            recoveryWordsSet = unlock.payload.recoveryWordsSet506            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)507                      else VaultCrypto.generateRootKey()508            _settings.value = store.loadSettings()509            publish()510            _isUnlocked.value = true511            UnlockResult.Success512        } catch (e: Exception) {513            UnlockResult.Error(e.message ?: "decoy_unlock_failed")514        }515    }516517    /** Zero a derived [DuressUnlock] that will not be adopted. */518    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)519520    /**521     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an522     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The523     * passphrase is never stored — only its ability to decrypt slot B proves it.524     * Requires the real vault to be unlocked and not itself a decoy session.525     */526    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {527        if (derivedKey == null || decoyMode) return false528        // Reject a duress passphrase that also opens the real vault: it would make the529        // lock screen ambiguous (both slots would decrypt for the same input).530        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }531        return try {532            val salt = VaultCrypto.generateSalt()533            val key = VaultCrypto.deriveKey(duressPassphrase, salt)534            // An empty decoy vault with its own fresh sync root key — nothing real leaks.535            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))536            val plaintext = VaultCodec.encode(decoyPayload)537            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))538            VaultCrypto.zero(key)539            VaultCrypto.zero(plaintext)540            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))541            if (ok) {542                duressConfigured = true543                runCatching { persistSlotA(bumpVersion = false) }544            }545            ok546        } catch (_: Exception) {547            false548        }549    }550551    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */552    suspend fun clearDuress(): Boolean = mutex.withLock {553        store.clearDecoySlot()554        if (derivedKey != null && !decoyMode) {555            duressConfigured = false556            runCatching { persistSlotA(bumpVersion = false) }557        }558        true559    }560561    /** Unlock with an already-derived AES-256 key (biometric path). */562    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {563        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")564        return try {565            val payload = VaultCodec.decode(566                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),567            )568            derivedKey = key.copyOf()569            decoyMode = false570            entryList = payload.entries.toMutableList()571            duressConfigured = payload.duressConfigured572            recoveryWordHash = payload.recoveryWordHash573            recoveryWordsSet = payload.recoveryWordsSet574            _settings.value = store.loadSettings()575            ensureRootKey(payload)576            autoPurgeExpiredTrash()577            publish()578            _isUnlocked.value = true579            UnlockResult.Success580        } catch (_: Exception) {581            UnlockResult.WrongPassword582        }583    }584585    fun lock() {586        derivedKey?.let(VaultCrypto::zero)587        rootKey?.let(VaultCrypto::zero)588        derivedKey = null589        rootKey = null590        entryList = mutableListOf()591        duressConfigured = false592        recoveryWordHash = null593        recoveryWordsSet = false594        decoyMode = false595        _entries.value = emptyList()596        _isUnlocked.value = false597    }598599    @Volatile private var suppressNextBackgroundLock = false600601    /**602     * Call immediately before launching a system Activity (a SAF file603     * picker/save dialog, a share sheet) that briefly takes the whole process604     * out of the foreground as an expected part of an in-app flow — not a605     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the606     * two apart on its own (it fires for both), and without this, exporting or607     * importing a file silently locked and wiped the vault mid-flow the moment608     * the picker took over, before its result ever reached the app.609     */610    fun expectTransientBackground() {611        suppressNextBackgroundLock = true612    }613614    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */615    fun consumeTransientBackgroundSuppression(): Boolean {616        val suppressed = suppressNextBackgroundLock617        suppressNextBackgroundLock = false618        return suppressed619    }620621    // ---- Sync identity ----------------------------------------------------622623    /**624     * The vault's stable sync master key (a copy). Caller must zero it. Backs625     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry626     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].627     */628    /**629     * HOLE 5 — the duress vault never syncs.630     *631     * [commitDuressUnlock] gives the decoy session a root key so its own codex632     * and recovery paths work, which means (unlike the browser, where the decoy633     * path simply has no root key) the sync surface has to refuse explicitly.634     * Without this the decoy vault would present a second, different vault_id635     * to a paired device, and a coerced user opening it would make sync visibly636     * fail on their other devices — a duress indicator, which is the one thing637     * a duress vault must never be.638     */639    fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf()640641    /**642     * The current session's root key, decoy included. For local-only uses that643     * must behave identically in a decoy session — Codex backup and recovery644     * words — because a feature that errors under duress is itself a duress645     * tell. In a decoy session this is the DECOY's root key (see646     * [commitDuressUnlock]), never the real vault's, so neither surface can647     * leak the real vault. Anything that leaves the device must use648     * [syncMasterKey] instead.649     */650    fun sessionRootKey(): ByteArray? = rootKey?.copyOf()651652    /** Stable per-vault id two paired devices must share to sync. Null when653     *  locked, and never present for the decoy vault (see [syncMasterKey]). */654    fun syncVaultId(): String? =655        if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }656657    /**658     * Browser parity (MV-8): rotate the sync identity by replacing the stable659     * root key. Entries remain under the master-password key, while sync660     * credentials, vault_id, and recovery words all change.661     */662    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {663        installRootKey(generateRecoverableRootKey())664    }665666    /**667     * Join the vault behind [rootKeyHex]: adopt that vault's identity while668     * keeping every local entry.669     *670     * Sync identity (vault id, signing keys) derives from the root key, and671     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently672     * created vaults can never reconcile. Taking the other side's root key is673     * what makes them one vault; from the next sync on, both entry sets merge.674     *675     * Entries survive because they are sealed under the password-derived key,676     * not the root key — the same property [rotateSyncIdentity] relies on.677     *678     * CONSEQUENCE, which the caller must have disclosed and had confirmed:679     * this device's previous recovery words stop working for good, and its new680     * words are the source vault's words, because words are a deterministic681     * encoding of the root key.682     */683    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {684        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {685            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")686        }687        if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")688        if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")689        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")690691        val incoming = hexToRootKey(rootKeyHex)692        if (incoming.size != VaultCrypto.KEY_SIZE) {693            VaultCrypto.zero(incoming)694            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")695        }696        // Refuse before committing rather than leave this device holding a vault697        // whose recovery words cannot be typed back. generateRecoverableRootKey698        // screens its own keys for this; a key arriving from a peer has not been.699        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess700        if (!roundTrips) {701            VaultCrypto.zero(incoming)702            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")703        }704705        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)706        return if (installRootKey(incoming)) {707            AdoptVaultResult.Success(vaultId)708        } else {709            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")710        }711    }712713    /**714     * The recovery words [rootKeyHex] will produce once joined, or null if it is715     * not a usable root key. Lets the join flow show the user the words that are716     * about to replace theirs *before* anything is committed.717     */718    fun previewRecoveryWords(rootKeyHex: String): List<String>? {719        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null720        val bytes = hexToRootKey(rootKeyHex)721        return try {722            val words = rootKeyToWords(bytes)723            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null724        } catch (_: Exception) {725            null726        } finally {727            VaultCrypto.zero(bytes)728        }729    }730731    /**732     * Swap the vault's root key, keeping every entry, and re-escrow under the733     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds734     * [mutex].735     */736    private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {737        val key = derivedKey ?: return false738        if (decoyMode) return false739        val priorSlot = store.loadSlotA() ?: return false740        val priorRootKey = rootKey ?: return false741        val priorRecoveryHash = recoveryWordHash742        val priorRecoverySet = recoveryWordsSet743        val nextWords = rootKeyToWords(nextRootKey)744745        return try {746            takeVaultSnapshot("local_save")747            rootKey = nextRootKey748            recoveryWordHash = recoveryWordsHash(nextWords)749            recoveryWordsSet = false750751            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)752                ?: throw IllegalStateException("recovery_escrow_failed")753            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)754                ?: throw IllegalStateException("root_escrow_failed")755            val plaintext = VaultCodec.encode(currentPayload())756            val blob = try {757                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))758            } finally {759                VaultCrypto.zero(plaintext)760            }761            val nextSlot = priorSlot.copy(762                blob = blob,763                recoveryEscrow = nextRecoveryEscrow,764                rootKeyEscrow = nextRootKeyEscrow,765            )766            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")767            try {768                writeMatchingNoiseSlotB(nextSlot)769                store.stampLocalMutation()770            } catch (e: Exception) {771                store.writeSlotA(priorSlot)772                throw e773            }774            VaultCrypto.zero(priorRootKey)775            publish()776            true777        } catch (_: Exception) {778            rootKey = priorRootKey779            recoveryWordHash = priorRecoveryHash780            recoveryWordsSet = priorRecoverySet781            VaultCrypto.zero(nextRootKey)782            store.writeSlotA(priorSlot)783            false784        }785    }786787    /**788     * A copy of the live Argon2id-derived vault key, for biometric escrow only789     * (wrapped under a hardware KeyStore key on biometric enable). Null when790     * locked. Caller must [VaultCrypto.zero] it after use.791     */792    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()793794    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {795        val slotA = store.loadSlotA() ?: return false796        return try {797            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))798            val payload = VaultCodec.decode(799                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),800            )801            val newSalt = VaultCrypto.generateSalt()802            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)803            // Re-seal the existing payload under the new key/salt.804            val plaintext = VaultCodec.encode(payload)805            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))806            // The escrow seals the slot-A PLAINTEXT, which a password change does not807            // alter — only the wrapping changes — so carry it forward; rebuilding the808            // StoredVault from scratch would silently drop it.809            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key810            // content that a password change does not alter — only the slot-A811            // wrapping rotates. The root-key escrow staying stable across password812            // changes matches the browser (`vault.ts:363-364`).813            val next = StoredVault(814                version = slotA.version,815                salt = b64(newSalt),816                blob = blob,817                recoveryEscrow = slotA.recoveryEscrow,818                rootKeyEscrow = slotA.rootKeyEscrow,819            )820            if (!store.writeSlotA(next)) return false821            writeMatchingNoiseSlotB(next)822            store.stampLocalMutation()823            VaultCrypto.zero(oldKey)824            derivedKey?.let(VaultCrypto::zero)825            derivedKey = newKey826            true827        } catch (_: Exception) {828            false829        }830    }831832    /**833     * Irreversible wipe. Locks the session and clears every vault key. The Pro834     * license is in a separate store and is preserved by construction.835     */836    suspend fun destroyVault() = mutex.withLock {837        lock()838        store.clearVault()839        store.clearDecoySlot()840    }841842    // ---- Entry CRUD -------------------------------------------------------843844    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {845        requireUnlocked()846        val stamp = VaultCrypto.monotonicEntryTimestamp()847        val now = System.currentTimeMillis()848        val entry = withDerivedLast4(draft.copy(849            id = draft.id.ifBlank { UUID.randomUUID().toString() },850            createdAt = if (draft.createdAt > 0) draft.createdAt else now,851            modifiedAt = stamp,852            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,853        ))854        val rollback = entryList.toMutableList()855        entryList.add(entry)856        persistOrRollback(rollback)857        entry858    }859860    /**861     * Insert many entries with a single encrypt-and-write at the end.862     *863     * Importing a real vault through [addEntry] was quadratic: every entry took864     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry865     * restore did 806 full-vault writes and hung the app long enough to ANR.866     * Rolls back to the pre-import list if the one write fails, matching867     * [addEntry]'s all-or-nothing behaviour.868     */869    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {870        requireUnlocked()871        if (drafts.isEmpty()) return 0872        val rollback = entryList.toMutableList()873        val now = System.currentTimeMillis()874        for (draft in drafts) {875            val stamp = VaultCrypto.monotonicEntryTimestamp()876            entryList.add(877                withDerivedLast4(878                    draft.copy(879                        id = draft.id.ifBlank { UUID.randomUUID().toString() },880                        createdAt = if (draft.createdAt > 0) draft.createdAt else now,881                        modifiedAt = stamp,882                        passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,883                    ),884                ),885            )886        }887        persistOrRollback(rollback)888        return drafts.size889    }890891    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {892        requireUnlocked()893        val idx = entryList.indexOfFirst { it.id == patch.id }894        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")895        val current = entryList[idx]896        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)897        // Only refresh passwordChangedAt when the password value actually changes.898        val passwordChanged = patch.password != null && patch.password != current.password899        // Capture the outgoing password (newest first) so the user can reveal/copy/900        // restore it later. History is computed authoritatively from `current`, not901        // the patch — updateEntry is a full-entry replace, so trusting patch's value902        // would let a stale form clobber the real history. Record the age the old903        // password carried, skip an empty prior, and skip an immediate exact dupe904        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.905        val priorPassword = current.password906        val shouldCapture = passwordChanged &&907            !priorPassword.isNullOrEmpty() &&908            current.passwordHistory?.firstOrNull()?.password != priorPassword909        val nextHistory = if (shouldCapture) {910            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +911                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)912        } else {913            current.passwordHistory914        }915        val updated = withDerivedLast4(patch.copy(916            createdAt = current.createdAt,917            modifiedAt = stamp,918            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,919            passwordHistory = nextHistory,920        ))921        val rollback = entryList.toMutableList()922        entryList[idx] = updated923        persistOrRollback(rollback)924        updated925    }926927    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */928    suspend fun deleteEntry(id: String) = mutex.withLock {929        requireUnlocked()930        val idx = entryList.indexOfFirst { it.id == id }931        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")932        val current = entryList[idx]933        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)934        val rollback = entryList.toMutableList()935        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)936        persistOrRollback(rollback)937    }938939    suspend fun restoreEntry(id: String) = mutex.withLock {940        requireUnlocked()941        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }942        if (idx == -1) throw IllegalStateException("deleted entry not found")943        val current = entryList[idx]944        val rollback = entryList.toMutableList()945        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))946        persistOrRollback(rollback)947    }948949    /** Permanent delete — no tombstone, no recovery. */950    suspend fun purgeEntry(id: String) = mutex.withLock {951        requireUnlocked()952        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }953        if (idx == -1) throw IllegalStateException("deleted entry not found")954        val rollback = entryList.toMutableList()955        entryList.removeAt(idx)956        persistOrRollback(rollback)957    }958959    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */960    suspend fun purgeDeletedEntries(): Int = mutex.withLock {961        requireUnlocked()962        val rollback = entryList.toMutableList()963        val before = entryList.size964        entryList.removeAll { it.deletedAt != null }965        val removed = before - entryList.size966        if (removed > 0) persistOrRollback(rollback)967        removed968    }969970    /** Browser parity: number of active entries that dedupe would move to trash. */971    suspend fun countDuplicateEntries(): Int = mutex.withLock {972        requireUnlocked()973        countDuplicateEntriesLocked()974    }975976    /**977     * Browser parity: soft-delete duplicate entries, keeping the most recently978     * modified member of each group. Refuse in decoy mode so the duress vault is979     * not reshaped into a forensically distinct state.980     */981    suspend fun deduplicateEntries(): Int = mutex.withLock {982        requireUnlocked()983        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")984        val active = entryList.filter { it.deletedAt == null }985        val keepIds = mutableSetOf<String>()986        active.groupBy { it.duplicateKey() }.values.forEach { group ->987            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id988        }989990        val rollback = entryList.toMutableList()991        var removed = 0992        entryList = entryList.map { entry ->993            if (entry.deletedAt == null && entry.id !in keepIds) {994                removed++995                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)996                entry.copy(deletedAt = stamp, modifiedAt = stamp)997            } else {998                entry999            }1000        }.toMutableList()1001        if (removed > 0) persistOrRollback(rollback)1002        removed1003    }10041005    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1006    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1007    fun getDeletedEntries(): List<VaultEntry> =1008        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }10091010    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1011    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1012        val active = entryList.filter { it.deletedAt == null }1013        if (tagFilter == null) return active1014        val needle = tagFilter.trim().lowercase()1015        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1016    }10171018    /** Sorted, de-duplicated tags across all active entries. */1019    fun getDistinctTags(): List<String> =1020        entryList.asSequence()1021            .filter { it.deletedAt == null }1022            .flatMap { it.tags?.asSequence() ?: emptySequence() }1023            .map { it.trim().lowercase() }1024            .filter { it.isNotEmpty() }1025            .distinct()1026            .sorted()1027            .toList()10281029    /**1030     * Distinct non-blank values for an identity field across active entries,1031     * most-frequently-used first. Supports "username" and "email"; unknown field1032     * names return an empty list. Values keep original casing (deduped1033     * case-insensitively) so a suggestion matches what the user typed.1034     */1035    fun getDistinctFieldValues(field: String): List<String> {1036        val select: (VaultEntry) -> String? = when (field) {1037            "username" -> { e -> e.username }1038            "email" -> { e -> e.email }1039            else -> return emptyList()1040        }1041        return entryList.asSequence()1042            .filter { it.deletedAt == null }1043            .mapNotNull { select(it)?.trim() }1044            .filter { it.isNotEmpty() }1045            .groupingBy { it.lowercase() }1046            .aggregate { _, acc: Pair<String, Int>?, value, _ ->1047                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1048            }1049            .values1050            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1051            .map { it.first }1052    }10531054    fun getTombstones(): List<Tombstone> =1055        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1056    fun getVaultVersion(): Long = store.version()1057    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots10581059    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1060        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1061        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1062        takeVaultSnapshot("restore")1063        if (!store.writeSlotA(restoredA)) return false1064        if (!store.writeSlotB(snapshot.vault_b)) return false1065        store.stampLocalMutation()1066        lock()1067        true1068    }10691070    // ---- Sync apply (does NOT advance the version) ------------------------10711072    /**1073     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1074     * WITHOUT bumping the version counter, so a sync round cannot cascade into1075     * another outbound sync. Returns the number of locally-applied changes.1076     */1077    suspend fun applyMergedRemoteEntries(1078        remoteEntries: List<VaultEntry>,1079        remoteTombstones: List<Tombstone>,1080    ): Int = mutex.withLock {1081        requireUnlocked()1082        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1083        // BEFORE any comparison against a local one. Without this a peer can1084        // send a far-future modifiedAt and permanently pin its version of an1085        // entry against every honest local edit. The browser clamps in the1086        // matching place (vault.ts applyMergedRemoteEntries).1087        val mergeNow = System.currentTimeMillis()1088        val clamped = remoteEntries.map { entry ->1089            entry.copy(1090                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1091                deletedAt = entry.deletedAt?.let {1092                    SyncLimits.clampSyncTimestamp(it, mergeNow)1093                },1094            )1095        }1096        val tombMap = remoteTombstones.associate {1097            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1098        }1099        var applied = 01100        val byId = entryList.associateBy { it.id }.toMutableMap()11011102        for (remote in clamped) {1103            val tomb = tombMap[remote.id]1104            if (tomb != null && tomb >= remote.modifiedAt) continue1105            val local = byId[remote.id]1106            if (local == null) {1107                entryList.add(remote); byId[remote.id] = remote; applied++1108            } else if (remote.modifiedAt > local.modifiedAt) {1109                val i = entryList.indexOfFirst { it.id == local.id }1110                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }1111            }1112        }11131114        for ((id, deletedAt) in tombMap) {1115            val local = byId[id] ?: continue1116            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1117            if (deletedAt < local.modifiedAt) continue1118            val i = entryList.indexOfFirst { it.id == id }1119            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }1120        }11211122        if (applied > 0) {1123            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1124            publish()1125        }1126        applied1127    }11281129    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1130        _settings.value = settings1131        store.writeSettings(settings)1132    }11331134    // ---- Recovery words (vault metadata) ----------------------------------11351136    /** True once the user has confirmed their recovery words for this vault. */1137    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet11381139    /**1140     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1141     * Persisted without bumping the version — recovery metadata is local-only and1142     * is not an entry mutation paired devices should sync against.1143     */1144    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1145        requireUnlocked()1146        recoveryWordHash = recoveryWordsHash(words)1147        recoveryWordsSet = true1148        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow1149        // (cross-platform recovery of the sync root key) and Android's whole-payload1150        // escrow (offline forgot-password restore of entries). Both derive their key1151        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.1152        runCatching {1153            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1154        }.getOrDefault(false)1155    }11561157    /** Verify entered words against the stored recovery hash. */1158    fun verifyRecoveryWords(words: List<String>): Boolean {1159        val stored = recoveryWordHash ?: return false1160        return stored == recoveryWordsHash(words)1161    }11621163    // ---- Recovery escrow (forgot-password) --------------------------------11641165    /** True at rest if this vault has any recovery escrow the recovery flow can use. */1166    fun hasRecoveryEscrow(): Boolean =1167        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false11681169    /**1170     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of1171     * the master password. On success the slot-A plaintext sealed in the escrow is1172     * adopted as the live session (entries, rootKey, settings), but no Argon2id1173     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a1174     * new password and finish unlocking. Returns false when there is no escrow, the1175     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).1176     *1177     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no1178     * detached vault key to return here — slot A is wrapped under the rotating1179     * Argon2id key — so this opens the escrowed payload directly.1180     */1181    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1182        val slotA = store.loadSlotA() ?: return false1183        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false11841185        // Prefer Android's whole-payload escrow: a full offline restore of entries,1186        // root key, and settings. Present for Android-created vaults.1187        slotA.recoveryEscrow?.let { escrow ->1188            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1189            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1190            val keyBytes = recoveryKey.encoded1191            try {1192                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1193                val payload = VaultCodec.decode(plaintext)1194                decoyMode = false1195                entryList = payload.entries.toMutableList()1196                duressConfigured = payload.duressConfigured1197                recoveryWordHash = payload.recoveryWordHash1198                recoveryWordsSet = payload.recoveryWordsSet1199                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1200                          else generateRecoverableRootKey()1201                _settings.value = store.loadSettings()1202                VaultCrypto.zero(plaintext)1203                return true1204            } catch (_: Exception) {1205                // Wrong words against this escrow — fall through to the root-key escrow.1206            } finally {1207                VaultCrypto.zero(material)1208                VaultCrypto.zero(keyBytes)1209            }1210        }12111212        // Browser-format vault (only the root-key escrow): recover the 32-byte root1213        // key (sync identity) and reset the password; entries return via sync. This1214        // is exactly the browser's recovery semantics (`messages.ts:312-347`).1215        slotA.rootKeyEscrow?.let { escrow ->1216            val rk = unsealRootKeyEscrow(words, escrow) ?: return false1217            decoyMode = false1218            entryList = mutableListOf()1219            duressConfigured = false1220            recoveryWordHash = recoveryWordsHash(words)1221            recoveryWordsSet = true1222            rootKey = rk1223            _settings.value = store.loadSettings()1224            return true1225        }1226        return false1227    }12281229    /**1230     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1231     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1232     * words, mark the session fully unlocked, and publish. Returns false if called1233     * without a prior successful recovery unlock (no in-memory root key).1234     *1235     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1236     * into one step, since there is no detached vault key here.1237     */1238    suspend fun resetPasswordWithRecoveryWords(1239        words: List<String>,1240        newPassword: String,1241    ): Boolean = mutex.withLock {1242        if (rootKey == null) return false // must have recovered first1243        return try {1244            val newSalt = VaultCrypto.generateSalt()1245            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1246            val plaintext = VaultCodec.encode(currentPayload())1247            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1248            val prior = store.loadSlotA()1249            // Rebuild both escrows against the recovered session so their salts rotate1250            // with the password reset; fall back to the prior escrows if rebuild fails1251            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1252            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1253            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1254            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1255            val next = StoredVault(1256                version = prior?.version ?: 1,1257                salt = b64(newSalt),1258                blob = blob,1259                recoveryEscrow = freshRecovery,1260                rootKeyEscrow = freshRootKey,1261            )1262            if (!store.writeSlotA(next)) return false1263            writeMatchingNoiseSlotB(next)1264            store.stampLocalMutation()1265            derivedKey?.let(VaultCrypto::zero)1266            derivedKey = newKey1267            VaultCrypto.zero(plaintext)1268            publish()1269            _isUnlocked.value = true1270            true1271        } catch (_: Exception) {1272            false1273        }1274    }12751276    /**1277     * Seal the current slot-A plaintext under a key derived from the 12 BIP391278     * recovery [words] (Android offline-restore escrow). IKM is the browser's1279     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1280     * Returns null for a checksum-invalid phrase.1281     */1282    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1283        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1284        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1285        val salt = VaultCrypto.generateSalt()1286        val recoveryKey = deriveRecoveryKey(material, salt)1287        val keyBytes = recoveryKey.encoded1288        val plaintext = VaultCodec.encode(currentPayload())1289        return try {1290            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1291            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1292        } finally {1293            VaultCrypto.zero(material)1294            VaultCrypto.zero(keyBytes)1295            VaultCrypto.zero(plaintext)1296        }1297    }12981299    /**1300     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1301     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1302     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1303     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1304     * the same root key on the browser. Returns null with no in-memory root key or1305     * a checksum-invalid phrase.1306     */1307    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1308        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1309        val rk = rootKey ?: return null1310        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1311        val salt = VaultCrypto.generateSalt()1312        val recoveryKey = deriveRecoveryKey(material, salt)1313        val keyBytes = recoveryKey.encoded1314        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1315        return try {1316            // combined = nonce(12) || ciphertext || tag(16)1317            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1318            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1319            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1320            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1321            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1322            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1323        } finally {1324            VaultCrypto.zero(material)1325            VaultCrypto.zero(keyBytes)1326            VaultCrypto.zero(plaintext)1327        }1328    }13291330    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1331    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1332        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1333        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1334        val keyBytes = recoveryKey.encoded1335        return try {1336            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1337            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1338            hexToRootKey(String(pt, Charsets.US_ASCII))1339        } catch (_: Exception) {1340            null1341        } finally {1342            VaultCrypto.zero(material)1343            VaultCrypto.zero(keyBytes)1344        }1345    }13461347    /**1348     * Re-seal slot A from the current in-memory state under the live key, carrying1349     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1350     * in decoy mode (recovery is never offered for the decoy) and persists without1351     * a version bump, like the recovery-words metadata write it replaces.1352     */1353    private fun persistSlotAWithEscrows(1354        recoveryEscrow: RecoveryEscrow?,1355        rootKeyEscrow: RootKeyEscrow?,1356    ): Boolean {1357        val key = derivedKey ?: throw IllegalStateException("vault_locked")1358        if (decoyMode) return false1359        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1360        val plaintext = VaultCodec.encode(currentPayload())1361        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1362        val next = prior.copy(1363            blob = blob,1364            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1365            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1366        )1367        if (!store.writeSlotA(next)) return false1368        try {1369            writeMatchingNoiseSlotB(next)1370        } catch (e: Exception) {1371            store.writeSlotA(prior)1372            throw e1373        }1374        return true1375    }13761377    // ---- Internals --------------------------------------------------------13781379    private fun requireUnlocked() {1380        if (derivedKey == null) throw IllegalStateException("vault_locked")1381    }13821383    private fun countDuplicateEntriesLocked(): Int {1384        val seen = mutableMapOf<String, VaultEntry>()1385        var duplicates = 01386        for (entry in entryList.filter { it.deletedAt == null }) {1387            val key = entry.duplicateKey()1388            val existing = seen[key]1389            if (existing == null) {1390                seen[key] = entry1391            } else {1392                duplicates++1393                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1394                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1395                if (entryStamp > existingStamp) seen[key] = entry1396            }1397        }1398        return duplicates1399    }14001401    private fun VaultEntry.duplicateKey(): String {1402        if (type == "note") return "note|${title.lowercase()}"1403        val passkeyData = passkey1404        if (passkeyData != null) {1405            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1406        }1407        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1408    }14091410    private fun takeVaultSnapshot(reason: String) {1411        runCatching {1412            val slotA = store.loadSlotA() ?: return1413            val vaultA = VaultCodec.encodeSlot(slotA)1414            val vaultB = store.loadSlotB() ?: slotA.blob1415            val history = store.loadVaultHistory()1416            val latest = history.snapshots.firstOrNull()1417            if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1418            val entryCount = if (derivedKey != null && !decoyMode) {1419                bucketEntryCount(entryList.count { it.deletedAt == null })1420            } else {1421                null1422            }1423            val snapshot = VaultSnapshot(1424                id = UUID.randomUUID().toString(),1425                savedAt = System.currentTimeMillis(),1426                reason = reason,1427                vault_a = vaultA,1428                vault_b = vaultB,1429                entryCount = entryCount,1430            )1431            store.writeVaultHistory(1432                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1433            )1434        }1435    }14361437    private fun bucketEntryCount(n: Int): Int = when {1438        n <= 0 -> 01439        n <= 10 -> 101440        n <= 25 -> 251441        n <= 50 -> 501442        n <= 100 -> 1001443        n <= 250 -> 2501444        n <= 500 -> 5001445        else -> ((n + 499) / 500) * 5001446    }14471448    /**1449     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1450     * so it is stored explicitly and survives the sync wire format rather than being1451     * re-derived at render time. Non-card entries pass through unchanged.1452     */1453    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1454        if (!entry.isCard()) return entry1455        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1456        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1457    }14581459    /**1460     * On unlock, hard-delete trashed entries older than the retention window.1461     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1462     * (the app's "Never" option), so nothing is purged. Persists without a1463     * version bump and without touching the publish state (caller publishes).1464     */1465    private fun autoPurgeExpiredTrash() {1466        val days = _settings.value.trashRetentionDays ?: return1467        if (days <= 0) return1468        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1469        val before = entryList.size1470        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1471        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1472    }14731474    /** The slot-A plaintext envelope for the current in-memory session. */1475    private fun currentPayload(): VaultPayload =1476        VaultPayload(1477            entries = entryList.toList(),1478            duressConfigured = duressConfigured,1479            rootKeyB64 = rootKey?.let(::b64) ?: "",1480            recoveryWordHash = recoveryWordHash,1481            recoveryWordsSet = recoveryWordsSet,1482        )14831484    /**1485     * Adopt the payload's root key, lazily minting one for legacy payloads.1486     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1487     * a root key is not a mutation paired devices should sync against.1488     */1489    private fun ensureRootKey(payload: VaultPayload) {1490        if (payload.rootKeyB64.isNotBlank()) {1491            rootKey = unb64(payload.rootKeyB64)1492            return1493        }1494        rootKey = generateRecoverableRootKey()1495        runCatching { persistSlotA(bumpVersion = false) }1496    }14971498    private fun publish() {1499        _entries.value = entryList.filter { it.deletedAt == null }1500    }15011502    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1503        val plaintext = VaultCodec.encode(currentPayload())1504        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1505        return StoredVault(version = 1, salt = b64(salt), blob = blob)1506    }15071508    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1509        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1510        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1511        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1512        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1513        val noise = ByteArray(approxBytes).also(rng::nextBytes)1514        return store.writeSlotB(b64(noise))1515    }15161517    /** Re-seal slot A from the current in-memory state under the live key. */1518    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1519        val key = derivedKey ?: throw IllegalStateException("vault_locked")1520        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1521        if (decoyMode) {1522            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1523            val plaintext = VaultCodec.encode(currentPayload())1524            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1525            return store.writeDecoySlot(prior.copy(blob = blob))1526        }1527        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1528        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1529        val plaintext = VaultCodec.encode(currentPayload())1530        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1531        val next = prior.copy(blob = blob)1532        if (!store.writeSlotA(next)) return false1533        // Slot A committed — keep slot B size-matched, then advance the version1534        // for local writes only. Restore slot A if a later step fails.1535        try {1536            writeMatchingNoiseSlotB(next)1537            if (bumpVersion) store.stampLocalMutation()1538        } catch (e: Exception) {1539            store.writeSlotA(prior)1540            throw e1541        }1542        return true1543    }15441545    /** Persist a local mutation, rolling the in-memory list back on failure. */1546    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1547        try {1548            if (!persistSlotA(bumpVersion = true)) {1549                entryList = rollback1550                throw IllegalStateException("storage_write_failed")1551            }1552        } catch (e: Exception) {1553            entryList = rollback1554            throw e1555        }1556        publish()1557    }1558}