VaultRepository.kt

38.1 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.util.Base6410import java.util.UUID11import javax.inject.Inject12import javax.inject.Singleton1314private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)15private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)1617/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */18private const val PASSWORD_HISTORY_MAX = 501920/** Fixed in-app key material for the decoy vault — holds nothing secret by design. */21private const val DECOY_SENTINEL = "peach_decoy_v1"2223sealed interface UnlockResult {24    data object Success : UnlockResult25    data object WrongPassword : UnlockResult26    data class Error(val reason: String) : UnlockResult27}2829sealed interface CreateVaultResult {30    data object Success : CreateVaultResult31    data class Error(val reason: String) : CreateVaultResult32}3334/**35 * In-memory vault session backed by [VaultStore]. Faithful port of the browser36 * `vault.ts` semantics that matter for correctness:37 *38 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched39 *    noise blob so a fresh install shows two similarly-shaped records.40 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a41 *    deletion always wins last-write-wins against an in-flight edit.42 *  - Local writes bump the version counter; sync-applied writes do NOT (so a43 *    sync round never cascades into another outbound sync).44 *  - add/update/delete roll back in-memory state if the disk write fails.45 *  - destroyVault never touches the Pro license (separate store).46 */47@Singleton48class VaultRepository @Inject constructor(49    private val store: VaultStorage,50) {51    private val mutex = Mutex()52    private val rng = SecureRandom()5354    private var derivedKey: ByteArray? = null55    private var rootKey: ByteArray? = null56    private var entryList: MutableList<VaultEntry> = mutableListOf()57    private var duressConfigured: Boolean = false58    private var recoveryWordHash: String? = null59    private var recoveryWordsSet: Boolean = false60    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */61    private var decoyMode: Boolean = false6263    private val _isUnlocked = MutableStateFlow(false)64    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6566    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())67    /** Active (non-deleted) entryList. */68    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()6970    private val _settings = MutableStateFlow(VaultSettings())71    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7273    fun hasVault(): Boolean = store.hasVault()7475    // ---- Codex (printed paper backup) -------------------------------------7677    /**78     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the79     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable80     * on any device that has the master password. Null when no vault exists.81     */82    fun codexVaultBlobBase64(): String? {83        val slotA = store.loadSlotA() ?: return null84        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""85        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))86    }8788    /**89     * Replace the local vault from a Codex blob, then unlock with [password].90     * Writes slot A (and a size-matched noise slot B) before unlocking so the91     * restored vault is at rest exactly as a freshly-created one would be.92     */93    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {94        return try {95            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)96            val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)97                ?: return UnlockResult.Error("invalid_codex_blob")98            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")99            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")100            writeMatchingNoiseSlotB(slotA)101            store.stampLocalMutation()102            unlockLocked(password)103        } catch (e: Exception) {104            UnlockResult.Error(e.message ?: "restore_failed")105        }106    }107108    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {109        return try {110            val salt = VaultCrypto.generateSalt()111            val key = VaultCrypto.deriveKey(password, salt)112            entryList = mutableListOf()113            duressConfigured = false114            recoveryWordHash = null115            recoveryWordsSet = false116            decoyMode = false117            rootKey = generateRecoverableRootKey()118            val slotA = sealSlotA(key, salt)119            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")120            writeMatchingNoiseSlotB(slotA)121            store.stampLocalMutation()122            store.writeSettings(_settings.value)123            derivedKey = key124            publish()125            _isUnlocked.value = true126            CreateVaultResult.Success127        } catch (e: Exception) {128            CreateVaultResult.Error(e.message ?: "create_failed")129        }130    }131132    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }133134    /** Unlock body without acquiring the mutex — callers must already hold it. */135    private fun unlockLocked(password: String): UnlockResult {136        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")137        return try {138            val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))139            val payload = try {140                VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))141            } catch (_: Exception) {142                VaultCrypto.zero(key)143                return UnlockResult.WrongPassword144            }145            derivedKey = key146            decoyMode = false147            entryList = payload.entries.toMutableList()148            duressConfigured = payload.duressConfigured149            recoveryWordHash = payload.recoveryWordHash150            recoveryWordsSet = payload.recoveryWordsSet151            _settings.value = store.loadSettings()152            ensureRootKey(payload)153            autoPurgeExpiredTrash()154            publish()155            _isUnlocked.value = true156            UnlockResult.Success157        } catch (e: Exception) {158            UnlockResult.Error(e.message ?: "unlock_failed")159        }160    }161162    /**163     * Unlock into the decoy (duress) vault. Creates an empty decoy on first use,164     * sealed under a fixed in-app sentinel key with its own salt. The real slot A165     * is never read, so the real vault stays hidden. Edits during a decoy session166     * persist to the decoy slot only (see [persistSlotA]).167     */168    suspend fun unlockDecoy(): UnlockResult = mutex.withLock {169        return try {170            val existing = store.loadDecoySlot()171            if (existing == null) {172                val salt = VaultCrypto.generateSalt()173                val key = VaultCrypto.deriveKey(DECOY_SENTINEL, salt)174                entryList = mutableListOf()175                duressConfigured = false176                recoveryWordHash = null177                recoveryWordsSet = false178                rootKey = VaultCrypto.generateRootKey()179                derivedKey = key180                decoyMode = true181                val plaintext = VaultCodec.encode(currentPayload())182                val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))183                if (!store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob)))184                    return UnlockResult.Error("storage_write_failed")185            } else {186                val key = VaultCrypto.deriveKey(DECOY_SENTINEL, unb64(existing.salt))187                val payload = VaultCodec.decode(188                    VaultCrypto.decrypt(unb64(existing.blob), key, VaultCrypto.AAD_SLOT_A),189                )190                derivedKey = key191                decoyMode = true192                entryList = payload.entries.toMutableList()193                duressConfigured = false194                recoveryWordHash = payload.recoveryWordHash195                recoveryWordsSet = payload.recoveryWordsSet196                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)197                          else VaultCrypto.generateRootKey()198            }199            _settings.value = store.loadSettings()200            publish()201            _isUnlocked.value = true202            UnlockResult.Success203        } catch (e: Exception) {204            UnlockResult.Error(e.message ?: "decoy_unlock_failed")205        }206    }207208    /** Unlock with an already-derived AES-256 key (biometric path). */209    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {210        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")211        return try {212            val payload = VaultCodec.decode(213                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),214            )215            derivedKey = key.copyOf()216            decoyMode = false217            entryList = payload.entries.toMutableList()218            duressConfigured = payload.duressConfigured219            recoveryWordHash = payload.recoveryWordHash220            recoveryWordsSet = payload.recoveryWordsSet221            _settings.value = store.loadSettings()222            ensureRootKey(payload)223            autoPurgeExpiredTrash()224            publish()225            _isUnlocked.value = true226            UnlockResult.Success227        } catch (_: Exception) {228            UnlockResult.WrongPassword229        }230    }231232    fun lock() {233        derivedKey?.let(VaultCrypto::zero)234        rootKey?.let(VaultCrypto::zero)235        derivedKey = null236        rootKey = null237        entryList = mutableListOf()238        duressConfigured = false239        recoveryWordHash = null240        recoveryWordsSet = false241        decoyMode = false242        _entries.value = emptyList()243        _isUnlocked.value = false244    }245246    // ---- Sync identity ----------------------------------------------------247248    /**249     * The vault's stable sync master key (a copy). Caller must zero it. Backs250     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry251     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].252     */253    fun syncMasterKey(): ByteArray? = rootKey?.copyOf()254255    /** Stable per-vault id two paired devices must share to sync. Null when locked. */256    fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }257258    /**259     * A copy of the live Argon2id-derived vault key, for biometric escrow only260     * (wrapped under a hardware KeyStore key on biometric enable). Null when261     * locked. Caller must [VaultCrypto.zero] it after use.262     */263    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()264265    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {266        val slotA = store.loadSlotA() ?: return false267        return try {268            val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))269            val payload = VaultCodec.decode(270                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),271            )272            val newSalt = VaultCrypto.generateSalt()273            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)274            // Re-seal the existing payload under the new key/salt.275            val plaintext = VaultCodec.encode(payload)276            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))277            // The escrow seals the slot-A PLAINTEXT, which a password change does not278            // alter — only the wrapping changes — so carry it forward; rebuilding the279            // StoredVault from scratch would silently drop it.280            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key281            // content that a password change does not alter — only the slot-A282            // wrapping rotates. The root-key escrow staying stable across password283            // changes matches the browser (`vault.ts:363-364`).284            val next = StoredVault(285                version = slotA.version,286                salt = b64(newSalt),287                blob = blob,288                recoveryEscrow = slotA.recoveryEscrow,289                rootKeyEscrow = slotA.rootKeyEscrow,290            )291            if (!store.writeSlotA(next)) return false292            writeMatchingNoiseSlotB(next)293            store.stampLocalMutation()294            VaultCrypto.zero(oldKey)295            derivedKey?.let(VaultCrypto::zero)296            derivedKey = newKey297            true298        } catch (_: Exception) {299            false300        }301    }302303    /**304     * Irreversible wipe. Locks the session and clears every vault key. The Pro305     * license is in a separate store and is preserved by construction.306     */307    suspend fun destroyVault() = mutex.withLock {308        lock()309        store.clearVault()310        store.clearDecoySlot()311    }312313    // ---- Entry CRUD -------------------------------------------------------314315    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {316        requireUnlocked()317        val stamp = VaultCrypto.monotonicEntryTimestamp()318        val now = System.currentTimeMillis()319        val entry = draft.copy(320            id = draft.id.ifBlank { UUID.randomUUID().toString() },321            createdAt = if (draft.createdAt > 0) draft.createdAt else now,322            modifiedAt = stamp,323            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,324        )325        val rollback = entryList.toMutableList()326        entryList.add(entry)327        persistOrRollback(rollback)328        entry329    }330331    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {332        requireUnlocked()333        val idx = entryList.indexOfFirst { it.id == patch.id }334        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")335        val current = entryList[idx]336        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)337        // Only refresh passwordChangedAt when the password value actually changes.338        val passwordChanged = patch.password != null && patch.password != current.password339        // Capture the outgoing password (newest first) so the user can reveal/copy/340        // restore it later. History is computed authoritatively from `current`, not341        // the patch — updateEntry is a full-entry replace, so trusting patch's value342        // would let a stale form clobber the real history. Record the age the old343        // password carried, skip an empty prior, and skip an immediate exact dupe344        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.345        val priorPassword = current.password346        val shouldCapture = passwordChanged &&347            !priorPassword.isNullOrEmpty() &&348            current.passwordHistory?.firstOrNull()?.password != priorPassword349        val nextHistory = if (shouldCapture) {350            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +351                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)352        } else {353            current.passwordHistory354        }355        val updated = patch.copy(356            createdAt = current.createdAt,357            modifiedAt = stamp,358            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,359            passwordHistory = nextHistory,360        )361        val rollback = entryList.toMutableList()362        entryList[idx] = updated363        persistOrRollback(rollback)364        updated365    }366367    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */368    suspend fun deleteEntry(id: String) = mutex.withLock {369        requireUnlocked()370        val idx = entryList.indexOfFirst { it.id == id }371        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")372        val current = entryList[idx]373        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)374        val rollback = entryList.toMutableList()375        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)376        persistOrRollback(rollback)377    }378379    suspend fun restoreEntry(id: String) = mutex.withLock {380        requireUnlocked()381        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }382        if (idx == -1) throw IllegalStateException("deleted entry not found")383        val current = entryList[idx]384        val rollback = entryList.toMutableList()385        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))386        persistOrRollback(rollback)387    }388389    /** Permanent delete — no tombstone, no recovery. */390    suspend fun purgeEntry(id: String) = mutex.withLock {391        requireUnlocked()392        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }393        if (idx == -1) throw IllegalStateException("deleted entry not found")394        val rollback = entryList.toMutableList()395        entryList.removeAt(idx)396        persistOrRollback(rollback)397    }398399    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */400    suspend fun purgeDeletedEntries(): Int = mutex.withLock {401        requireUnlocked()402        val rollback = entryList.toMutableList()403        val before = entryList.size404        entryList.removeAll { it.deletedAt != null }405        val removed = before - entryList.size406        if (removed > 0) persistOrRollback(rollback)407        removed408    }409410    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }411    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }412    fun getDeletedEntries(): List<VaultEntry> =413        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }414415    /** Active entries, optionally narrowed to those carrying [tagFilter]. */416    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {417        val active = entryList.filter { it.deletedAt == null }418        if (tagFilter == null) return active419        val needle = tagFilter.trim().lowercase()420        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }421    }422423    /** Sorted, de-duplicated tags across all active entries. */424    fun getDistinctTags(): List<String> =425        entryList.asSequence()426            .filter { it.deletedAt == null }427            .flatMap { it.tags?.asSequence() ?: emptySequence() }428            .map { it.trim().lowercase() }429            .filter { it.isNotEmpty() }430            .distinct()431            .sorted()432            .toList()433434    /**435     * Distinct non-blank values for an identity field across active entries,436     * most-frequently-used first. Supports "username" and "email"; unknown field437     * names return an empty list. Values keep original casing (deduped438     * case-insensitively) so a suggestion matches what the user typed.439     */440    fun getDistinctFieldValues(field: String): List<String> {441        val select: (VaultEntry) -> String? = when (field) {442            "username" -> { e -> e.username }443            "email" -> { e -> e.email }444            else -> return emptyList()445        }446        return entryList.asSequence()447            .filter { it.deletedAt == null }448            .mapNotNull { select(it)?.trim() }449            .filter { it.isNotEmpty() }450            .groupingBy { it.lowercase() }451            .aggregate { _, acc: Pair<String, Int>?, value, _ ->452                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)453            }454            .values455            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })456            .map { it.first }457    }458459    fun getTombstones(): List<Tombstone> =460        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }461    fun getVaultVersion(): Long = store.version()462463    // ---- Sync apply (does NOT advance the version) ------------------------464465    /**466     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists467     * WITHOUT bumping the version counter, so a sync round cannot cascade into468     * another outbound sync. Returns the number of locally-applied changes.469     */470    suspend fun applyMergedRemoteEntries(471        remoteEntries: List<VaultEntry>,472        remoteTombstones: List<Tombstone>,473    ): Int = mutex.withLock {474        requireUnlocked()475        val tombMap = remoteTombstones.associate { it.id to it.deletedAt }476        var applied = 0477        val byId = entryList.associateBy { it.id }.toMutableMap()478479        for (remote in remoteEntries) {480            val tomb = tombMap[remote.id]481            if (tomb != null && tomb >= remote.modifiedAt) continue482            val local = byId[remote.id]483            if (local == null) {484                entryList.add(remote); byId[remote.id] = remote; applied++485            } else if (remote.modifiedAt > local.modifiedAt) {486                val i = entryList.indexOfFirst { it.id == local.id }487                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }488            }489        }490491        for ((id, deletedAt) in tombMap) {492            val local = byId[id] ?: continue493            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue494            if (deletedAt < local.modifiedAt) continue495            val i = entryList.indexOfFirst { it.id == id }496            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }497        }498499        if (applied > 0) {500            persistSlotA(bumpVersion = false)501            publish()502        }503        applied504    }505506    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {507        _settings.value = settings508        store.writeSettings(settings)509    }510511    // ---- Recovery words (vault metadata) ----------------------------------512513    /** True once the user has confirmed their recovery words for this vault. */514    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet515516    /**517     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.518     * Persisted without bumping the version — recovery metadata is local-only and519     * is not an entry mutation paired devices should sync against.520     */521    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {522        requireUnlocked()523        recoveryWordHash = recoveryWordsHash(words)524        recoveryWordsSet = true525        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow526        // (cross-platform recovery of the sync root key) and Android's whole-payload527        // escrow (offline forgot-password restore of entries). Both derive their key528        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.529        runCatching {530            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))531        }.getOrDefault(false)532    }533534    /** Verify entered words against the stored recovery hash. */535    fun verifyRecoveryWords(words: List<String>): Boolean {536        val stored = recoveryWordHash ?: return false537        return stored == recoveryWordsHash(words)538    }539540    // ---- Recovery escrow (forgot-password) --------------------------------541542    /** True at rest if this vault has any recovery escrow the recovery flow can use. */543    fun hasRecoveryEscrow(): Boolean =544        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false545546    /**547     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of548     * the master password. On success the slot-A plaintext sealed in the escrow is549     * adopted as the live session (entries, rootKey, settings), but no Argon2id550     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a551     * new password and finish unlocking. Returns false when there is no escrow, the552     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).553     *554     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no555     * detached vault key to return here — slot A is wrapped under the rotating556     * Argon2id key — so this opens the escrowed payload directly.557     */558    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {559        val slotA = store.loadSlotA() ?: return false560        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false561562        // Prefer Android's whole-payload escrow: a full offline restore of entries,563        // root key, and settings. Present for Android-created vaults.564        slotA.recoveryEscrow?.let { escrow ->565            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let566            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))567            val keyBytes = recoveryKey.encoded568            try {569                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)570                val payload = VaultCodec.decode(plaintext)571                decoyMode = false572                entryList = payload.entries.toMutableList()573                duressConfigured = payload.duressConfigured574                recoveryWordHash = payload.recoveryWordHash575                recoveryWordsSet = payload.recoveryWordsSet576                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)577                          else generateRecoverableRootKey()578                _settings.value = store.loadSettings()579                VaultCrypto.zero(plaintext)580                return true581            } catch (_: Exception) {582                // Wrong words against this escrow — fall through to the root-key escrow.583            } finally {584                VaultCrypto.zero(material)585                VaultCrypto.zero(keyBytes)586            }587        }588589        // Browser-format vault (only the root-key escrow): recover the 32-byte root590        // key (sync identity) and reset the password; entries return via sync. This591        // is exactly the browser's recovery semantics (`messages.ts:312-347`).592        slotA.rootKeyEscrow?.let { escrow ->593            val rk = unsealRootKeyEscrow(words, escrow) ?: return false594            decoyMode = false595            entryList = mutableListOf()596            duressConfigured = false597            recoveryWordHash = recoveryWordsHash(words)598            recoveryWordsSet = true599            rootKey = rk600            _settings.value = store.loadSettings()601            return true602        }603        return false604    }605606    /**607     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:608     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same609     * words, mark the session fully unlocked, and publish. Returns false if called610     * without a prior successful recovery unlock (no in-memory root key).611     *612     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`613     * into one step, since there is no detached vault key here.614     */615    suspend fun resetPasswordWithRecoveryWords(616        words: List<String>,617        newPassword: String,618    ): Boolean = mutex.withLock {619        if (rootKey == null) return false // must have recovered first620        return try {621            val newSalt = VaultCrypto.generateSalt()622            val newKey = VaultCrypto.deriveKey(newPassword, newSalt)623            val plaintext = VaultCodec.encode(currentPayload())624            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))625            val prior = store.loadSlotA()626            // Rebuild both escrows against the recovered session so their salts rotate627            // with the password reset; fall back to the prior escrows if rebuild fails628            // (e.g. a checksum-invalid phrase). The same 12 words keep working.629            val valid = words.size == 12 && findInvalidWords(words).isEmpty()630            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow631            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow632            val next = StoredVault(633                version = prior?.version ?: 1,634                salt = b64(newSalt),635                blob = blob,636                recoveryEscrow = freshRecovery,637                rootKeyEscrow = freshRootKey,638            )639            if (!store.writeSlotA(next)) return false640            writeMatchingNoiseSlotB(next)641            store.stampLocalMutation()642            derivedKey?.let(VaultCrypto::zero)643            derivedKey = newKey644            VaultCrypto.zero(plaintext)645            publish()646            _isUnlocked.value = true647            true648        } catch (_: Exception) {649            false650        }651    }652653    /**654     * Seal the current slot-A plaintext under a key derived from the 12 BIP39655     * recovery [words] (Android offline-restore escrow). IKM is the browser's656     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].657     * Returns null for a checksum-invalid phrase.658     */659    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {660        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null661        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null662        val salt = VaultCrypto.generateSalt()663        val recoveryKey = deriveRecoveryKey(material, salt)664        val keyBytes = recoveryKey.encoded665        val plaintext = VaultCodec.encode(currentPayload())666        return try {667            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)668            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))669        } finally {670            VaultCrypto.zero(material)671            VaultCrypto.zero(keyBytes)672            VaultCrypto.zero(plaintext)673        }674    }675676    /**677     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,678     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex679     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,680     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap681     * the same root key on the browser. Returns null with no in-memory root key or682     * a checksum-invalid phrase.683     */684    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {685        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null686        val rk = rootKey ?: return null687        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null688        val salt = VaultCrypto.generateSalt()689        val recoveryKey = deriveRecoveryKey(material, salt)690        val keyBytes = recoveryKey.encoded691        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)692        return try {693            // combined = nonce(12) || ciphertext || tag(16)694            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)695            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)696            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)697            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)698            val ct = ctTag.copyOfRange(0, ctTag.size - 16)699            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))700        } finally {701            VaultCrypto.zero(material)702            VaultCrypto.zero(keyBytes)703            VaultCrypto.zero(plaintext)704        }705    }706707    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */708    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {709        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null710        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))711        val keyBytes = recoveryKey.encoded712        return try {713            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)714            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)715            hexToRootKey(String(pt, Charsets.US_ASCII))716        } catch (_: Exception) {717            null718        } finally {719            VaultCrypto.zero(material)720            VaultCrypto.zero(keyBytes)721        }722    }723724    /**725     * Re-seal slot A from the current in-memory state under the live key, carrying726     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs727     * in decoy mode (recovery is never offered for the decoy) and persists without728     * a version bump, like the recovery-words metadata write it replaces.729     */730    private fun persistSlotAWithEscrows(731        recoveryEscrow: RecoveryEscrow?,732        rootKeyEscrow: RootKeyEscrow?,733    ): Boolean {734        val key = derivedKey ?: throw IllegalStateException("vault_locked")735        if (decoyMode) return false736        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")737        val plaintext = VaultCodec.encode(currentPayload())738        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))739        val next = prior.copy(740            blob = blob,741            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,742            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,743        )744        if (!store.writeSlotA(next)) return false745        try {746            writeMatchingNoiseSlotB(next)747        } catch (e: Exception) {748            store.writeSlotA(prior)749            throw e750        }751        return true752    }753754    // ---- Internals --------------------------------------------------------755756    private fun requireUnlocked() {757        if (derivedKey == null) throw IllegalStateException("vault_locked")758    }759760    /**761     * On unlock, hard-delete trashed entries older than the retention window.762     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"763     * (the app's "Never" option), so nothing is purged. Persists without a764     * version bump and without touching the publish state (caller publishes).765     */766    private fun autoPurgeExpiredTrash() {767        val days = _settings.value.trashRetentionDays ?: return768        if (days <= 0) return769        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L770        val before = entryList.size771        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }772        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }773    }774775    /** The slot-A plaintext envelope for the current in-memory session. */776    private fun currentPayload(): VaultPayload =777        VaultPayload(778            entries = entryList.toList(),779            duressConfigured = duressConfigured,780            rootKeyB64 = rootKey?.let(::b64) ?: "",781            recoveryWordHash = recoveryWordHash,782            recoveryWordsSet = recoveryWordsSet,783        )784785    /**786     * Adopt the payload's root key, lazily minting one for legacy payloads.787     * A freshly-minted key is persisted WITHOUT bumping the version — adopting788     * a root key is not a mutation paired devices should sync against.789     */790    private fun ensureRootKey(payload: VaultPayload) {791        if (payload.rootKeyB64.isNotBlank()) {792            rootKey = unb64(payload.rootKeyB64)793            return794        }795        rootKey = generateRecoverableRootKey()796        runCatching { persistSlotA(bumpVersion = false) }797    }798799    private fun publish() {800        _entries.value = entryList.filter { it.deletedAt == null }801    }802803    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {804        val plaintext = VaultCodec.encode(currentPayload())805        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))806        return StoredVault(version = 1, salt = b64(salt), blob = blob)807    }808809    private fun writeMatchingNoiseSlotB(slotA: StoredVault) {810        val approxBytes = (slotA.blob.length / 2).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)811        val noise = ByteArray(approxBytes).also(rng::nextBytes)812        store.writeSlotB(b64(noise))813    }814815    /** Re-seal slot A from the current in-memory state under the live key. */816    private fun persistSlotA(bumpVersion: Boolean): Boolean {817        val key = derivedKey ?: throw IllegalStateException("vault_locked")818        // Decoy sessions persist to the decoy slot only — never touch the real slot A.819        if (decoyMode) {820            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")821            val plaintext = VaultCodec.encode(currentPayload())822            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))823            return store.writeDecoySlot(prior.copy(blob = blob))824        }825        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")826        val plaintext = VaultCodec.encode(currentPayload())827        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))828        val next = prior.copy(blob = blob)829        if (!store.writeSlotA(next)) return false830        // Slot A committed — keep slot B size-matched, then advance the version831        // for local writes only. Restore slot A if a later step fails.832        try {833            writeMatchingNoiseSlotB(next)834            if (bumpVersion) store.stampLocalMutation()835        } catch (e: Exception) {836            store.writeSlotA(prior)837            throw e838        }839        return true840    }841842    /** Persist a local mutation, rolling the in-memory list back on failure. */843    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {844        try {845            if (!persistSlotA(bumpVersion = true)) {846                entryList = rollback847                throw IllegalStateException("storage_write_failed")848            }849        } catch (e: Exception) {850            entryList = rollback851            throw e852        }853        publish()854    }855}