VaultRepository.kt

101.1 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton2526/** Lenient parser for the Codex vault-blob wrapper only. */27private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)3132/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */33private const val PASSWORD_HISTORY_MAX = 5034private const val VAULT_HISTORY_MAX = 53536// How long the transient-background-lock suppression stays valid. If the37// launched system Activity never reports the expected lifecycle stop (picker38// crashed, user backed out into another app, OEM killed the picker process),39// the flag expires and the next genuine background event locks the vault40// rather than consuming a stale suppression.41private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4243sealed interface UnlockResult {44    data object Success : UnlockResult45    data object WrongPassword : UnlockResult46    data class Error(val reason: String) : UnlockResult47}4849sealed interface CreateVaultResult {50    data object Success : CreateVaultResult51    data class Error(val reason: String) : CreateVaultResult52}5354sealed interface AdoptVaultResult {55    data class Success(val vaultId: String) : AdoptVaultResult56    data class Error(val reason: String) : AdoptVaultResult57}5859/**60 * In-memory vault session backed by [VaultStore]. Faithful port of the browser61 * `vault.ts` semantics that matter for correctness:62 *63 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched64 *    noise blob so a fresh install shows two similarly-shaped records.65 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a66 *    deletion always wins last-write-wins against an in-flight edit.67 *  - Local writes bump the version counter; sync-applied writes do NOT (so a68 *    sync round never cascades into another outbound sync).69 *  - add/update/delete roll back in-memory state if the disk write fails.70 *  - destroyVault never touches the Pro license (separate store).71 */72@Singleton73class VaultRepository @Inject constructor(74    private val store: VaultStorage,75) {76    private val mutex = Mutex()77    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend78    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync79    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so80    // lock() can serialize against an in-flight suspend op without blocking on81    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and82    // take [stateLock] only around the state-write portions. Acquisition order83    // is always mutex → stateLock.84    private val stateLock = ReentrantLock()85    private val rng = SecureRandom()8687    /**88     * Run [block] with [stateLock] held. Used to fence the small state-write89     * portions of suspend operations against [lock] and [requireUnlocked]90     * racing from another thread.91     */92    private inline fun <T> mutateState(block: () -> T): T {93        stateLock.lock()94        return try { block() } finally { stateLock.unlock() }95    }9697    private var derivedKey: ByteArray? = null98    private var rootKey: ByteArray? = null99    private var entryList: MutableList<VaultEntry> = mutableListOf()100    private var duressConfigured: Boolean = false101    private var recoveryWordHash: String? = null102    private var recoveryWordsSet: Boolean = false103    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */104    private var decoyMode: Boolean = false105106    private val _isUnlocked = MutableStateFlow(false)107    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()108109    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())110    /** Active (non-deleted) entryList. */111    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()112113    private val _settings = MutableStateFlow(VaultSettings())114    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()115116    fun hasVault(): Boolean = store.hasVault()117118    // ---- Codex (printed paper backup) -------------------------------------119120    /**121     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the122     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's123     * A/B naming where slot B is the duress decoy); the noise slot is local-only124     * and never crosses devices. Restorable on any device that has the master125     * password. Null when no vault exists.126     */127    fun codexVaultBlobBase64(): String? {128        val slotA = store.loadSlotA() ?: return null129        // H-04: duress slot is always present after createVault (real or fake),130        // so the wrapper can rely on it being loadable.131        val slotD = store.loadDecoySlot() ?: return null132        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""133        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))134    }135136    /**137     * Replace the local vault from a Codex blob, then unlock with [password].138     * Writes slot A (and a size-matched noise slot B) before unlocking so the139     * restored vault is at rest exactly as a freshly-created one would be.140     */141    /** A slot is stored either as a JSON string (browser) or inline object (Android). */142    private fun JsonElement?.asSlotJson(): String? = when {143        this == null || this is JsonNull -> null144        this is JsonPrimitive && isString -> content145        else -> toString()146    }147148    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {149        var candidateKey: ByteArray? = null150        var plaintext: ByteArray? = null151        return try {152            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)153            // Parsed as JSON, not matched with a regex. The wrapper is154            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which155            // generates most real Codexes — writes each slot as a JSON156            // *string*, with "b" present. A regex looking for an object157            // literal under "a" at the very end of the wrapper matches158            // neither of those, so every browser-generated Codex failed here159            // with "invalid_codex_blob" after decoding perfectly.160            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject161                ?: return UnlockResult.Error("invalid_codex_blob")162            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")163            // A Codex printed from the browser carries a browser-shaped slot,164            // which is the common case — most Codexes are printed there. It is165            // translated to Android's shape (see BrowserSlot) rather than166            // rejected. A translation failure is a wrong password, not a bad167            // blob: the codes decoded fine, the password just didn't open them.168            val slotA = VaultCodec.decodeSlot(slotJson)169                ?: BrowserSlot.parse(slotJson)?.let {170                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword171                }172                ?: return UnlockResult.Error("invalid_codex_blob")173            // C-01: authenticate the candidate ENTIRELY in memory before touching174            // storage. VaultCodec.decodeSlot is structural — an Android-shaped175            // Codex parses under ANY password — so writing slot A first and176            // unlocking afterwards destroyed the device's existing vault every177            // time the user mistyped the password.178            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))179            plaintext = decryptSlotABlob(slotA, candidateKey)180            // LV-1: a Codex printed from a vault sealed before NFC normalization181            // opens only under the raw password bytes. unlockLocked used to supply182            // this fallback; authenticating up front here has to keep it, or those183            // Codexes become unrestorable.184            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {185                VaultCrypto.zero(candidateKey)186                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))187                plaintext = decryptSlotABlob(slotA, candidateKey)188            }189            if (plaintext == null) return UnlockResult.WrongPassword190            val payload = try {191                VaultCodec.decode(plaintext)192            } catch (_: Exception) {193                return UnlockResult.Error("invalid_codex_blob")194            }195196            val priorA = store.loadSlotA()197            val priorB = store.loadSlotB()198            val priorD = store.loadDecoySlot()199            // Carry the real duress slot across when the Codex has one — the200            // browser writes "b" as its duress-shaped slot, and dropping it201            // here would silently destroy a configured decoy.202            // H-04: write browser B into the duress slot (not the noise slot),203            // translating browser-shaped slots to Android's StoredVault shape.204            val codexDuressJson = wrapper["b"].asSlotJson()205            val codexDuress: StoredVault? = codexDuressJson?.let {206                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->207                    // Wrong-password on the duress slot only (browser decoys can208                    // use a different passphrase). Fall through to a fake duress209                    // — the slot-A password was already authenticated.210                    BrowserSlot.toAndroidSlot(b, password)211                }212            }213214            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")215            val committed = runCatching {216                // Noise slot is always size-matched to slot A — local-only,217                // never written from a Codex.218                if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")219                // H-04: duress slot always present; real one from the Codex or220                // a fake the size of slot A.221                val duressOk = if (codexDuress != null) {222                    store.writeDecoySlot(codexDuress)223                } else {224                    writeFakeDuressSlot(slotA)225                }226                if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")227                store.stampLocalMutation()228            }.isSuccess229            if (!committed) {230                rollbackRestore(priorA, priorB, priorD)231                return UnlockResult.Error("storage_write_failed")232            }233234            val ownedKey = candidateKey!!235            candidateKey = null // the session owns the key from here on236            adoptSlotA(ownedKey, payload)237        } catch (e: Exception) {238            // A raw kotlinx parse error ("Unexpected JSON token at offset N")239            // reaching the user says nothing about WHICH json failed — the240            // wrapper, or the payload decrypted out of it. Codex screens are241            // FLAG_SECURE so this can't be screenshotted either. Debug builds242            // log the structure around the failure; alphanumerics are masked so243            // no ciphertext or vault content lands in a log.244            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))245            UnlockResult.Error(e.message ?: "restore_failed")246        } finally {247            plaintext?.let(VaultCrypto::zero)248            candidateKey?.let(VaultCrypto::zero)249        }250    }251252    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */253    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =254        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()255256    /**257     * Undo a partially-committed Codex restore. Every step is attempted258     * independently so one failure cannot strand the others. With no prior vault259     * the accurate inverse is having none at all, rather than a half-written260     * slot A that [hasVault] would report as a real vault.261     */262    private fun rollbackRestore(priorA: StoredVault?, priorB: String?, priorD: StoredVault? = null) {263        val failures = mutableListOf<String>()264        if (priorA == null) {265            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }266        } else {267            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }268                .onFailure { failures += "writeSlotA: $it" }269            if (priorB != null) {270                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }271                    .onFailure { failures += "writeSlotB: $it" }272            }273            // H-04: restore the prior duress slot — a half-committed restore that274            // left the duress record on the Codex side and slot A on the prior275            // side would put the at-rest pair in a state that never existed.276            if (priorD != null) {277                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }278                    .onFailure { failures += "writeDecoySlot: $it" }279            } else {280                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }281            }282        }283        // Log is a throwing stub off-device, and a failed log must never mask284        // the rollback result the caller is about to report.285        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }286    }287288    /** Debug-only: structure of a failed Codex restore, with content masked. */289    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {290        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)291        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()292        val window = offset?.let {293            val from = (it - 40).coerceAtLeast(0)294            val to = (it + 40).coerceAtMost(wrapperJson.length)295            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")296        }297        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()298        val slot = wrapper?.get("a")299        buildString {300            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)301            append(" | blobBase64=").append(vaultBlobBase64.length)302            append(" wrapperChars=").append(wrapperJson.length)303            append(" wrapperParses=").append(wrapper != null)304            append(" keys=").append(wrapper?.keys)305            append(" slotAKind=").append(306                when {307                    slot == null -> "absent"308                    slot is JsonPrimitive && slot.isString -> "string"309                    slot is JsonObject -> "object:" + slot.keys310                    else -> slot::class.simpleName311                },312            )313            if (window != null) append(" masked@").append(offset).append("=").append(window)314        }315    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }316317    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {318        return try {319            val salt = VaultCrypto.generateSalt()320            val key = VaultCrypto.deriveKey(password, salt)321            mutateState {322                entryList = mutableListOf()323                duressConfigured = false324                recoveryWordHash = null325                recoveryWordsSet = false326                decoyMode = false327                rootKey = generateRecoverableRootKey()328            }329            // M-04: the fields above have to be live before sealSlotA, since it330            // seals currentPayload(). That stages a root key the session would331            // otherwise keep after a failed create — [lock] discards the whole332            // staged session on every non-success exit below.333            val slotA = sealSlotA(key, salt)334            if (!store.writeSlotA(slotA)) {335                lock()336                return CreateVaultResult.Error("storage_write_failed")337            }338            // P1-3 (codex): every later step must roll the slot pair back if it339            // fails — leaving a half-built vault is worse than refusing to make it.340            val created = try {341                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")342                // H-04: duress slot must be present from the start so its343                // existence on disk is independent of duressConfigured.344                if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")345                store.stampLocalMutation()346                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")347                true348            } catch (_: Exception) {349                // Half-built vault on disk is worse than no vault — refuse to350                // create if any step past slot A fails.351                store.clearVault()352                false353            }354            if (!created) {355                lock()356                return CreateVaultResult.Error("storage_write_failed")357            }358            mutateState {359                derivedKey = key360                publish()361                _isUnlocked.value = true362            }363            CreateVaultResult.Success364        } catch (e: Exception) {365            lock()366            CreateVaultResult.Error(e.message ?: "create_failed")367        }368    }369370    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {371        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")372        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")373        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {374            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")375        }376        var adoptedRootKey: ByteArray? = null377        var nextDerivedKey: ByteArray? = null378        var sessionRootKey: ByteArray? = null379        return try {380            adoptedRootKey = hexToRootKey(rootKeyHex)381            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {382                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")383            }384            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)385            val salt = VaultCrypto.generateSalt()386            nextDerivedKey = VaultCrypto.deriveKey(password, salt)387            sessionRootKey = adoptedRootKey.copyOf()388            val payload = VaultPayload(389                entries = emptyList(),390                duressConfigured = false,391                rootKeyB64 = b64(sessionRootKey),392                recoveryWordHash = null,393                recoveryWordsSet = false,394            )395            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))396            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)397            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")398            // P1-3 (codex): same partial-commit guard as createVault — any failure399            // past slot A wipes the half-built vault and refuses adoption.400            val created = try {401                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")402                // H-04: duress slot must be present from the start so its403                // existence on disk is independent of duressConfigured.404                if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")405                store.stampLocalMutation()406                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")407                true408            } catch (_: Exception) {409                store.clearVault()410                false411            }412            if (!created) return AdoptVaultResult.Error("storage_write_failed")413414            val previousDerivedKey = derivedKey415            val previousRootKey = rootKey416            mutateState {417                derivedKey = nextDerivedKey418                rootKey = sessionRootKey419                nextDerivedKey = null420                sessionRootKey = null421                entryList = mutableListOf()422                duressConfigured = false423                recoveryWordHash = null424                recoveryWordsSet = false425                decoyMode = false426                publish()427                _isUnlocked.value = true428            }429            previousDerivedKey?.let(VaultCrypto::zero)430            previousRootKey?.let(VaultCrypto::zero)431            AdoptVaultResult.Success(vaultId)432        } catch (e: Exception) {433            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")434        } finally {435            adoptedRootKey?.let(VaultCrypto::zero)436            nextDerivedKey?.let(VaultCrypto::zero)437            sessionRootKey?.let(VaultCrypto::zero)438        }439    }440441    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }442443    /** Unlock body without acquiring the mutex — callers must already hold it. */444    private fun unlockLocked(password: String): UnlockResult {445        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")446        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))447        val payload = try {448            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))449        } catch (_: Exception) {450            VaultCrypto.zero(key)451            // LV-1: a vault sealed before NFC normalization with a non-NFC452            // password derives a different key now. If the supplied password is453            // not already in NFC form, retry with the raw (un-normalized) bytes.454            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {455                val legacy = tryLegacyRawUnlock(password, slotA)456                if (legacy != null) return legacy457            }458            return UnlockResult.WrongPassword459        }460        return adoptSlotA(key, payload)461    }462463    /**464     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw465     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed466     * under the NFC-normalized key — so every future unlock takes the normal467     * path. Returns null when the raw key also fails (genuinely wrong password)468     * OR when any step of the heal commits fails (the original slot A is restored469     * so disk and session stay aligned).470     */471    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {472        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))473        val payload = try {474            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))475        } catch (_: Exception) {476            VaultCrypto.zero(rawKey)477            return null478        }479        // Heal: re-seal slot A under the NFC key (mirrors changePassword's480        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.481        val newSalt = VaultCrypto.generateSalt()482        val newKey = VaultCrypto.deriveKey(password, newSalt)483        val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))484        val next = StoredVault(485            version = slotA.version,486            salt = b64(newSalt),487            blob = blob,488            recoveryEscrow = slotA.recoveryEscrow,489            rootKeyEscrow = slotA.rootKeyEscrow,490        )491        // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,492        // or stampLocalMutation must restore the prior slot A AND slot B so disk493        // and the eventual session key never disagree on the wrapping.494        val priorB = store.loadSlotB()495        val healed = if (!store.writeSlotA(next)) {496            false497        } else try {498            if (!writeMatchingNoiseSlotB(next)) {499                runCatching { store.writeSlotA(slotA) }500                if (priorB != null) runCatching { store.writeSlotB(priorB) }501                false502            } else if (!writeCompanionDuressSlot(next)) {503                runCatching { store.writeSlotA(slotA) }504                if (priorB != null) runCatching { store.writeSlotB(priorB) }505                false506            } else {507                store.stampLocalMutation()508                true509            }510        } catch (_: Exception) {511            runCatching { store.writeSlotA(slotA) }512            if (priorB != null) runCatching { store.writeSlotB(priorB) }513            false514        }515        return if (healed) {516            VaultCrypto.zero(rawKey)517            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot518        } else {519            VaultCrypto.zero(newKey)520            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key521        }522    }523524    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */525    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {526        return try {527            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they528            // can throw after the fields above are live. There is no prior session529            // worth restoring — adoption has already replaced it — so a failed530            // adoption discards everything rather than leaving entries and keys531            // resident behind _isUnlocked=false.532            mutateState {533                derivedKey = key534                decoyMode = false535                entryList = payload.entries.toMutableList()536                duressConfigured = payload.duressConfigured537                recoveryWordHash = payload.recoveryWordHash538                recoveryWordsSet = payload.recoveryWordsSet539                _settings.value = store.loadSettings()540                ensureRootKey(payload)541                autoPurgeExpiredTrash()542                publish()543                _isUnlocked.value = true544            }545            UnlockResult.Success546        } catch (e: Exception) {547            lock()548            UnlockResult.Error(e.message ?: "unlock_failed")549        }550    }551552    // ---- Constant-time unlock (MF-5) --------------------------------------553554    /**555     * A slot-A unlock that has been derived but not yet adopted. Holds the live556     * Argon2id key and decoded payload so the caller can derive several candidates557     * concurrently and commit only the winner. The key is owned by this token until558     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.559     */560    class SlotAUnlock internal constructor(561        internal val key: ByteArray,562        internal val payload: VaultPayload,563    )564565    /**566     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.567     * Pure computation (no mutex), so it can run concurrently with other candidate568     * derivations before the caller decides which to commit (MF-5). Returns null on569     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).570     */571    fun tryUnlockSlotA(password: String): SlotAUnlock? {572        val slotA = store.loadSlotA() ?: return null573        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))574        return try {575            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))576            SlotAUnlock(key, payload)577        } catch (_: Exception) {578            VaultCrypto.zero(key)579            null580        }581    }582583    /** Adopt a previously-derived [SlotAUnlock] as the live session. */584    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {585        adoptSlotA(unlock.key, unlock.payload)586    }587588    /** Zero a derived [SlotAUnlock] that will not be adopted. */589    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)590591    // ---- Duress slot B (MF-6) ---------------------------------------------592593    /**594     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key595     * and decoded decoy payload so it can be derived concurrently with slot A and596     * only committed if the duress passphrase was the one entered (MF-5/MF-6).597     */598    class DuressUnlock internal constructor(599        internal val key: ByteArray,600        internal val payload: VaultPayload,601    )602603    /**604     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the605     * session. Success means the entered password is the duress passphrase that keys606     * slot B — indistinguishable from the real slot A at the lock screen. Pure607     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].608     * Returns null when no duress slot exists or the password is not the duress one.609     */610    fun tryUnlockDuress(password: String): DuressUnlock? {611        val slot = store.loadDecoySlot() ?: return null612        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))613        return try {614            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))615            DuressUnlock(key, payload)616        } catch (_: Exception) {617            VaultCrypto.zero(key)618            null619        }620    }621622    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */623    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {624        return try {625            mutateState {626                derivedKey = unlock.key627                decoyMode = true628                entryList = unlock.payload.entries.toMutableList()629                duressConfigured = false630                recoveryWordHash = unlock.payload.recoveryWordHash631                recoveryWordsSet = unlock.payload.recoveryWordsSet632                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)633                          else VaultCrypto.generateRootKey()634                _settings.value = store.loadSettings()635                publish()636                _isUnlocked.value = true637            }638            UnlockResult.Success639        } catch (e: Exception) {640            UnlockResult.Error(e.message ?: "decoy_unlock_failed")641        }642    }643644    /** Zero a derived [DuressUnlock] that will not be adopted. */645    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)646647    /**648     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an649     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The650     * passphrase is never stored — only its ability to decrypt slot B proves it.651     * Requires the real vault to be unlocked and not itself a decoy session.652     */653    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {654        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().655        val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }656        if (!unlockedAndReal) return false657        // Reject a duress passphrase that also opens the real vault: it would make the658        // lock screen ambiguous (both slots would decrypt for the same input).659        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }660        return try {661            val salt = VaultCrypto.generateSalt()662            val key = VaultCrypto.deriveKey(duressPassphrase, salt)663            // An empty decoy vault with its own fresh sync root key — nothing real leaks.664            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))665            val basePlaintext = VaultCodec.encode(decoyPayload)666            // H-04: pad the duress plaintext to slot A's ciphertext length so the667            // duress blob matches slot A's blob on disk. VaultCodec.decode strips668            // trailing NULs, so the extra padding survives encrypt but never669            // reaches the JSON parser.670            // ponytail: only re-pads at configuration time — if slot A grows past671            // its initial bucket (entries cross 4096/16384/etc.), the real decoy672            // stays at the smaller bucket. Re-padding on every persist requires673            // keeping the duress passphrase in memory, which we don't.674            val slotA = store.loadSlotA()675            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0676            val plaintext = if (targetSize > basePlaintext.size) basePlaintext.copyOf(targetSize) else basePlaintext677            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))678            VaultCrypto.zero(key)679            VaultCrypto.zero(plaintext)680            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))681            if (ok) {682                mutateState { duressConfigured = true }683                runCatching { persistSlotA(bumpVersion = false) }684            }685            ok686        } catch (_: Exception) {687            false688        }689    }690691    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */692    suspend fun clearDuress(): Boolean = mutex.withLock {693        // H-04: presence-indistinguishable — replace the real decoy with a fake694        // blob of slot A's size rather than removing the duress record. The695        // record stays on disk at the same length, so an at-rest observer696        // cannot tell "duress cleared" from "no duress ever configured".697        store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) } }698            ?: store.clearDecoySlot()699        // H-03: stateLock-fence the derivedKey/decoyMode read AND the700        // duressConfigured write so a concurrent lock() cannot land between them.701        val shouldClear = mutateState { derivedKey != null && !decoyMode }702        if (shouldClear) {703            mutateState { duressConfigured = false }704            runCatching { persistSlotA(bumpVersion = false) }705        }706        true707    }708709    /** Unlock with an already-derived AES-256 key (biometric path). */710    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {711        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")712        return try {713            val payload = VaultCodec.decode(714                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),715            )716            mutateState {717                derivedKey = key.copyOf()718                decoyMode = false719                entryList = payload.entries.toMutableList()720                duressConfigured = payload.duressConfigured721                recoveryWordHash = payload.recoveryWordHash722                recoveryWordsSet = payload.recoveryWordsSet723                _settings.value = store.loadSettings()724                ensureRootKey(payload)725                autoPurgeExpiredTrash()726                publish()727                _isUnlocked.value = true728            }729            UnlockResult.Success730        } catch (_: Exception) {731            UnlockResult.WrongPassword732        }733    }734735    fun lock() {736        // H-03: must observe derivedKey/entryList atomically with any in-flight737        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,738        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock739        // guards just the brief state reset.740        stateLock.lock()741        try {742            derivedKey?.let(VaultCrypto::zero)743            rootKey?.let(VaultCrypto::zero)744            derivedKey = null745            rootKey = null746            entryList = mutableListOf()747            duressConfigured = false748            recoveryWordHash = null749            recoveryWordsSet = false750            decoyMode = false751            _entries.value = emptyList()752            _isUnlocked.value = false753        } finally {754            stateLock.unlock()755        }756    }757758    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L759760    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock761    // is a stub off-device).762    @VisibleForTesting763    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }764765    /**766     * Call immediately before launching a system Activity (a SAF file767     * picker/save dialog, a share sheet) that briefly takes the whole process768     * out of the foreground as an expected part of an in-app flow — not a769     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the770     * two apart on its own (it fires for both), and without this, exporting or771     * importing a file silently locked and wiped the vault mid-flow the moment772     * the picker took over, before its result ever reached the app.773     *774     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]775     * (M-07); a never-consumed stale flag cannot suppress a later real lock.776     */777    fun expectTransientBackground() {778        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS779    }780781    /**782     * Consumed once by the next background-lock check; see [expectTransientBackground].783     * Returns false (and clears the flag) if the suppression has expired or was784     * never set — a genuine background event must always lock.785     */786    fun consumeTransientBackgroundSuppression(): Boolean {787        val until = suppressNextBackgroundLockUntil788        val active = until != 0L && nowMs() < until789        suppressNextBackgroundLockUntil = 0L790        return active791    }792793    // ---- Sync identity ----------------------------------------------------794795    /**796     * The vault's stable sync master key (a copy). Caller must zero it. Backs797     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry798     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].799     */800    /**801     * HOLE 5 — the duress vault never syncs.802     *803     * [commitDuressUnlock] gives the decoy session a root key so its own codex804     * and recovery paths work, which means (unlike the browser, where the decoy805     * path simply has no root key) the sync surface has to refuse explicitly.806     * Without this the decoy vault would present a second, different vault_id807     * to a paired device, and a coerced user opening it would make sync visibly808     * fail on their other devices — a duress indicator, which is the one thing809     * a duress vault must never be.810     */811    // M-04: the "null when locked" half of the contract was documented but never812    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see813    // [unlockWithRecoveryWords], which installs a root key with no password key814    // yet) and in a create that failed after staging. Sync must fail closed815    // there, so the authoritative unlocked flag gates it, not just decoyMode.816    fun syncMasterKey(): ByteArray? =817        if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()818819    /**820     * The current session's root key, decoy included. For local-only uses that821     * must behave identically in a decoy session — Codex backup and recovery822     * words — because a feature that errors under duress is itself a duress823     * tell. In a decoy session this is the DECOY's root key (see824     * [commitDuressUnlock]), never the real vault's, so neither surface can825     * leak the real vault. Anything that leaves the device must use826     * [syncMasterKey] instead.827     */828    fun sessionRootKey(): ByteArray? = rootKey?.copyOf()829830    /** Stable per-vault id two paired devices must share to sync. Null when831     *  locked, and never present for the decoy vault (see [syncMasterKey]). */832    fun syncVaultId(): String? =833        if (decoyMode || !_isUnlocked.value) null834        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }835836    /**837     * Browser parity (MV-8): rotate the sync identity by replacing the stable838     * root key. Entries remain under the master-password key, while sync839     * credentials, vault_id, and recovery words all change.840     */841    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {842        installRootKey(generateRecoverableRootKey())843    }844845    /**846     * Join the vault behind [rootKeyHex]: adopt that vault's identity while847     * keeping every local entry.848     *849     * Sync identity (vault id, signing keys) derives from the root key, and850     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently851     * created vaults can never reconcile. Taking the other side's root key is852     * what makes them one vault; from the next sync on, both entry sets merge.853     *854     * Entries survive because they are sealed under the password-derived key,855     * not the root key — the same property [rotateSyncIdentity] relies on.856     *857     * CONSEQUENCE, which the caller must have disclosed and had confirmed:858     * this device's previous recovery words stop working for good, and its new859     * words are the source vault's words, because words are a deterministic860     * encoding of the root key.861     */862    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {863        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {864            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")865        }866        if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")867        if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")868        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")869870        val incoming = hexToRootKey(rootKeyHex)871        if (incoming.size != VaultCrypto.KEY_SIZE) {872            VaultCrypto.zero(incoming)873            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")874        }875        // Refuse before committing rather than leave this device holding a vault876        // whose recovery words cannot be typed back. generateRecoverableRootKey877        // screens its own keys for this; a key arriving from a peer has not been.878        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess879        if (!roundTrips) {880            VaultCrypto.zero(incoming)881            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")882        }883884        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)885        return if (installRootKey(incoming)) {886            AdoptVaultResult.Success(vaultId)887        } else {888            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")889        }890    }891892    /**893     * The recovery words [rootKeyHex] will produce once joined, or null if it is894     * not a usable root key. Lets the join flow show the user the words that are895     * about to replace theirs *before* anything is committed.896     */897    fun previewRecoveryWords(rootKeyHex: String): List<String>? {898        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null899        val bytes = hexToRootKey(rootKeyHex)900        return try {901            val words = rootKeyToWords(bytes)902            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null903        } catch (_: Exception) {904            null905        } finally {906            VaultCrypto.zero(bytes)907        }908    }909910    /**911     * Swap the vault's root key, keeping every entry, and re-escrow under the912     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds913     * [mutex].914     */915    private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {916        // H-03: snapshot the live fields under stateLock so the success and the917        // rollback paths both see consistent prior/new state. Writing the new918        // rootKey/recovery fields is inside its own mutateState at the end.919        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays920        // — holding live references would let a zero-on-failure operation also921        // wipe the session's still-live key arrays.922        data class Snapshot(923            val key: ByteArray,924            val priorRootKey: ByteArray,925            val priorRecoveryHash: String?,926            val priorRecoverySet: Boolean,927        )928        val snap = mutateState {929            val k = derivedKey ?: return false930            if (decoyMode) return false931            val priorRoot = rootKey ?: return false932            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)933        } as Snapshot? ?: return false934        val priorSlot = store.loadSlotA() ?: run {935            VaultCrypto.zero(snap.priorRootKey)936            return false937        }938        val nextWords = rootKeyToWords(nextRootKey)939940        return try {941            takeVaultSnapshot("local_save")942            mutateState {943                rootKey = nextRootKey944                recoveryWordHash = recoveryWordsHash(nextWords)945                recoveryWordsSet = false946            }947948            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)949                ?: throw IllegalStateException("recovery_escrow_failed")950            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)951                ?: throw IllegalStateException("root_escrow_failed")952            val plaintext = VaultCodec.encode(currentPayload())953            val blob = try {954                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))955            } finally {956                VaultCrypto.zero(plaintext)957            }958            val nextSlot = priorSlot.copy(959                blob = blob,960                recoveryEscrow = nextRecoveryEscrow,961                rootKeyEscrow = nextRootKeyEscrow,962            )963            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")964            try {965                writeMatchingNoiseSlotB(nextSlot)966                // H-04: a root-key rotation re-seals slot A, so the duress slot967                // (real or fake) must be re-padded to the new ciphertext length.968                if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")969                store.stampLocalMutation()970            } catch (e: Exception) {971                store.writeSlotA(priorSlot)972                throw e973            }974            VaultCrypto.zero(snap.priorRootKey)975            mutateState { publish() }976            true977        } catch (_: Exception) {978            mutateState {979                rootKey = snap.priorRootKey980                recoveryWordHash = snap.priorRecoveryHash981                recoveryWordsSet = snap.priorRecoverySet982            }983            VaultCrypto.zero(nextRootKey)984            store.writeSlotA(priorSlot)985            false986        }987    }988989    /**990     * A copy of the live Argon2id-derived vault key, for biometric escrow only991     * (wrapped under a hardware KeyStore key on biometric enable). Null when992     * locked. Caller must [VaultCrypto.zero] it after use.993     */994    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()995996    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {997        val slotA = store.loadSlotA() ?: return false998        var oldKey: ByteArray? = null999        var newKey: ByteArray? = null1000        var plaintext: ByteArray? = null1001        return try {1002            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1003            val payload = VaultCodec.decode(1004                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),1005            )1006            val newSalt = VaultCrypto.generateSalt()1007            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1008            // Re-seal the existing payload under the new key/salt.1009            plaintext = VaultCodec.encode(payload)1010            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1011            // The escrow seals the slot-A PLAINTEXT, which a password change does not1012            // alter — only the wrapping changes — so carry it forward; rebuilding the1013            // StoredVault from scratch would silently drop it.1014            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1015            // content that a password change does not alter — only the slot-A1016            // wrapping rotates. The root-key escrow staying stable across password1017            // changes matches the browser (`vault.ts:363-364`).1018            val next = StoredVault(1019                version = slotA.version,1020                salt = b64(newSalt),1021                blob = blob,1022                recoveryEscrow = slotA.recoveryEscrow,1023                rootKeyEscrow = slotA.rootKeyEscrow,1024            )1025            // H-01: durably commit slot A + size-matched noise + version stamp1026            // before swapping the session key. A partial commit (new A on disk,1027            // old key in memory) would have the next save encrypt under the old1028            // key while matching disk on the new one.1029            val priorB = store.loadSlotB()1030            if (!commitVaultRotation(slotA, priorB, next)) return false1031            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1032            // inside the same stateLock critical section, so a concurrent lock()1033            // can't observe the in-between (zeroed but not yet reassigned).1034            mutateState {1035                oldKey?.let(VaultCrypto::zero); oldKey = null1036                derivedKey?.let(VaultCrypto::zero)1037                derivedKey = newKey; newKey = null1038            }1039            true1040        } catch (_: Exception) {1041            false1042        } finally {1043            oldKey?.let(VaultCrypto::zero)1044            newKey?.let(VaultCrypto::zero)1045            plaintext?.let(VaultCrypto::zero)1046        }1047    }10481049    /**1050     * Irreversible wipe. Locks the session and clears every vault key. The Pro1051     * license is in a separate store and is preserved by construction.1052     */1053    suspend fun destroyVault() = mutex.withLock {1054        lock()1055        store.clearVault()1056        store.clearDecoySlot()1057    }10581059    // ---- Entry CRUD -------------------------------------------------------10601061    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1062        requireUnlocked()1063        val stamp = VaultCrypto.monotonicEntryTimestamp()1064        val now = System.currentTimeMillis()1065        val entry = withDerivedLast4(draft.copy(1066            id = draft.id.ifBlank { UUID.randomUUID().toString() },1067            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1068            modifiedAt = stamp,1069            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1070        ))1071        // H-03: capture rollback AND write entryList under stateLock so a1072        // concurrent lock() cannot zero the session between the read and the write.1073        val rollback = mutateState {1074            val r = entryList.toMutableList()1075            entryList.add(entry)1076            r1077        }1078        persistOrRollback(rollback)1079        entry1080    }10811082    /**1083     * Insert many entries with a single encrypt-and-write at the end.1084     *1085     * Importing a real vault through [addEntry] was quadratic: every entry took1086     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1087     * restore did 806 full-vault writes and hung the app long enough to ANR.1088     * Rolls back to the pre-import list if the one write fails, matching1089     * [addEntry]'s all-or-nothing behaviour.1090     */1091    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1092        requireUnlocked()1093        if (drafts.isEmpty()) return 01094        val now = System.currentTimeMillis()1095        val ready = drafts.map { draft ->1096            val stamp = VaultCrypto.monotonicEntryTimestamp()1097            withDerivedLast4(1098                draft.copy(1099                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1100                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1101                    modifiedAt = stamp,1102                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1103                ),1104            )1105        }1106        // H-03: capture rollback AND append every entry under stateLock.1107        val rollback = mutateState {1108            val r = entryList.toMutableList()1109            entryList.addAll(ready)1110            r1111        }1112        persistOrRollback(rollback)1113        return drafts.size1114    }11151116    /**1117     * M-01: replace the whole active set with [newEntries] in one write.1118     *1119     * Import's REPLACE mode used to soft-delete every current entry one durable1120     * write at a time (swallowing each failure) and then batch-add, so a failed1121     * add left the old entries stranded in Trash with nothing restored. Staging1122     * the entire next list in memory and committing it through1123     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1124     * pre-import list — active entries included — is restored.1125     *1126     * Existing tombstones are preserved so paired devices still see the1127     * deletions they already know about.1128     */1129    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1130        requireUnlocked()1131        val now = System.currentTimeMillis()1132        val ready = newEntries.map { draft ->1133            val stamp = VaultCrypto.monotonicEntryTimestamp()1134            withDerivedLast4(1135                draft.copy(1136                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1137                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1138                    modifiedAt = stamp,1139                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1140                ),1141            )1142        }1143        // M-03: an incoming entry that shares an id with an existing live entry1144        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1145        // the old one and then appending the new one would leave the list with1146        // two entries of the same id — the tombstoned one would be returned by1147        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1148        // superseded entries from the tombstone set instead.1149        val incomingIds = ready.map { it.id }.toHashSet()1150        val rollback = mutateState {1151            val r = entryList.toMutableList()1152            val replaced = entryList.map { entry ->1153                if (entry.deletedAt != null) return@map entry1154                if (entry.id in incomingIds) return@map null1155                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1156                entry.copy(deletedAt = stamp, modifiedAt = stamp)1157            }.filterNotNull()1158            entryList = (replaced + ready).toMutableList()1159            r1160        }1161        persistOrRollback(rollback)1162        return ready.size1163    }11641165    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1166        requireUnlocked()1167        // H-03: read entryList, compute the patched entry, and write it under a1168        // single stateLock so a concurrent lock() cannot interleave between the1169        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1170        // list, or clobber the entry onto the wrong id after a relock).1171        var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1172        mutateState {1173            val idx = entryList.indexOfFirst { it.id == patch.id }1174            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1175            val current = entryList[idx]1176            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1177            // Only refresh passwordChangedAt when the password value actually changes.1178            val passwordChanged = patch.password != null && patch.password != current.password1179            // Capture the outgoing password (newest first) so the user can reveal/copy/1180            // restore it later. History is computed authoritatively from `current`, not1181            // the patch — updateEntry is a full-entry replace, so trusting patch's value1182            // would let a stale form clobber the real history. Record the age the old1183            // password carried, skip an empty prior, and skip an immediate exact dupe1184            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1185            val priorPassword = current.password1186            val shouldCapture = passwordChanged &&1187                !priorPassword.isNullOrEmpty() &&1188                current.passwordHistory?.firstOrNull()?.password != priorPassword1189            val nextHistory = if (shouldCapture) {1190                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1191                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1192            } else {1193                current.passwordHistory1194            }1195            val updated = withDerivedLast4(patch.copy(1196                createdAt = current.createdAt,1197                modifiedAt = stamp,1198                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1199                passwordHistory = nextHistory,1200            ))1201            val rollback = entryList.toMutableList()1202            entryList[idx] = updated1203            prepared = updated to rollback1204        }1205        val (updated, rollback) = prepared!!1206        persistOrRollback(rollback)1207        updated1208    }12091210    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1211    suspend fun deleteEntry(id: String) = mutex.withLock {1212        requireUnlocked()1213        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1214        // the list out from under the indexOfFirst.1215        var rollback: MutableList<VaultEntry>? = null1216        mutateState {1217            val idx = entryList.indexOfFirst { it.id == id }1218            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1219            val current = entryList[idx]1220            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1221            rollback = entryList.toMutableList()1222            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1223        }1224        persistOrRollback(rollback!!)1225    }12261227    suspend fun restoreEntry(id: String) = mutex.withLock {1228        requireUnlocked()1229        var rollback: MutableList<VaultEntry>? = null1230        mutateState {1231            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1232            if (idx == -1) throw IllegalStateException("deleted entry not found")1233            val current = entryList[idx]1234            rollback = entryList.toMutableList()1235            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1236        }1237        persistOrRollback(rollback!!)1238    }12391240    /** Permanent delete — no tombstone, no recovery. */1241    suspend fun purgeEntry(id: String) = mutex.withLock {1242        requireUnlocked()1243        var rollback: MutableList<VaultEntry>? = null1244        mutateState {1245            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1246            if (idx == -1) throw IllegalStateException("deleted entry not found")1247            rollback = entryList.toMutableList()1248            entryList.removeAt(idx)1249        }1250        persistOrRollback(rollback!!)1251    }12521253    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1254    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1255        requireUnlocked()1256        // H-03: read+remove under stateLock so lock() can't reorder the remove.1257        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1258        val r = mutateState {1259            val rollback = entryList.toMutableList()1260            val before = entryList.size1261            entryList.removeAll { it.deletedAt != null }1262            R(rollback, before - entryList.size)1263        }1264        if (r.removed > 0) persistOrRollback(r.rollback)1265        r.removed1266    }12671268    /** Browser parity: number of active entries that dedupe would move to trash. */1269    suspend fun countDuplicateEntries(): Int = mutex.withLock {1270        requireUnlocked()1271        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1272        // lock() can't race the read with state writes.1273        mutateState { countDuplicateEntriesLocked() }1274    }12751276    /**1277     * Browser parity: soft-delete duplicate entries, keeping the most recently1278     * modified member of each group. Refuse in decoy mode so the duress vault is1279     * not reshaped into a forensically distinct state.1280     */1281    suspend fun deduplicateEntries(): Int = mutex.withLock {1282        requireUnlocked()1283        // H-03: read the decoyMode flag and the entry list, then reassign1284        // entryList, all under one stateLock.1285        val decoy = mutateState { decoyMode }1286        if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1287        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1288        val ready = mutateState {1289            val active = entryList.filter { it.deletedAt == null }1290            val keepIds = mutableSetOf<String>()1291            active.groupBy { it.duplicateKey() }.values.forEach { group ->1292                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1293            }1294            val rollback = entryList.toMutableList()1295            var removed = 01296            entryList = entryList.map { entry ->1297                if (entry.deletedAt == null && entry.id !in keepIds) {1298                    removed++1299                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1300                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1301                } else {1302                    entry1303                }1304            }.toMutableList()1305            R(rollback, removed)1306        }1307        if (ready.removed > 0) persistOrRollback(ready.rollback)1308        ready.removed1309    }13101311    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1312    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1313    fun getDeletedEntries(): List<VaultEntry> =1314        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }13151316    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1317    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1318        val active = entryList.filter { it.deletedAt == null }1319        if (tagFilter == null) return active1320        val needle = tagFilter.trim().lowercase()1321        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1322    }13231324    /** Sorted, de-duplicated tags across all active entries. */1325    fun getDistinctTags(): List<String> =1326        entryList.asSequence()1327            .filter { it.deletedAt == null }1328            .flatMap { it.tags?.asSequence() ?: emptySequence() }1329            .map { it.trim().lowercase() }1330            .filter { it.isNotEmpty() }1331            .distinct()1332            .sorted()1333            .toList()13341335    /**1336     * Distinct non-blank values for an identity field across active entries,1337     * most-frequently-used first. Supports "username" and "email"; unknown field1338     * names return an empty list. Values keep original casing (deduped1339     * case-insensitively) so a suggestion matches what the user typed.1340     */1341    fun getDistinctFieldValues(field: String): List<String> {1342        val select: (VaultEntry) -> String? = when (field) {1343            "username" -> { e -> e.username }1344            "email" -> { e -> e.email }1345            else -> return emptyList()1346        }1347        return entryList.asSequence()1348            .filter { it.deletedAt == null }1349            .mapNotNull { select(it)?.trim() }1350            .filter { it.isNotEmpty() }1351            .groupingBy { it.lowercase() }1352            .aggregate { _, acc: Pair<String, Int>?, value, _ ->1353                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1354            }1355            .values1356            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1357            .map { it.first }1358    }13591360    fun getTombstones(): List<Tombstone> =1361        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1362    fun getVaultVersion(): Long = store.version()1363    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots13641365    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1366        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1367        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1368        // H-04: snapshots now carry the duress slot too — restore preserves1369        // the at-rest triad (A/B/D), not just the A/B pair.1370        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1371        val priorA = store.loadSlotA()1372        val priorB = store.loadSlotB()1373        val priorD = store.loadDecoySlot()1374        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1375        // slot write — the old code took this snapshot, then stranded it on1376        // every failure, polluting history with a "restore" entry that never1377        // restored anything. The history below is rolled back together with1378        // the slots if the commit fails.1379        val priorHistory = store.loadVaultHistory()1380        // P1-4 (codex): the recovery snapshot must land before we commit the1381        // slot pair, otherwise a failed restore cannot be rolled back to the1382        // pre-restore history. If the history write itself fails, refuse to1383        // proceed — the alternative is a successful restore with no undo path.1384        if (!takeVaultSnapshot("restore")) {1385            return false1386        }1387        // H-04: legacy snapshots without vault_d, or vault_d that fails to1388        // decode, fall back to a fake duress of slot A's size — presence-1389        // indistinguishable from any other freshly-restored vault.1390        val nextD: StoredVault = restoredD ?: run {1391            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161392            val fakeBlob = ByteArray(target).also(rng::nextBytes)1393            val fakeSalt = VaultCrypto.generateSalt()1394            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1395        }1396        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD)) {1397            store.writeVaultHistory(priorHistory)1398            return false1399        }1400        lock()1401        true1402    }14031404    /**1405     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1406     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1407     * (a restore preserves the duress-shaped blob from history), not a fresh1408     * noise record. Any failure rolls back so the at-rest pair never holds a1409     * restored slot A against a pre-restore slot B. Caller is responsible for1410     * restoring the pre-snapshot history on a false return.1411     *1412     * H-04: duress slot is now part of the atomic triad — restored last so1413     * a duress-write failure rolls the A/B pair back too.1414     */1415    private fun commitRestoreAtomic(1416        nextA: StoredVault,1417        nextB: String,1418        nextD: StoredVault,1419        priorA: StoredVault?,1420        priorB: String?,1421        priorD: StoredVault?,1422    ): Boolean {1423        if (!store.writeSlotA(nextA)) return false1424        // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1425        // must trigger rollback; previously a throw escaped without restoring1426        // the prior slot A and history.1427        try {1428            if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1429            // H-04: duress slot is part of the restore triad. Failures here1430            // roll A+B back so the at-rest state stays consistent.1431            if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1432            store.stampLocalMutation()1433        } catch (_: Exception) {1434            rollbackRestore(priorA, priorB, priorD)1435            return false1436        }1437        return true1438    }14391440    // ---- Sync apply (does NOT advance the version) ------------------------14411442    /**1443     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1444     * WITHOUT bumping the version counter, so a sync round cannot cascade into1445     * another outbound sync. Returns the number of locally-applied changes.1446     */1447    suspend fun applyMergedRemoteEntries(1448        remoteEntries: List<VaultEntry>,1449        remoteTombstones: List<Tombstone>,1450    ): Int = mutex.withLock {1451        requireUnlocked()1452        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1453        // BEFORE any comparison against a local one. Without this a peer can1454        // send a far-future modifiedAt and permanently pin its version of an1455        // entry against every honest local edit. The browser clamps in the1456        // matching place (vault.ts applyMergedRemoteEntries).1457        val mergeNow = System.currentTimeMillis()1458        val clamped = remoteEntries.map { entry ->1459            entry.copy(1460                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1461                deletedAt = entry.deletedAt?.let {1462                    SyncLimits.clampSyncTimestamp(it, mergeNow)1463                },1464            )1465        }1466        val tombMap = remoteTombstones.associate {1467            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1468        }1469        // H-03: build the new entryList under stateLock so a concurrent lock()1470        // cannot zero derivedKey/entryList between the byId read and the merge.1471        val applied = mutateState {1472            var n = 01473            val byId = entryList.associateBy { it.id }.toMutableMap()14741475            for (remote in clamped) {1476                val tomb = tombMap[remote.id]1477                if (tomb != null && tomb >= remote.modifiedAt) continue1478                val local = byId[remote.id]1479                if (local == null) {1480                    entryList.add(remote); byId[remote.id] = remote; n++1481                } else if (remote.modifiedAt > local.modifiedAt) {1482                    val i = entryList.indexOfFirst { it.id == local.id }1483                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1484                }1485            }14861487            for ((id, deletedAt) in tombMap) {1488                val local = byId[id] ?: continue1489                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1490                if (deletedAt < local.modifiedAt) continue1491                val i = entryList.indexOfFirst { it.id == id }1492                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1493            }1494            n1495        }14961497        if (applied > 0) {1498            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1499            mutateState { publish() }1500        }1501        applied1502    }15031504    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1505        // _settings is a StateFlow (atomic write), but publish under stateLock so1506        // any concurrent lock() observes either the old or the new value — not1507        // ordering with the in-memory entries flip.1508        mutateState { _settings.value = settings }1509        store.writeSettings(settings)1510    }15111512    // ---- Recovery words (vault metadata) ----------------------------------15131514    /** True once the user has confirmed their recovery words for this vault. */1515    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet15161517    /**1518     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1519     * Persisted without bumping the version — recovery metadata is local-only and1520     * is not an entry mutation paired devices should sync against.1521     */1522    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1523        requireUnlocked()1524        // H-03: hash + escrow-related state fields set under stateLock so a1525        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.1526        // M-04: the flags have to be live before the escrows are built — both seal1527        // currentPayload() — so capture the prior pair and put it back if the write1528        // never lands. Otherwise the session reports words set against a slot A1529        // that still holds no escrow, and the recovery screen never offers a retry.1530        val prior = mutateState {1531            val priorPair = recoveryWordHash to recoveryWordsSet1532            recoveryWordHash = recoveryWordsHash(words)1533            recoveryWordsSet = true1534            priorPair1535        }1536        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow1537        // (cross-platform recovery of the sync root key) and Android's whole-payload1538        // escrow (offline forgot-password restore of entries). Both derive their key1539        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.1540        val persisted = runCatching {1541            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1542        }.getOrDefault(false)1543        if (!persisted) {1544            mutateState {1545                recoveryWordHash = prior.first1546                recoveryWordsSet = prior.second1547            }1548        }1549        persisted1550    }15511552    /** Verify entered words against the stored recovery hash. */1553    fun verifyRecoveryWords(words: List<String>): Boolean {1554        val stored = recoveryWordHash ?: return false1555        return stored == recoveryWordsHash(words)1556    }15571558    // ---- Recovery escrow (forgot-password) --------------------------------15591560    /** True at rest if this vault has any recovery escrow the recovery flow can use. */1561    fun hasRecoveryEscrow(): Boolean =1562        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false15631564    /**1565     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of1566     * the master password. On success the slot-A plaintext sealed in the escrow is1567     * adopted as the live session (entries, rootKey, settings), but no Argon2id1568     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a1569     * new password and finish unlocking. Returns false when there is no escrow, the1570     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).1571     *1572     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no1573     * detached vault key to return here — slot A is wrapped under the rotating1574     * Argon2id key — so this opens the escrowed payload directly.1575     */1576    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1577        val slotA = store.loadSlotA() ?: return false1578        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false15791580        // Prefer Android's whole-payload escrow: a full offline restore of entries,1581        // root key, and settings. Present for Android-created vaults.1582        slotA.recoveryEscrow?.let { escrow ->1583            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1584            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1585            val keyBytes = recoveryKey.encoded1586            try {1587                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1588                val payload = VaultCodec.decode(plaintext)1589                // H-03: adoption is one atomic state-write — lock() cannot interleave.1590                mutateState {1591                    decoyMode = false1592                    entryList = payload.entries.toMutableList()1593                    duressConfigured = payload.duressConfigured1594                    recoveryWordHash = payload.recoveryWordHash1595                    recoveryWordsSet = payload.recoveryWordsSet1596                    rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1597                              else generateRecoverableRootKey()1598                    _settings.value = store.loadSettings()1599                }1600                VaultCrypto.zero(plaintext)1601                return true1602            } catch (_: Exception) {1603                // Wrong words against this escrow — fall through to the root-key escrow.1604            } finally {1605                VaultCrypto.zero(material)1606                VaultCrypto.zero(keyBytes)1607            }1608        }16091610        // Browser-format vault (only the root-key escrow): recover the 32-byte root1611        // key (sync identity) and reset the password; entries return via sync. This1612        // is exactly the browser's recovery semantics (`messages.ts:312-347`).1613        slotA.rootKeyEscrow?.let { escrow ->1614            val rk = unsealRootKeyEscrow(words, escrow) ?: return false1615            mutateState {1616                decoyMode = false1617                entryList = mutableListOf()1618                duressConfigured = false1619                recoveryWordHash = recoveryWordsHash(words)1620                recoveryWordsSet = true1621                rootKey = rk1622                _settings.value = store.loadSettings()1623            }1624            return true1625        }1626        return false1627    }16281629    /**1630     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1631     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1632     * words, mark the session fully unlocked, and publish. Returns false if called1633     * without a prior successful recovery unlock (no in-memory root key).1634     *1635     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1636     * into one step, since there is no detached vault key here.1637     */1638    suspend fun resetPasswordWithRecoveryWords(1639        words: List<String>,1640        newPassword: String,1641    ): Boolean = mutex.withLock {1642        // H-03: read rootKey + entryList under stateLock before computing the new1643        // plaintext, then re-publish + swap the derived key under stateLock so a1644        // concurrent lock() cannot straddle the two fences.1645        if (!mutateState { rootKey != null }) return false1646        val priorA = store.loadSlotA() ?: return false1647        var newKey: ByteArray? = null1648        var plaintext: ByteArray? = null1649        return try {1650            val newSalt = VaultCrypto.generateSalt()1651            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1652            plaintext = VaultCodec.encode(currentPayload())1653            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1654            // Rebuild both escrows against the recovered session so their salts rotate1655            // with the password reset; fall back to the prior escrows if rebuild fails1656            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1657            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1658            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1659            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1660            val next = StoredVault(1661                version = priorA.version,1662                salt = b64(newSalt),1663                blob = blob,1664                recoveryEscrow = freshRecovery,1665                rootKeyEscrow = freshRootKey,1666            )1667            // H-01: see changePassword — both rotations share the same1668            // commit-or-keep-old-key contract so a partial write can't strand1669            // the session on a key that no longer matches disk.1670            val priorB = store.loadSlotB()1671            if (!commitVaultRotation(priorA, priorB, next)) return false1672            // H-03 (P1-2 codex): zero the old live key and install the new one1673            // inside the same stateLock critical section so a concurrent lock()1674            // can't observe the in-between.1675            mutateState {1676                derivedKey?.let(VaultCrypto::zero)1677                derivedKey = newKey; newKey = null1678                publish()1679                _isUnlocked.value = true1680            }1681            true1682        } catch (_: Exception) {1683            false1684        } finally {1685            newKey?.let(VaultCrypto::zero)1686            plaintext?.let(VaultCrypto::zero)1687        }1688    }16891690    /**1691     * Seal the current slot-A plaintext under a key derived from the 12 BIP391692     * recovery [words] (Android offline-restore escrow). IKM is the browser's1693     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1694     * Returns null for a checksum-invalid phrase.1695     */1696    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1697        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1698        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1699        val salt = VaultCrypto.generateSalt()1700        val recoveryKey = deriveRecoveryKey(material, salt)1701        val keyBytes = recoveryKey.encoded1702        val plaintext = VaultCodec.encode(currentPayload())1703        return try {1704            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1705            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1706        } finally {1707            VaultCrypto.zero(material)1708            VaultCrypto.zero(keyBytes)1709            VaultCrypto.zero(plaintext)1710        }1711    }17121713    /**1714     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1715     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1716     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1717     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1718     * the same root key on the browser. Returns null with no in-memory root key or1719     * a checksum-invalid phrase.1720     */1721    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1722        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1723        val rk = rootKey ?: return null1724        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1725        val salt = VaultCrypto.generateSalt()1726        val recoveryKey = deriveRecoveryKey(material, salt)1727        val keyBytes = recoveryKey.encoded1728        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1729        return try {1730            // combined = nonce(12) || ciphertext || tag(16)1731            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1732            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1733            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1734            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1735            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1736            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1737        } finally {1738            VaultCrypto.zero(material)1739            VaultCrypto.zero(keyBytes)1740            VaultCrypto.zero(plaintext)1741        }1742    }17431744    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1745    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1746        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1747        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1748        val keyBytes = recoveryKey.encoded1749        var pt: ByteArray? = null1750        return try {1751            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1752            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1753            hexToRootKey(String(pt, Charsets.US_ASCII))1754        } catch (_: Exception) {1755            null1756        } finally {1757            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the1758            // IKM-equivalent of the vault identity. material+keyBytes were1759            // already zeroed; pt was leaking.1760            VaultCrypto.zero(material)1761            VaultCrypto.zero(keyBytes)1762            pt?.let(VaultCrypto::zero)1763        }1764    }17651766    /**1767     * Re-seal slot A from the current in-memory state under the live key, carrying1768     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1769     * in decoy mode (recovery is never offered for the decoy) and persists without1770     * a version bump, like the recovery-words metadata write it replaces.1771     */1772    private fun persistSlotAWithEscrows(1773        recoveryEscrow: RecoveryEscrow?,1774        rootKeyEscrow: RootKeyEscrow?,1775    ): Boolean {1776        val key = derivedKey ?: throw IllegalStateException("vault_locked")1777        if (decoyMode) return false1778        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1779        val plaintext = VaultCodec.encode(currentPayload())1780        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1781        val next = prior.copy(1782            blob = blob,1783            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1784            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1785        )1786        if (!store.writeSlotA(next)) return false1787        try {1788            writeMatchingNoiseSlotB(next)1789            // H-04: escrow rebuild changes slot A's plaintext size, so re-pad1790            // the duress slot too. A real decoy is left alone; a fake is1791            // re-sized to slot A's new ciphertext length.1792            if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")1793        } catch (e: Exception) {1794            store.writeSlotA(prior)1795            throw e1796        }1797        return true1798    }17991800    // ---- Internals --------------------------------------------------------18011802    private fun requireUnlocked() {1803        // H-03: read derivedKey atomically with [lock]'s reset; without it,1804        // [lock] from a background lifecycle event can run between this read1805        // and the caller's first state touch.1806        stateLock.lock()1807        try {1808            if (derivedKey == null) throw IllegalStateException("vault_locked")1809        } finally {1810            stateLock.unlock()1811        }1812    }18131814    private fun countDuplicateEntriesLocked(): Int {1815        val seen = mutableMapOf<String, VaultEntry>()1816        var duplicates = 01817        for (entry in entryList.filter { it.deletedAt == null }) {1818            val key = entry.duplicateKey()1819            val existing = seen[key]1820            if (existing == null) {1821                seen[key] = entry1822            } else {1823                duplicates++1824                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1825                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1826                if (entryStamp > existingStamp) seen[key] = entry1827            }1828        }1829        return duplicates1830    }18311832    private fun VaultEntry.duplicateKey(): String {1833        if (type == "note") return "note|${title.lowercase()}"1834        val passkeyData = passkey1835        if (passkeyData != null) {1836            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1837        }1838        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1839    }18401841    /**1842     * Capture the current slot pair (and metadata) into the history list.1843     * Returns true when the history write landed, false when it didn't — callers1844     * that depend on the snapshot existing (notably [restoreVaultSnapshot])1845     * must check the result and roll back if it does not.1846     */1847    private fun takeVaultSnapshot(reason: String): Boolean {1848        val slotA = store.loadSlotA() ?: return false1849        val vaultA = VaultCodec.encodeSlot(slotA)1850        val vaultB = store.loadSlotB() ?: slotA.blob1851        // H-04: capture the duress slot so a restore preserves the at-rest1852        // triad, not just the A/B pair. The duress slot is always present1853        // (real or fake), so loadDecoySlot() returning null is a logic error1854        // — fall through to a fake duress in that case rather than silently1855        // losing the record from the snapshot.1856        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {1857            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161858            val fakeBlob = ByteArray(target).also(rng::nextBytes)1859            val fakeSalt = VaultCrypto.generateSalt()1860            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1861        }1862        val history = store.loadVaultHistory()1863        val latest = history.snapshots.firstOrNull()1864        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true1865        val entryCount = if (derivedKey != null && !decoyMode) {1866            bucketEntryCount(entryList.count { it.deletedAt == null })1867        } else {1868            null1869        }1870        return try {1871            val snapshot = VaultSnapshot(1872                id = UUID.randomUUID().toString(),1873                savedAt = System.currentTimeMillis(),1874                reason = reason,1875                vault_a = vaultA,1876                vault_b = vaultB,1877                vault_d = vaultD,1878                entryCount = entryCount,1879            )1880            // P1-4 (codex): return the history-write boolean to callers so a1881            // false return no longer leaves the snapshot out of history while1882            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.1883            store.writeVaultHistory(1884                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1885            )1886        } catch (_: Exception) {1887            false1888        }1889    }18901891    private fun bucketEntryCount(n: Int): Int = when {1892        n <= 0 -> 01893        n <= 10 -> 101894        n <= 25 -> 251895        n <= 50 -> 501896        n <= 100 -> 1001897        n <= 250 -> 2501898        n <= 500 -> 5001899        else -> ((n + 499) / 500) * 5001900    }19011902    /**1903     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1904     * so it is stored explicitly and survives the sync wire format rather than being1905     * re-derived at render time. Non-card entries pass through unchanged.1906     *1907     * M-03: never overwrite a stored last4 with null. A card kept only as last41908     * (e.g. after a PeachScript import that had no PAN) must keep that value;1909     * re-deriving from an absent cardNumber would silently zero the field.1910     */1911    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1912        if (!entry.isCard()) return entry1913        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1914        val derived = if (digits.length >= 4) digits.takeLast(4) else null1915        return entry.copy(last4 = derived ?: entry.last4)1916    }19171918    /**1919     * On unlock, hard-delete trashed entries older than the retention window.1920     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1921     * (the app's "Never" option), so nothing is purged. Persists without a1922     * version bump and without touching the publish state (caller publishes).1923     */1924    private fun autoPurgeExpiredTrash() {1925        val days = _settings.value.trashRetentionDays ?: return1926        if (days <= 0) return1927        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1928        val before = entryList.size1929        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1930        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1931    }19321933    /** The slot-A plaintext envelope for the current in-memory session. */1934    private fun currentPayload(): VaultPayload =1935        VaultPayload(1936            entries = entryList.toList(),1937            duressConfigured = duressConfigured,1938            rootKeyB64 = rootKey?.let(::b64) ?: "",1939            recoveryWordHash = recoveryWordHash,1940            recoveryWordsSet = recoveryWordsSet,1941        )19421943    /**1944     * Adopt the payload's root key, lazily minting one for legacy payloads.1945     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1946     * a root key is not a mutation paired devices should sync against.1947     */1948    private fun ensureRootKey(payload: VaultPayload) {1949        if (payload.rootKeyB64.isNotBlank()) {1950            rootKey = unb64(payload.rootKeyB64)1951            return1952        }1953        rootKey = generateRecoverableRootKey()1954        runCatching { persistSlotA(bumpVersion = false) }1955    }19561957    private fun publish() {1958        _entries.value = entryList.filter { it.deletedAt == null }1959    }19601961    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1962        var plaintext = VaultCodec.encode(currentPayload())1963        return try {1964            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1965            StoredVault(version = 1, salt = b64(salt), blob = blob)1966        } finally {1967            // M-05: full vault plaintext is allocated here once per createVault.1968            // Zeroing in finally keeps the transient copy off the heap after the1969            // caller either succeeds or rolls back via lock().1970            VaultCrypto.zero(plaintext)1971        }1972    }19731974    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1975        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1976        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1977        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1978        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1979        val noise = ByteArray(approxBytes).also(rng::nextBytes)1980        return store.writeSlotB(b64(noise))1981    }19821983    /** H-04: the ciphertext length slot A is using right now, so the duress1984     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext1985     *  length equals ciphertext length, so this also matches slot A's plaintext. */1986    private fun matchingCiphertextLength(slotA: StoredVault): Int =1987        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)19881989    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]1990     *  bytes. Replaces a real decoy when the user clears duress, and is the1991     *  initial duress slot when no decoy is configured — so the record's1992     *  PRESENCE on disk is independent of duressConfigured, and its SIZE1993     *  matches slot A's blob length. */1994    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {1995        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161996        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)1997        val fakeSalt = VaultCrypto.generateSalt()1998        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1999    }20002001    /** H-04: keep the duress record presence- and size-indistinguishable from2002     *  slot A on every persist. A real decoy is left alone (it was padded at2003     *  setDuressPassphrase time, and we don't have the duress key here to2004     *  re-pad on entry growth — that's an accepted residual tell, see the2005     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2006     *  A's size. */2007    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2008        val isConfigured = mutateState { duressConfigured }2009        return if (isConfigured) true else writeFakeDuressSlot(slotA)2010    }20112012    /** Re-seal slot A from the current in-memory state under the live key. */2013    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {2014        val key = derivedKey ?: throw IllegalStateException("vault_locked")2015        var plaintext: ByteArray? = null2016        return try {2017            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2018            if (decoyMode) {2019                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2020                plaintext = VaultCodec.encode(currentPayload())2021                val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2022                plaintext = null2023                return store.writeDecoySlot(prior.copy(blob = blob))2024            }2025            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2026            // H-01: captured BEFORE the noise write so a stamp failure can restore2027            // the prior slot B alongside the prior slot A. Slot B is always2028            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2029            // leaves (new A, new B, old version) on disk — partial commit.2030            val priorB = store.loadSlotB()2031            if (snapshotReason != null) takeVaultSnapshot(snapshotReason)2032            plaintext = VaultCodec.encode(currentPayload())2033            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2034            plaintext = null2035            val next = prior.copy(blob = blob)2036            if (!store.writeSlotA(next)) return false2037            // Slot A committed — keep slot B size-matched, then advance the version2038            // for local writes only. Restore both A and B if a later step fails.2039            // P1-1 (codex): the noise write's boolean AND any thrown exception from2040            // it must trigger rollback; previously the call sat outside the try.2041            try {2042                if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2043                // H-04: duress slot stays present (real one untouched, fake one2044                // re-padded to slot A's current size on every persist).2045                if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2046                if (bumpVersion) store.stampLocalMutation()2047            } catch (e: Exception) {2048                store.writeSlotA(prior)2049                if (priorB != null) runCatching { store.writeSlotB(priorB) }2050                throw e2051            }2052            true2053        } finally {2054            // M-05: the full vault plaintext crosses this helper on every save.2055            // Zeroing in finally covers both the success and the rollback/throw2056            // paths so a partial commit can't leave a copy in GC.2057            plaintext?.let(VaultCrypto::zero)2058        }2059    }20602061    /**2062     * H-01: durably commit a slot-A rotation by writing slot A, its2063     * size-matched noise slot B, and the local-mutation stamp in order. Any2064     * failure rolls the previously-committed steps back so the at-rest pair2065     * matches what was there before the call. Returns true only when every2066     * step succeeded — callers must NOT swap their session key on false.2067     */2068    private fun commitVaultRotation(2069        priorA: StoredVault,2070        priorB: String?,2071        nextA: StoredVault,2072    ): Boolean {2073        if (!store.writeSlotA(nextA)) return false2074        // P1-1 (codex): the noise write's boolean AND any thrown exception from2075        // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2076        // escaped the helper and left slot A committed.2077        try {2078            if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2079            // H-04: a password rotation changes slot A's plaintext size (new2080            // salt → re-encrypted blob), so the duress slot must be re-padded2081            // alongside. writeCompanionDuressSlot leaves a real decoy alone2082            // and re-emits a fake of slot A's size otherwise.2083            if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2084            store.stampLocalMutation()2085        } catch (_: Exception) {2086            rollbackRotation(priorA, priorB)2087            return false2088        }2089        return true2090    }20912092    /**2093     * Best-effort undo of the writes [commitVaultRotation] may have committed.2094     * Each step runs independently — a failed rollback must not mask the2095     * original commit failure. Log is a throwing stub off-device.2096     */2097    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2098        val failures = mutableListOf<String>()2099        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2100            .onFailure { failures += "writeSlotA: $it" }2101        if (priorB != null) {2102            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2103                .onFailure { failures += "writeSlotB: $it" }2104        }2105        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2106    }21072108    /** Persist a local mutation, rolling the in-memory list back on failure. */2109    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {2110        try {2111            if (!persistSlotA(bumpVersion = true)) {2112                mutateState { entryList = rollback }2113                throw IllegalStateException("storage_write_failed")2114            }2115        } catch (e: Exception) {2116            mutateState { entryList = rollback }2117            throw e2118        }2119        mutateState { publish() }2120    }2121}