VaultRepository.kt

149.6 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.MessageDigest9import java.security.SecureRandom10import java.text.Normalizer11import kotlinx.serialization.json.Json12import android.os.SystemClock13import android.util.Log14import androidx.annotation.VisibleForTesting15import com.peachpasswords.BuildConfig16import com.peachpasswords.sync.SyncLimits17import com.peachpasswords.sync.LockedSyncSpoolHooks18import kotlinx.serialization.json.JsonElement19import kotlinx.serialization.json.JsonNull20import kotlinx.serialization.json.JsonObject21import kotlinx.serialization.json.JsonPrimitive22import java.util.Base6423import java.util.UUID24import java.util.concurrent.locks.ReentrantLock25import javax.inject.Inject26import javax.inject.Singleton2728/** Lenient parser for the Codex vault-blob wrapper only. */29private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }3031private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)32private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)3334/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */35private const val PASSWORD_HISTORY_MAX = 5036private const val VAULT_HISTORY_MAX = 53738// How long the transient-background-lock suppression stays valid. If the39// launched system Activity never reports the expected lifecycle stop (picker40// crashed, user backed out into another app, OEM killed the picker process),41// the flag expires and the next genuine background event locks the vault42// rather than consuming a stale suppression.43private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4445sealed interface UnlockResult {46    data object Success : UnlockResult47    data object WrongPassword : UnlockResult48    data class Error(val reason: String) : UnlockResult49}5051sealed interface CreateVaultResult {52    data object Success : CreateVaultResult53    data class Error(val reason: String) : CreateVaultResult54}5556sealed interface AdoptVaultResult {57    data class Success(val vaultId: String) : AdoptVaultResult58    data class Error(val reason: String) : AdoptVaultResult59}6061/**62 * Slot-A key rotation signal.63 *64 * Every site that re-seals slot A under a different Argon2id key must fire this:65 * the PIN and biometric escrows each wrap a copy of the OLD key, and a stale copy66 * passes the hardware verifier and then fails the slot AEAD — so the user is told67 * the credential was accepted and then that the vault key is wrong. Registered68 * once in [com.peachpasswords.PeachApplication] (PB-26-126).69 */70internal object VaultKeyRotationHooks {71    @Volatile var onRotated: () -> Unit = {}72}7374internal object VaultMutationHooks {75    @Volatile var onCommitted: () -> Unit = {}76}7778/**79 * In-memory vault session backed by [VaultStore]. Faithful port of the browser80 * `vault.ts` semantics that matter for correctness:81 *82 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched83 *    noise blob so a fresh install shows two similarly-shaped records.84 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a85 *    deletion always wins last-write-wins against an in-flight edit.86 *  - Local writes bump the version counter; sync-applied writes do NOT (so a87 *    sync round never cascades into another outbound sync).88 *  - add/update/delete roll back in-memory state if the disk write fails.89 *  - destroyVault never touches the Pro license (separate store).90 */91@Singleton92class VaultRepository @Inject constructor(93    private val store: VaultStorage,94) {95    private val slotRollback = VaultSlotRollback(store)96    private val mutex = Mutex()97    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend98    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync99    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so100    // lock() can serialize against an in-flight suspend op without blocking on101    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and102    // take [stateLock] only around the state-write portions. Acquisition order103    // is always mutex → stateLock.104    private val stateLock = ReentrantLock()105    private val rng = SecureRandom()106107    /**108     * Monotonic session generation.  [lock] advances this before clearing any109     * secret state; work that captured the previous generation is therefore110     * stale even if it is still running its (non-suspending) storage step.111     */112    private var sessionEpoch: Long = 0L113114    private fun nextSessionEpoch(epoch: Long): Long =115        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L116117    /** Test seam for deterministic "lock while waiting to commit" races. */118    @VisibleForTesting119    internal var beforeDurableCommit: () -> Unit = {}120121    /** Test seam after a failed Codex rollback, while the epoch fence is held. */122    @VisibleForTesting123    internal var afterCodexRollback: () -> Unit = {}124125    /**126     * Run [block] with [stateLock] held. Used to fence the small state-write127     * portions of suspend operations against [lock] and [requireUnlocked]128     * racing from another thread.129     */130    private inline fun <T> mutateState(block: () -> T): T {131        stateLock.lock()132        return try { block() } finally { stateLock.unlock() }133    }134135    /** Run a durable commit only while the session generation is still live. */136    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {137        stateLock.lock()138        return try {139            if (sessionEpoch != epoch) null else block()140        } finally {141            stateLock.unlock()142        }143    }144145    /** Test seam for a caller that must stage work before the epoch fence. */146    @VisibleForTesting147    internal var beforeSessionEpochCommit: () -> Unit = {}148149    /**150     * Test seam for the narrower H-03 race: a suspend mutation has authenticated151     * the session and staged its candidate state, but has not yet entered the152     * epoch-fenced in-memory write. Production callers leave this as a no-op.153     */154    @VisibleForTesting155    internal var beforeSessionStateMutation: () -> Unit = {}156157    /**158     * Run an external session-bound commit while the repository's epoch fence is159     * held. The callback is deliberately non-suspending: [lock] cannot advance160     * the epoch until the whole external batch has reached its linearization161     * point.162     */163    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {164        beforeSessionEpochCommit()165        return withEpochCommit(epoch, block)166    }167168    private fun currentEpoch(): Long = mutateState { sessionEpoch }169170    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =171        mutateState { if (sessionEpoch == epoch) block() else null }172173    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */174    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {175        beforeSessionStateMutation()176        return mutateIfCurrentEpoch(epoch, block)177    }178179    private data class PersistSnapshot(180        val epoch: Long,181        val key: ByteArray,182        val payload: VaultPayload,183        val decoy: Boolean,184    )185186    /** Copy all session material while fenced; callers own and must zero [key]. */187    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {188        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null189        val liveKey = derivedKey ?: return@mutateState null190        PersistSnapshot(191            epoch = sessionEpoch,192            key = liveKey.copyOf(),193            payload = currentPayloadLocked(),194            decoy = decoyMode,195        )196    }197198    private var derivedKey: ByteArray? = null199    private var rootKey: ByteArray? = null200    private var entryList: MutableList<VaultEntry> = mutableListOf()201    private var authenticatedDuressConfigured: Boolean = false202    private var recoveryWordHash: String? = null203    private var recoveryWordsSet: Boolean = false204    /** True when an authenticated vault payload is available for local reads. */205    private var sessionDataAvailable: Boolean = false206    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */207    private var decoyMode: Boolean = false208209    private val _isUnlocked = MutableStateFlow(false)210    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()211212    private val _duressConfigured = MutableStateFlow(false)213    /** Authenticated payload state; never loaded from plaintext settings. */214    val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()215216217    /**218     * UI-only gate for sync-retained background mode. The vault stays unlocked219     * in memory (sync can proceed) but the UI shows the lock screen until the220     * user authenticates on resume. Cleared by a full [lock] or an explicit221     * [clearUiGate] from the lock screen.222     */223    private val _uiGateLocked = MutableStateFlow(false)224    val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()225226    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())227    /** Active (non-deleted) entryList. */228    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()229230    // Settings live in plain prefs, not inside the sealed vault — they hold no231    // secrets — so they are readable before unlock. Loading them here keeps the232    // lock screen at the user's chosen appearance instead of the defaults, which233    // otherwise snap to the real values the moment they authenticate.234    private val _settings = MutableStateFlow(store.loadSettings())235    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()236237    /** Clear every in-memory session field while [stateLock] is held. */238    private fun clearSessionStateLocked() {239        derivedKey?.let(VaultCrypto::zero)240        rootKey?.let(VaultCrypto::zero)241        derivedKey = null242        rootKey = null243        entryList = mutableListOf()244        authenticatedDuressConfigured = false245        _duressConfigured.value = false246        recoveryWordHash = null247        recoveryWordsSet = false248        sessionDataAvailable = false249        decoyMode = false250        _settings.value = store.loadSettings()251        _entries.value = emptyList()252        _isUnlocked.value = false253    }254255    fun hasVault(): Boolean = store.hasVault()256257    // ---- Codex (printed paper backup) -------------------------------------258259    /**260     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the261     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's262     * A/B naming where slot B is the duress decoy); the noise slot is local-only263     * and never crosses devices. Restorable on any device that has the master264     * password. Null when no vault exists.265     */266    fun codexVaultBlobBase64(): String? {267        val slotA = store.loadSlotA() ?: return null268        // H-04: duress slot is always present after createVault (real or fake),269        // so the wrapper can rely on it being loadable.270        val slotD = store.loadDecoySlot() ?: return null271        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""272        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))273    }274275    /**276     * Replace the local vault from a Codex blob, then unlock with [password].277     * Writes slot A (and a size-matched noise slot B) before unlocking so the278     * restored vault is at rest exactly as a freshly-created one would be.279     */280    /** A slot is stored either as a JSON string (browser) or inline object (Android). */281    private fun JsonElement?.asSlotJson(): String? = when {282        this == null || this is JsonNull -> null283        this is JsonPrimitive && isString -> content284        else -> toString()285    }286287    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {288        val expectedEpoch = currentEpoch()289        var candidateKey: ByteArray? = null290        var plaintext: ByteArray? = null291        return try {292            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)293            // Parsed as JSON, not matched with a regex. The wrapper is294            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which295            // generates most real Codexes — writes each slot as a JSON296            // *string*, with "b" present. A regex looking for an object297            // literal under "a" at the very end of the wrapper matches298            // neither of those, so every browser-generated Codex failed here299            // with "invalid_codex_blob" after decoding perfectly.300            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject301                ?: return UnlockResult.Error("invalid_codex_blob")302            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")303            // A Codex printed from the browser carries a browser-shaped slot,304            // which is the common case — most Codexes are printed there. It is305            // translated to Android's shape (see BrowserSlot) rather than306            // rejected. A translation failure is a wrong password, not a bad307            // blob: the codes decoded fine, the password just didn't open them.308            val slotA = VaultCodec.decodeSlot(slotJson)309                ?: BrowserSlot.parse(slotJson)?.let {310                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword311                }312                ?: return UnlockResult.Error("invalid_codex_blob")313            // C-01: authenticate the candidate ENTIRELY in memory before touching314            // storage. VaultCodec.decodeSlot is structural — an Android-shaped315            // Codex parses under ANY password — so writing slot A first and316            // unlocking afterwards destroyed the device's existing vault every317            // time the user mistyped the password.318            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))319            plaintext = decryptSlotABlob(slotA, candidateKey)320            // LV-1: a Codex printed from a vault sealed before NFC normalization321            // opens only under the raw password bytes. unlockLocked used to supply322            // this fallback; authenticating up front here has to keep it, or those323            // Codexes become unrestorable.324            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {325                VaultCrypto.zero(candidateKey)326                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))327                plaintext = decryptSlotABlob(slotA, candidateKey)328            }329            if (plaintext == null) return UnlockResult.WrongPassword330            val payload = try {331                VaultCodec.decode(plaintext)332            } catch (_: Exception) {333                return UnlockResult.Error("invalid_codex_blob")334            }335336            val priorA = store.loadSlotA()337            val priorB = store.loadSlotB()338            val priorD = store.loadDecoySlot()339            // Carry the real duress slot across when the Codex has one — the340            // browser writes "b" as its duress-shaped slot, and dropping it341            // here would silently destroy a configured decoy.342            // H-04: write browser B into the duress slot (not the noise slot),343            // translating browser-shaped slots to Android's StoredVault shape.344            val codexDuressJson = wrapper["b"].asSlotJson()345            val codexDuress: StoredVault? = codexDuressJson?.let {346                VaultCodec.decodeSlot(it)?.let { slot ->347                    if (slot.version != LEGACY_ANDROID_SLOT_VERSION && slot.version != CURRENT_ANDROID_SLOT_VERSION) null else slot348                } ?: BrowserSlot.parse(it)?.let { b ->349                    browserDuressPassthrough(b)350                }351            }352353            beforeDurableCommit()354            val committed = runCatching {355                withEpochCommit(expectedEpoch) {356                    try {357                        // Keep every slot write, including the inverse, under the358                        // same epoch fence. Releasing stateLock after a partial359                        // write and reacquiring it for rollback lets lock() advance360                        // the epoch in between and strand the incoming slot A.361                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")362                        // Noise slot is always size-matched to slot A — local-only,363                        // never written from a Codex.364                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")365                        // H-04: duress slot always present; real one from the Codex or366                        // a fake the size of slot A.367                        val duressOk = if (codexDuress != null) {368                            store.writeDecoySlot(codexDuress)369                        } else {370                            writeFakeDuressSlot(slotA)371                        }372                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")373                        store.stampLocalMutation()374                        true375                    } catch (_: Exception) {376                        reportRollbackFailures(slotRollback.restoreTriad(priorA, priorB, priorD))377                        // Keep the fence held through the final rollback378                        // observation. A concurrent lock() may queue here, but379                        // cannot invalidate the restored A/B/D triad.380                        afterCodexRollback()381                        false382                    }383                } ?: false384            }.getOrDefault(false)385            if (!committed) {386                return UnlockResult.Error("storage_write_failed")387            }388389            val ownedKey = candidateKey!!390            candidateKey = null // the session owns the key from here on391            VaultKeyRotationHooks.onRotated()392            adoptSlotA(ownedKey, payload, expectedEpoch)393        } catch (e: Exception) {394            // A raw kotlinx parse error ("Unexpected JSON token at offset N")395            // reaching the user says nothing about WHICH json failed — the396            // wrapper, or the payload decrypted out of it. Codex screens are397            // FLAG_SECURE so this can't be screenshotted either. Debug builds398            // log the structure around the failure; alphanumerics are masked so399            // no ciphertext or vault content lands in a log.400            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))401            UnlockResult.Error(e.message ?: "restore_failed")402        } finally {403            plaintext?.let(VaultCrypto::zero)404            candidateKey?.let(VaultCrypto::zero)405        }406    }407408    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */409    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =410        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()411412    private fun reportRollbackFailures(failures: List<String>) {413        // Log is a throwing stub off-device, and a failed log must never mask414        // the rollback result the caller is about to report.415        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }416    }417418    /** Debug-only: structure of a failed Codex restore, with content masked. */419    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {420        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)421        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()422        val window = offset?.let {423            val from = (it - 40).coerceAtLeast(0)424            val to = (it + 40).coerceAtMost(wrapperJson.length)425            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")426        }427        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()428        val slot = wrapper?.get("a")429        buildString {430            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)431            append(" | blobBase64=").append(vaultBlobBase64.length)432            append(" wrapperChars=").append(wrapperJson.length)433            append(" wrapperParses=").append(wrapper != null)434            append(" keys=").append(wrapper?.keys)435            append(" slotAKind=").append(436                when {437                    slot == null -> "absent"438                    slot is JsonPrimitive && slot.isString -> "string"439                    slot is JsonObject -> "object:" + slot.keys440                    else -> slot::class.simpleName441                },442            )443            if (window != null) append(" masked@").append(offset).append("=").append(window)444        }445    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }446447    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {448        val expectedEpoch = currentEpoch()449        var key: ByteArray? = null450        return try {451            val salt = VaultCrypto.generateSalt()452            key = VaultCrypto.deriveKey(password, salt)453            if (mutateIfCurrentEpoch(expectedEpoch) {454                entryList = mutableListOf()455                authenticatedDuressConfigured = false456                _duressConfigured.value = false457                recoveryWordHash = null458                recoveryWordsSet = false459                decoyMode = false460                sessionDataAvailable = false461                rootKey = generateRecoverableRootKey()462            } == null) return CreateVaultResult.Error("stale_create")463            // M-04: the fields above have to be live before sealSlotA, since it464            // seals currentPayload(). That stages a root key the session would465            // otherwise keep after a failed create — [lock] discards the whole466            // staged session on every non-success exit below.467            val slotA = sealSlotA(key!!, salt)468            // P1-3 (codex): every later step must roll the slot pair back if it469            // fails — leaving a half-built vault is worse than refusing to make it.470            beforeDurableCommit()471            val created = withEpochCommit(expectedEpoch) {472                if (!store.writeSlotA(slotA)) return@withEpochCommit false473                try {474                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")475                    // H-04: duress slot must be present from the start so its476                    // existence on disk is independent of authenticatedDuressConfigured.477                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")478                    store.stampLocalMutation()479                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")480                    true481                } catch (_: Exception) {482                    // Half-built vault on disk is worse than no vault — refuse to483                    // create if any step past slot A fails.484                    store.clearVault()485                    false486                }487            } ?: false488            if (!created) {489                lock()490                return CreateVaultResult.Error("storage_write_failed")491            }492            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")493            if (mutateIfCurrentEpoch(expectedEpoch) {494                derivedKey = sessionKey495                key = null496                publish()497                _isUnlocked.value = true498                sessionDataAvailable = true499                sessionEpoch = nextSessionEpoch(sessionEpoch)500            } == null) return CreateVaultResult.Error("stale_create")501            CreateVaultResult.Success502        } catch (e: Exception) {503            lock()504            CreateVaultResult.Error(e.message ?: "create_failed")505        } finally {506            key?.let(VaultCrypto::zero)507        }508    }509510    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {511        val expectedEpoch = currentEpoch()512        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")513        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")514        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {515            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")516        }517        var adoptedRootKey: ByteArray? = null518        var nextDerivedKey: ByteArray? = null519        var sessionRootKey: ByteArray? = null520        return try {521            adoptedRootKey = hexToRootKey(rootKeyHex)522            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {523                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")524            }525            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)526            val salt = VaultCrypto.generateSalt()527            nextDerivedKey = VaultCrypto.deriveKey(password, salt)528            sessionRootKey = adoptedRootKey.copyOf()529            val payload = VaultPayload(530                entries = emptyList(),531                duressConfigured = false,532                rootKeyB64 = b64(sessionRootKey),533                recoveryWordHash = null,534                recoveryWordsSet = false,535            )536            val plaintext = VaultCodec.encode(payload)537            val blob = try {538                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))539            } finally {540                VaultCrypto.zero(plaintext)541            }542            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)543            // P1-3 (codex): same partial-commit guard as createVault — any failure544            // past slot A wipes the half-built vault and refuses adoption.545            beforeDurableCommit()546            val created = withEpochCommit(expectedEpoch) {547                if (!store.writeSlotA(slotA)) return@withEpochCommit false548                try {549                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")550                    // H-04: duress slot must be present from the start so its551                    // existence on disk is independent of authenticatedDuressConfigured.552                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")553                    store.stampLocalMutation()554                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")555                    true556                } catch (_: Exception) {557                    store.clearVault()558                    false559                }560            } ?: false561            if (!created) return AdoptVaultResult.Error("storage_write_failed")562563            val adopted = mutateIfCurrentEpoch(expectedEpoch) {564                val previousDerivedKey = derivedKey565                val previousRootKey = rootKey566                derivedKey = nextDerivedKey567                rootKey = sessionRootKey568                nextDerivedKey = null569                sessionRootKey = null570                entryList = mutableListOf()571                authenticatedDuressConfigured = false572                _duressConfigured.value = false573                recoveryWordHash = null574                recoveryWordsSet = false575                decoyMode = false576                sessionDataAvailable = true577                publish()578                _isUnlocked.value = true579                sessionEpoch = nextSessionEpoch(sessionEpoch)580                previousDerivedKey to previousRootKey581            }582            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")583            val (previousDerivedKey, previousRootKey) = adopted584            previousDerivedKey?.let(VaultCrypto::zero)585            previousRootKey?.let(VaultCrypto::zero)586            // Write the recovery escrow so the forgot-password flow works on587            // this device. The words are deterministic from the root key and588            // were already confirmed on the source device.589            runCatching {590                val words = rootKeyToWords(rootKey!!)591                val escrow = buildRecoveryEscrow(words)592                val rkEscrow = buildRootKeyEscrow(words)593                if (escrow != null || rkEscrow != null) {594                    val prior = store.loadSlotA()595                    if (prior != null) {596                        store.writeSlotA(prior.copy(597                            recoveryEscrow = escrow ?: prior.recoveryEscrow,598                            rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,599                        ))600                    }601                    mutateState {602                        recoveryWordHash = recoveryWordsHash(words)603                        recoveryWordsSet = true604                    }605                }606            }607            AdoptVaultResult.Success(vaultId)608        } catch (e: Exception) {609            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")610        } finally {611            adoptedRootKey?.let(VaultCrypto::zero)612            nextDerivedKey?.let(VaultCrypto::zero)613            sessionRootKey?.let(VaultCrypto::zero)614        }615    }616617    suspend fun unlock(password: String): UnlockResult = mutex.withLock {618        val result = unlockLocked(password, currentEpoch())619        if (result is UnlockResult.Success) ensureRecoveryEscrow()620        result621    }622623    /** Unlock body without acquiring the mutex — callers must already hold it. */624    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {625        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")626        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))627        val payload = try {628            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)629            try {630                VaultCodec.decode(plaintext)631            } finally {632                VaultCrypto.zero(plaintext)633            }634        } catch (_: Exception) {635            VaultCrypto.zero(key)636            // LV-1: a vault sealed before NFC normalization with a non-NFC637            // password derives a different key now. If the supplied password is638            // not already in NFC form, retry with the raw (un-normalized) bytes.639            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {640                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)641                if (legacy != null) return legacy642            }643            return UnlockResult.WrongPassword644        }645        return adoptSlotA(key, payload, expectedEpoch)646    }647648    /**649     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw650     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed651     * under the NFC-normalized key — so every future unlock takes the normal652     * path. Returns null when the raw key also fails (genuinely wrong password)653     * OR when any step of the heal commits fails (the original slot A is restored654     * so disk and session stay aligned).655     */656    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {657        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))658        var newKey: ByteArray? = null659        return try {660            val payload = try {661                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)662                try {663                    VaultCodec.decode(plaintext)664                } finally {665                    VaultCrypto.zero(plaintext)666                }667            } catch (_: Exception) {668                return null669            }670            // Heal: re-seal slot A under the NFC key (mirrors changePassword's671            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.672            val newSalt = VaultCrypto.generateSalt()673            newKey = VaultCrypto.deriveKey(password, newSalt)674            val plaintext = VaultCodec.encode(payload)675            val blob = try {676                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))677            } finally {678                VaultCrypto.zero(plaintext)679            }680            val next = StoredVault(681                version = slotA.version,682                salt = b64(newSalt),683                blob = blob,684                recoveryEscrow = slotA.recoveryEscrow,685                rootKeyEscrow = slotA.rootKeyEscrow,686            )687            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,688            // or stampLocalMutation must restore the prior slot A AND slot B so disk689            // and the eventual session key never disagree on the wrapping.690            val priorB = store.loadSlotB()691            beforeDurableCommit()692            val healed = withEpochCommit(expectedEpoch) {693                if (!store.writeSlotA(next)) {694                    false695                } else try {696                    if (!writeMatchingNoiseSlotB(next)) {697                        runCatching { store.writeSlotA(slotA) }698                        if (priorB != null) runCatching { store.writeSlotB(priorB) }699                        false700                    } else if (!writeCompanionDuressSlot(next)) {701                        runCatching { store.writeSlotA(slotA) }702                        if (priorB != null) runCatching { store.writeSlotB(priorB) }703                        false704                    } else {705                        store.stampLocalMutation()706                        true707                    }708                } catch (_: Exception) {709                    runCatching { store.writeSlotA(slotA) }710                    if (priorB != null) runCatching { store.writeSlotB(priorB) }711                    false712                }713            } ?: false714            if (healed) {715                VaultCrypto.zero(rawKey!!)716                rawKey = null717                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot718                newKey = null719                result720            } else {721                VaultCrypto.zero(newKey!!)722                newKey = null723                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key724                rawKey = null725                result726            }727        } finally {728            // Cover codec, encryption, storage, and stale-epoch failures before729            // either candidate key can be transferred into the live session.730            rawKey?.let(VaultCrypto::zero)731            newKey?.let(VaultCrypto::zero)732        }733    }734735    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */736    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {737        return try {738            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they739            // can throw after the fields above are live. There is no prior session740            // worth restoring — adoption has already replaced it — so a failed741            // adoption discards everything rather than leaving entries and keys742            // resident behind _isUnlocked=false.743            val adopted = mutateIfCurrentEpoch(expectedEpoch) {744                derivedKey = key745                decoyMode = false746                entryList = payload.entries.toMutableList()747                authenticatedDuressConfigured = payload.duressConfigured748                _duressConfigured.value = payload.duressConfigured749                recoveryWordHash = payload.recoveryWordHash750                recoveryWordsSet = payload.recoveryWordsSet751                sessionDataAvailable = true752                _settings.value = store.loadSettings()753                ensureRootKey(payload)754                autoPurgeExpiredTrash()755                consolidatePasskeyEntries()756                publish()757                _isUnlocked.value = true758                // A successful adoption starts a new generation. A sibling759                // SlotAUnlock captured in the prior epoch will fail closed at760                // the next commit instead of replacing this session.761                sessionEpoch = nextSessionEpoch(sessionEpoch)762            }763            if (adopted == null) {764                VaultCrypto.zero(key)765                UnlockResult.Error("stale_unlock")766            } else {767                UnlockResult.Success768            }769        } catch (e: Exception) {770            // Do not call lock() here: a concurrent newer session may already771            // own this repository. Only invalidate the failed epoch's fields.772            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }773            VaultCrypto.zero(key)774            UnlockResult.Error(e.message ?: "unlock_failed")775        }776    }777778    // ---- Constant-time unlock (MF-5) --------------------------------------779780    /**781     * A slot-A unlock that has been derived but not yet adopted. Holds the live782     * Argon2id key and decoded payload so the caller can derive several candidates783     * concurrently and commit only the winner. The key is owned by this token until784     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.785     */786    class SlotAUnlock internal constructor(787        key: ByteArray,788        internal val payload: VaultPayload,789        internal val epoch: Long,790    ) {791        private var ownedKey: ByteArray? = key792        private var consumed = false793794        /** Transfer the key exactly once to the adopter. */795        internal fun takeKey(): ByteArray? = synchronized(this) {796            if (consumed) return@synchronized null797            consumed = true798            ownedKey.also { ownedKey = null }799        }800801        /** Discard an uncommitted token without touching a transferred key. */802        internal fun discard() = synchronized(this) {803            if (consumed) return@synchronized804            consumed = true805            ownedKey?.let(VaultCrypto::zero)806            ownedKey = null807        }808    }809810    /**811     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.812     * Pure computation (no mutex), so it can run concurrently with other candidate813     * derivations before the caller decides which to commit (MF-5). Returns null on814     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).815     */816    fun tryUnlockSlotA(password: String): SlotAUnlock? {817        val epoch = currentEpoch()818        val slotA = store.loadSlotA() ?: return null819        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))820        try {821            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)822            val payload = try {823                VaultCodec.decode(plaintext)824            } finally {825                VaultCrypto.zero(plaintext)826            }827            return SlotAUnlock(key, payload, epoch)828        } catch (_: Exception) {829            VaultCrypto.zero(key)830        }831832        if (password == Normalizer.normalize(password, Normalizer.Form.NFC)) return null833        return tryLegacyRawCandidate(password, slotA, epoch)834    }835836    /** Legacy raw-password fallback; successful candidates heal slot A to NFC. */837    private fun tryLegacyRawCandidate(password: String, slotA: StoredVault, epoch: Long): SlotAUnlock? {838        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))839        var newKey: ByteArray? = null840        return try {841            val plaintext = try {842                VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)843            } catch (_: Exception) {844                return null845            }846            val payload = try {847                VaultCodec.decode(plaintext)848            } finally {849                VaultCrypto.zero(plaintext)850            }851            val newSalt = VaultCrypto.generateSalt()852            newKey = VaultCrypto.deriveKey(password, newSalt)853            val encoded = VaultCodec.encode(payload)854            val blob = try {855                b64(VaultCrypto.encrypt(encoded, newKey!!, VaultCrypto.AAD_SLOT_A))856            } finally {857                VaultCrypto.zero(encoded)858            }859            val next = slotA.copy(salt = b64(newSalt), blob = blob)860            val priorB = store.loadSlotB()861            beforeDurableCommit()862            val healed = withEpochCommit(epoch) {863                if (!store.writeSlotA(next)) false else try {864                    if (!writeMatchingNoiseSlotB(next) || !writeCompanionDuressSlot(next)) {865                        runCatching { store.writeSlotA(slotA) }866                        if (priorB != null) runCatching { store.writeSlotB(priorB) }867                        false868                    } else {869                        store.stampLocalMutation()870                        true871                    }872                } catch (_: Exception) {873                    runCatching { store.writeSlotA(slotA) }874                    if (priorB != null) runCatching { store.writeSlotB(priorB) }875                    false876                }877            } ?: false878            if (healed) {879                VaultCrypto.zero(rawKey!!)880                rawKey = null881                SlotAUnlock(newKey!!, payload, epoch).also { newKey = null }882            } else {883                VaultCrypto.zero(newKey!!)884                newKey = null885                SlotAUnlock(rawKey!!, payload, epoch).also { rawKey = null }886            }887        } finally {888            rawKey?.let(VaultCrypto::zero)889            newKey?.let(VaultCrypto::zero)890        }891    }892893    /** Adopt a previously-derived [SlotAUnlock] as the live session. */894    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {895        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")896        adoptSlotA(key, unlock.payload, unlock.epoch)897    }898899    /** Zero a derived [SlotAUnlock] that will not be adopted. */900    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()901902    // ---- Duress slot B (MF-6) ---------------------------------------------903904    /**905     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key906     * and decoded decoy payload so it can be derived concurrently with slot A and907     * only committed if the duress passphrase was the one entered (MF-5/MF-6).908     */909    class DuressUnlock internal constructor(910        key: ByteArray,911        internal val payload: VaultPayload,912        internal val epoch: Long,913    ) {914        private var ownedKey: ByteArray? = key915        private var consumed = false916917        internal fun takeKey(): ByteArray? = synchronized(this) {918            if (consumed) return@synchronized null919            consumed = true920            ownedKey.also { ownedKey = null }921        }922923        internal fun discard() = synchronized(this) {924            if (consumed) return@synchronized925            consumed = true926            ownedKey?.let(VaultCrypto::zero)927            ownedKey = null928        }929    }930931    /**932     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the933     * session. Success means the entered password is the duress passphrase that keys934     * slot B — indistinguishable from the real slot A at the lock screen. Pure935     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].936     * Returns null when no duress slot exists or the password is not the duress one.937     */938    fun tryUnlockDuress(password: String): DuressUnlock? {939        val epoch = currentEpoch()940        val slot = store.loadDecoySlot() ?: return null941        if (slot.version != LEGACY_ANDROID_SLOT_VERSION && slot.version != CURRENT_ANDROID_SLOT_VERSION) return null942        var key: ByteArray? = VaultCrypto.deriveKey(password, unb64(slot.salt))943        return try {944            val plaintext = try {945                if (slot.version == CURRENT_ANDROID_SLOT_VERSION) {946                    VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_B)947                } else {948                    runCatching {949                        VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_A)950                    }.getOrElse {951                        VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_B)952                    }953                }954            } catch (_: Exception) {955                VaultCrypto.zero(key!!)956                key = null957                return null958            }959            val payload = try {960                VaultCodec.decode(plaintext)961            } finally {962                VaultCrypto.zero(plaintext)963            }964            if (slot.version == LEGACY_ANDROID_SLOT_VERSION) {965                val migratedKey = migrateLegacyDuressSlot(slot, password, payload, key!!, epoch)966                    ?: run {967                        VaultCrypto.zero(key!!)968                        key = null969                        return null970                    }971                if (migratedKey !== key) {972                    VaultCrypto.zero(key!!)973                    key = migratedKey974                }975            }976            DuressUnlock(key!!, payload, epoch).also { key = null }977        } catch (_: Exception) {978            key?.let(VaultCrypto::zero)979            null980        }981    }982983    /**984     * Re-seal an authenticated legacy decoy under the current Slot-B AAD.985     * A failed write or verification restores the exact legacy record, while986     * already-authenticated unlock remains usable for this session.987     */988    private fun migrateLegacyDuressSlot(989        legacy: StoredVault,990        password: String,991        payload: VaultPayload,992        legacyKey: ByteArray,993        epoch: Long,994    ): ByteArray? {995        var salt: ByteArray? = null996        var migratedKey: ByteArray? = null997        var plaintext: ByteArray? = null998        var restoredLegacy = false999        var migrated = false1000        try {1001            salt = VaultCrypto.generateSalt()1002            migratedKey = VaultCrypto.deriveKey(password, salt)1003            plaintext = VaultCodec.encode(payload)1004            val next = StoredVault(1005                version = CURRENT_ANDROID_SLOT_VERSION,1006                salt = b64(salt),1007                blob = b64(VaultCrypto.encrypt(plaintext, migratedKey, VaultCrypto.AAD_SLOT_B)),1008            )1009            var priorA: StoredVault? = null1010            var priorB: String? = null1011            withSessionEpochCommit(epoch) {1012                priorA = store.loadSlotA()1013                priorB = store.loadSlotB()1014                if (store.loadDecoySlot() != legacy) return@withSessionEpochCommit false1015                if (!store.writeDecoySlot(next)) {1016                    restoredLegacy = store.loadDecoySlot() == legacy1017                    return@withSessionEpochCommit restoredLegacy1018                }1019                val reopened = store.loadDecoySlot()1020                val verified = reopened?.version == CURRENT_ANDROID_SLOT_VERSION && runCatching {1021                    val reopenedPlaintext = VaultCrypto.decrypt(unb64(reopened.blob), migratedKey!!, VaultCrypto.AAD_SLOT_B)1022                    try { VaultCodec.decode(reopenedPlaintext) == payload } finally { VaultCrypto.zero(reopenedPlaintext) }1023                }.getOrDefault(false)1024                if (verified) {1025                    migrated = true1026                    return@withSessionEpochCommit true1027                }1028                val restored = priorA != null && store.restoreVaultTriad(priorA!!, priorB, legacy)1029                restoredLegacy = restored && store.loadSlotA() == priorA && store.loadSlotB() == priorB && store.loadDecoySlot() == legacy1030                restoredLegacy1031            }1032            if (migrated) {1033                VaultCrypto.zero(legacyKey)1034                return migratedKey.also { migratedKey = null }1035            }1036            if (restoredLegacy) return legacyKey1037        } catch (_: Exception) {1038            // A failed migration must not claim a legacy key unless the exact1039            // legacy record was durably restored and observed.1040        }1041        if (!restoredLegacy) runCatching { store.loadDecoySlot() == legacy }1042        migratedKey?.let(VaultCrypto::zero)1043        return if (restoredLegacy) legacyKey else null1044    }10451046    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */1047    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {1048        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")1049        if (currentEpoch() != unlock.epoch) {1050            VaultCrypto.zero(key)1051            return@withLock UnlockResult.Error("stale_unlock")1052        }1053        return try {1054            val adopted = mutateIfCurrentEpoch(unlock.epoch) {1055                derivedKey = key1056                decoyMode = true1057                entryList = unlock.payload.entries.toMutableList()1058                authenticatedDuressConfigured = false1059                _duressConfigured.value = false1060                recoveryWordHash = unlock.payload.recoveryWordHash1061                recoveryWordsSet = unlock.payload.recoveryWordsSet1062                sessionDataAvailable = true1063                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)1064                          else VaultCrypto.generateRootKey()1065                // The decoy session gets its own defaults, never the real vault's1066                // record. Settings are shared, plaintext state loaded at every1067                // session start, so reading it here both told the coercer the real1068                // configuration and let updateSettings below persist changes into1069                // the record the real vault loads next (PB-26-120).1070                _settings.value = VaultSettings()1071                publish()1072                _isUnlocked.value = true1073                sessionEpoch = nextSessionEpoch(sessionEpoch)1074            }1075            if (adopted == null) {1076                VaultCrypto.zero(key)1077                UnlockResult.Error("stale_unlock")1078            } else {1079                UnlockResult.Success1080            }1081        } catch (e: Exception) {1082            // A payload/root/settings failure can occur after the candidate key1083            // has been assigned to derivedKey. Tear down only this epoch so a1084            // newer session is not disturbed, and scrub the token reference on1085            // every error path.1086            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }1087            VaultCrypto.zero(key)1088            UnlockResult.Error(e.message ?: "decoy_unlock_failed")1089        }1090    }10911092    /** Zero a derived [DuressUnlock] that will not be adopted. */1093    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()10941095    /**1096     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an1097     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The1098     * passphrase is never stored — only its ability to decrypt slot B proves it.1099     * Requires the real vault to be unlocked and not itself a decoy session.1100     */1101    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {1102        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().1103        val expectedEpoch = mutateState {1104            if (derivedKey != null && !decoyMode) sessionEpoch else null1105        } ?: return false1106        // Reject a duress passphrase that also opens the real vault: it would make the1107        // lock screen ambiguous (both slots would decrypt for the same input).1108        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }1109        var key: ByteArray? = null1110        var basePlaintext: ByteArray? = null1111        return try {1112            val salt = VaultCrypto.generateSalt()1113            key = VaultCrypto.deriveKey(duressPassphrase, salt)1114            // An empty decoy vault with its own fresh sync root key — nothing real leaks.1115            val decoyRoot = VaultCrypto.generateRootKey()1116            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }1117            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)1118            basePlaintext = VaultCodec.encode(decoyPayload)1119            // H-04: pad the duress plaintext to slot A's ciphertext length so the1120            // duress blob matches slot A's blob on disk. VaultCodec.decode strips1121            // trailing NULs, so the extra padding survives encrypt but never1122            // reaches the JSON parser.1123            // This decoy plaintext length is frozen from here on: re-sealing it1124            // on later growth would need the duress passphrase, which is never1125            // retained. VaultStore compensates by re-padding the two slot1126            // RECORDS to the longer of the two on every write, so the frozen1127            // blob no longer shows up as a length difference at rest.1128            val slotA = store.loadSlotA()1129            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 01130            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!1131            val blob = try {1132                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_B))1133            } finally {1134                VaultCrypto.zero(plaintext)1135                // Padding creates a second array; scrub the original encoded1136                // payload too rather than dropping its last reference untouched.1137                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)1138                basePlaintext = null1139                key?.let(VaultCrypto::zero)1140                key = null1141            }1142            beforeDurableCommit()1143            val ok = withEpochCommit(expectedEpoch) {1144                store.writeDecoySlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(salt), blob = blob))1145            } ?: false1146            if (!ok) return false1147            val marked = mutateIfCurrentEpoch(expectedEpoch) {1148                authenticatedDuressConfigured = true1149                _duressConfigured.value = true1150                true1151            } ?: false1152            if (!marked) return false1153            if (!runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }.getOrDefault(false)) {1154                mutateIfCurrentEpoch(expectedEpoch) {1155                    authenticatedDuressConfigured = false1156                    _duressConfigured.value = false1157                }1158                runCatching { writeFakeDuressSlot(store.loadSlotA()!!) }1159                return false1160            }1161            true1162        } catch (_: Exception) {1163            false1164        } finally {1165            // Cover failures before the encrypt block is entered (for example a1166            // codec/copy exception) as well as any early return after key derivation.1167            key?.let(VaultCrypto::zero)1168            basePlaintext?.let(VaultCrypto::zero)1169        }1170    }117111721173    /**1174     * Export the decoy slot as browser-shaped JSON for adoption snapshots.1175     *1176     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all1177     * lowercase hex — identical to the browser's `duress_slot` wire format — so1178     * any receiver (browser or Android) can install it via the existing1179     * passthrough. Returns null when duress is not configured, no decoy exists,1180     * or any conversion failure occurs. Never decrypts.1181     */1182    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1183        if (!authenticatedDuressConfigured) return null1184        val slot = store.loadDecoySlot() ?: return null1185        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1186    }11871188    /**1189     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1190     *1191     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1192     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1193     * byte arrays are zeroed in finally.1194     */1195    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1196        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1197        // serialized — kotlinx-serialization's encodeDefaults = false would1198        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1199        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1200        // silently dropping the duress slot on Android→browser adoptions.1201        var salt: ByteArray? = null1202        var blob: ByteArray? = null1203        var nonce: ByteArray? = null1204        var ctAndTag: ByteArray? = null1205        var ct: ByteArray? = null1206        var tag: ByteArray? = null1207        try {1208            salt = unb64(slot.salt)1209            blob = unb64(slot.blob)1210            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1211            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1212            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1213            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1214            val obj = JsonObject(mapOf(1215                "version" to JsonPrimitive(1),1216                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1217                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1218                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1219                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1220            ))1221            return obj.toString()1222        } finally {1223            salt?.let(VaultCrypto::zero)1224            blob?.let(VaultCrypto::zero)1225            nonce?.let(VaultCrypto::zero)1226            ctAndTag?.let(VaultCrypto::zero)1227            ct?.let(VaultCrypto::zero)1228            tag?.let(VaultCrypto::zero)1229        }1230    }12311232    /**1233     * Install a browser-shaped duress slot received through adoption.1234     */1235    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1236        val b = BrowserSlot.parse(json) ?: return false1237        if (b.version != 1) return false1238        val expectedEpoch = currentEpoch()1239        var salt: ByteArray? = null1240        var nonce: ByteArray? = null1241        var ct: ByteArray? = null1242        var tag: ByteArray? = null1243        var blob: ByteArray? = null1244        try {1245            salt = unhex(b.salt)1246            nonce = unhex(b.nonce)1247            ct = unhex(b.ciphertext)1248            tag = unhex(b.tag)1249            blob = ByteArray(nonce.size + ct.size + tag.size).also {1250                System.arraycopy(nonce, 0, it, 0, nonce.size)1251                System.arraycopy(ct, 0, it, nonce.size, ct.size)1252                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1253            }1254            val stored = StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = b64(blob))1255            val priorA = store.loadSlotA() ?: return false1256            val priorB = store.loadSlotB()1257            val priorD = store.loadDecoySlot() ?: return false1258            beforeDurableCommit()1259            val committed = withEpochCommit(expectedEpoch) {1260                if (!store.writeDecoySlot(stored)) return@withEpochCommit false1261                authenticatedDuressConfigured = true1262                _duressConfigured.value = true1263                true1264            } ?: false1265            if (!committed) return false1266            if (runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }.getOrDefault(false)) {1267                true1268            } else {1269                mutateIfCurrentEpoch(expectedEpoch) {1270                    authenticatedDuressConfigured = false1271                    _duressConfigured.value = false1272                }1273                withEpochCommit(expectedEpoch) { store.restoreVaultTriad(priorA, priorB, priorD) }1274                false1275            }1276        } catch (_: Exception) {1277            false1278        } finally {1279            salt?.let(VaultCrypto::zero)1280            nonce?.let(VaultCrypto::zero)1281            ct?.let(VaultCrypto::zero)1282            tag?.let(VaultCrypto::zero)1283            blob?.let(VaultCrypto::zero)1284        }1285    }12861287    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1288    suspend fun clearDuress(): Boolean = mutex.withLock {1289        // H-04: presence-indistinguishable — replace the real decoy with a fake1290        // blob of slot A's size rather than removing the duress record. The1291        // record stays on disk at the same length, so an at-rest observer1292        // cannot tell "duress cleared" from "no duress ever configured".1293        val expectedEpoch = currentEpoch()1294        val replaced = withEpochCommit(expectedEpoch) {1295            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1296                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1297        } ?: false1298        if (!replaced) return@withLock false1299        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1300        // authenticatedDuressConfigured write so a concurrent lock() cannot land between them.1301        if (!isSessionEpochCurrent(expectedEpoch)) return false1302        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1303        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) {1304            authenticatedDuressConfigured = false1305            _duressConfigured.value = false1306        } != null) {1307            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1308        }1309        true1310    }13111312    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1313    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1314        var current: Throwable? = error1315        while (current != null) {1316            if (current is javax.crypto.AEADBadTagException) return true1317            // Some Android providers expose a GCM tag failure as the parent1318            // BadPaddingException rather than the AEAD-specific subtype.  A1319            // short ciphertext is an IllegalBlockSizeException and therefore1320            // remains an internal/malformed-payload Error below.1321            if (current is javax.crypto.BadPaddingException) return true1322            current = current.cause1323        }1324        return false1325    }13261327    /** Unlock with an already-derived AES-256 key (biometric path). */1328    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1329        val expectedEpoch = currentEpoch()1330        val slotA = try {1331            store.loadSlotA()1332        } catch (_: Exception) {1333            return@withLock UnlockResult.Error("unlock_key_storage_failed")1334        } ?: return@withLock UnlockResult.Error("no_vault")1335        // Keep the caller-owned key separate from the staged session key. The1336        // biometric caller always zeroes its input in a finally block; adoption1337        // must therefore own a copy and use the same all-or-nothing path as a1338        // password unlock rather than publishing a partially decoded session.1339        var stagedKey: ByteArray? = key.copyOf()1340        return try {1341            val encrypted = try {1342                unb64(slotA.blob)1343            } catch (_: Exception) {1344                return@withLock UnlockResult.Error("invalid_vault_blob")1345            }1346            // A GCM record must include nonce + authentication tag.  Providers1347            // differ on whether a shorter record is reported as BadPadding or1348            // AEADBadTag; reject it before decrypt so malformed storage cannot be1349            // misreported as a user-entered wrong key.1350            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1351                return@withLock UnlockResult.Error("invalid_vault_blob")1352            }1353            val plaintext = try {1354                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1355            } catch (e: Exception) {1356                if (isGcmAuthenticationFailure(e)) {1357                    return@withLock UnlockResult.WrongPassword1358                }1359                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1360            }1361            val payload = try {1362                VaultCodec.decode(plaintext)1363            } catch (_: Exception) {1364                return@withLock UnlockResult.Error("invalid_vault_payload")1365            } finally {1366                VaultCrypto.zero(plaintext)1367            }1368            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1369            val result = adoptSlotA(candidate, payload, expectedEpoch)1370            if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1371            result1372        } finally {1373            stagedKey?.let(VaultCrypto::zero)1374        }1375    }13761377    fun lock() {1378        // Never snapshot a mutation that has changed entryList but has not yet1379        // crossed its durable epoch fence. Successful writes refresh the spool1380        // before releasing this mutex; an in-flight writer therefore makes this1381        // lock skip the convenience refresh and fail closed to the prior cache.1382        if (mutex.tryLock()) {1383            try {1384                runCatching { LockedSyncSpoolHooks.refresh(this) }1385            } finally {1386                mutex.unlock()1387            }1388        }1389        // H-03: must observe derivedKey/entryList atomically with any in-flight1390        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1391        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1392        // guards just the brief state reset. Advancing the epoch first invalidates1393        // every operation that captured this session before it reaches a later1394        // durable commit or publication.1395        stateLock.lock()1396        try {1397            sessionEpoch = nextSessionEpoch(sessionEpoch)1398            clearSessionStateLocked()1399            _uiGateLocked.value = false1400        } finally {1401            stateLock.unlock()1402        }1403    }14041405    /**1406     * Enter sync-retained background mode: the UI shows the lock screen but the1407     * vault stays fully unlocked in memory so [PeachSyncService] can complete1408     * sync sessions. Called from [PeachApplication.onStop] when background sync1409     * is enabled. A subsequent full [lock] (auto-lock timer, manual lock)1410     * supersedes this and zeroes everything.1411     */1412    fun lockUiGate() {1413        if (_isUnlocked.value) _uiGateLocked.value = true1414    }14151416    /** Dismiss the UI gate after the user authenticates on resume. */1417    fun clearUiGate() {1418        _uiGateLocked.value = false1419    }14201421    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L14221423    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1424    // is a stub off-device).1425    @VisibleForTesting1426    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }14271428    /**1429     * Call immediately before launching a system Activity (a SAF file1430     * picker/save dialog, a share sheet) that briefly takes the whole process1431     * out of the foreground as an expected part of an in-app flow — not a1432     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1433     * two apart on its own (it fires for both), and without this, exporting or1434     * importing a file silently locked and wiped the vault mid-flow the moment1435     * the picker took over, before its result ever reached the app.1436     *1437     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1438     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1439     */1440    fun expectTransientBackground() {1441        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1442    }14431444    /**1445     * Consumed once by the next background-lock check; see [expectTransientBackground].1446     * Returns false (and clears the flag) if the suppression has expired or was1447     * never set — a genuine background event must always lock.1448     */1449    fun consumeTransientBackgroundSuppression(): Boolean {1450        val until = suppressNextBackgroundLockUntil1451        val active = until != 0L && nowMs() < until1452        suppressNextBackgroundLockUntil = 0L1453        return active1454    }14551456    // ---- Sync identity ----------------------------------------------------14571458    /**1459     * The vault's stable sync master key (a copy). Caller must zero it. Backs1460     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1461     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1462     */1463    /**1464     * HOLE 5 — the duress vault never syncs.1465     *1466     * [commitDuressUnlock] gives the decoy session a root key so its own codex1467     * and recovery paths work, which means (unlike the browser, where the decoy1468     * path simply has no root key) the sync surface has to refuse explicitly.1469     * Without this the decoy vault would present a second, different vault_id1470     * to a paired device, and a coerced user opening it would make sync visibly1471     * fail on their other devices — a duress indicator, which is the one thing1472     * a duress vault must never be.1473     */1474    // M-04: the "null when locked" half of the contract was documented but never1475    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1476    // [unlockWithRecoveryWords], which installs a root key with no password key1477    // yet) and in a create that failed after staging. Sync must fail closed1478    // there, so the authoritative unlocked flag gates it, not just decoyMode.1479    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)14801481    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1482        if (decoyMode || !_isUnlocked.value) null1483        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1484    }14851486    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1487        mutateState { sessionEpoch == epoch }14881489    internal fun unlockedSessionEpoch(): Long? = mutateState {1490        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1491    }14921493    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key14941495    /**1496     * The current session's root key, decoy included. For local-only uses that1497     * must behave identically in a decoy session — Codex backup and recovery1498     * words — because a feature that errors under duress is itself a duress1499     * tell. In a decoy session this is the DECOY's root key (see1500     * [commitDuressUnlock]), never the real vault's, so neither surface can1501     * leak the real vault. Anything that leaves the device must use1502     * [syncMasterKey] instead.1503     */1504    fun sessionRootKey(): ByteArray? = mutateState {1505        if (!sessionDataAvailable) null else rootKey?.copyOf()1506    }15071508    /** Stable per-vault id two paired devices must share to sync. Null when1509     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1510    fun syncVaultId(): String? = mutateState {1511        if (decoyMode || !_isUnlocked.value) null1512        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1513    }15141515    /**1516     * Browser parity (MV-8): rotate the sync identity by replacing the stable1517     * root key. Entries remain under the master-password key, while sync1518     * credentials, vault_id, and recovery words all change.1519     */1520    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1521        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1522            ?: return false1523        installRootKey(generateRecoverableRootKey(), expectedEpoch)1524    }15251526    /**1527     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1528     * keeping every local entry.1529     *1530     * Sync identity (vault id, signing keys) derives from the root key, and1531     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1532     * created vaults can never reconcile. Taking the other side's root key is1533     * what makes them one vault; from the next sync on, both entry sets merge.1534     *1535     * Entries survive because they are sealed under the password-derived key,1536     * not the root key — the same property [rotateSyncIdentity] relies on.1537     *1538     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1539     * this device's previous recovery words stop working for good, and its new1540     * words are the source vault's words, because words are a deterministic1541     * encoding of the root key.1542     */1543    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1544        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1545            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1546        }1547        val expectedEpoch = mutateState {1548            when {1549                derivedKey == null -> null1550                decoyMode -> Long.MIN_VALUE1551                else -> sessionEpoch1552            }1553        }1554        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1555        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1556        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")15571558        val incoming = hexToRootKey(rootKeyHex)1559        if (incoming.size != VaultCrypto.KEY_SIZE) {1560            VaultCrypto.zero(incoming)1561            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1562        }1563        // Refuse before committing rather than leave this device holding a vault1564        // whose recovery words cannot be typed back. generateRecoverableRootKey1565        // screens its own keys for this; a key arriving from a peer has not been.1566        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1567        if (!roundTrips) {1568            VaultCrypto.zero(incoming)1569            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1570        }15711572        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1573        return if (installRootKey(incoming, expectedEpoch)) {1574            AdoptVaultResult.Success(vaultId)1575        } else {1576            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1577        }1578    }15791580    /**1581     * The recovery words [rootKeyHex] will produce once joined, or null if it is1582     * not a usable root key. Lets the join flow show the user the words that are1583     * about to replace theirs *before* anything is committed.1584     */1585    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1586        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1587        val bytes = hexToRootKey(rootKeyHex)1588        return try {1589            val words = rootKeyToWords(bytes)1590            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1591        } catch (_: Exception) {1592            null1593        } finally {1594            VaultCrypto.zero(bytes)1595        }1596    }15971598    /**1599     * Swap the vault's root key, keeping every entry, and re-escrow under the1600     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1601     * [mutex].1602     */1603    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1604        // H-03: snapshot the live fields under stateLock so the success and the1605        // rollback paths both see consistent prior/new state. Writing the new1606        // rootKey/recovery fields is inside its own mutateState at the end.1607        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1608        // — holding live references would let a zero-on-failure operation also1609        // wipe the session's still-live key arrays.1610        data class Snapshot(1611            val key: ByteArray,1612            val priorRootKey: ByteArray,1613            val priorRecoveryHash: String?,1614            val priorRecoverySet: Boolean,1615        )1616        val snap = mutateIfCurrentEpoch(expectedEpoch) {1617            val k = derivedKey ?: return@mutateIfCurrentEpoch null1618            if (decoyMode) return@mutateIfCurrentEpoch null1619            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1620            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1621        } ?: run {1622            VaultCrypto.zero(nextRootKey)1623            return false1624        }1625        val priorSlot = store.loadSlotA() ?: run {1626            VaultCrypto.zero(snap.key)1627            VaultCrypto.zero(snap.priorRootKey)1628            VaultCrypto.zero(nextRootKey)1629            return false1630        }1631        val nextWords = rootKeyToWords(nextRootKey)16321633        return try {1634            beforeDurableCommit()1635            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1636            if (mutateIfCurrentEpoch(expectedEpoch) {1637                rootKey = nextRootKey1638                recoveryWordHash = recoveryWordsHash(nextWords)1639                recoveryWordsSet = false1640            } == null) throw IllegalStateException("stale_root_rotation")16411642            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1643                ?: throw IllegalStateException("recovery_escrow_failed")1644            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1645                ?: throw IllegalStateException("root_escrow_failed")1646            val plaintext = VaultCodec.encode(currentPayload())1647            val blob = try {1648                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1649            } finally {1650                VaultCrypto.zero(plaintext)1651            }1652            val nextSlot = priorSlot.copy(1653                blob = blob,1654                recoveryEscrow = nextRecoveryEscrow,1655                rootKeyEscrow = nextRootKeyEscrow,1656            )1657            val committed = withEpochCommit(expectedEpoch) {1658                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1659                try {1660                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1661                    // H-04: a root-key rotation re-seals slot A, so the duress slot1662                    // (real or fake) must be re-padded to the new ciphertext length.1663                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1664                    store.stampLocalMutation()1665                } catch (_: Exception) {1666                    store.writeSlotA(priorSlot)1667                    return@withEpochCommit false1668                }1669                true1670            } ?: false1671            if (!committed) throw IllegalStateException("storage_write_failed")1672            mutateIfCurrentEpoch(expectedEpoch) { publish() }1673            true1674        } catch (_: Exception) {1675            val restored = mutateIfCurrentEpoch(expectedEpoch) {1676                rootKey?.let(VaultCrypto::zero)1677                rootKey = snap.priorRootKey.copyOf()1678                recoveryWordHash = snap.priorRecoveryHash1679                recoveryWordsSet = snap.priorRecoverySet1680            }1681            if (restored != null) {1682                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1683            }1684            false1685        } finally {1686            VaultCrypto.zero(snap.key)1687            VaultCrypto.zero(snap.priorRootKey)1688            // On success nextRootKey is owned by rootKey. On every other path1689            // it is not, including a stale epoch after lock(); scrub it while1690            // the local reference is still available.1691            val stillOwnedBySession = mutateState {1692                sessionEpoch == expectedEpoch && rootKey === nextRootKey1693            }1694            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1695        }1696    }16971698    /**1699     * A copy of the live Argon2id-derived vault key, for biometric escrow only1700     * (wrapped under a hardware KeyStore key on biometric enable). Null when1701     * locked. Caller must [VaultCrypto.zero] it after use.1702     */1703    fun currentVaultKey(): ByteArray? = mutateState {1704        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1705    }17061707    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1708        val expectedEpoch = currentEpoch()1709        val slotA = store.loadSlotA() ?: return false1710        var oldKey: ByteArray? = null1711        var newKey: ByteArray? = null1712        var plaintext: ByteArray? = null1713        return try {1714            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1715            // Keep ownership of the decrypted vault plaintext until decoding has1716            // finished.  Passing decrypt() inline made the byte array unreachable1717            // before the finally block, so a successful or failed password change1718            // could leave the full plaintext resident in the heap.1719            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1720            val payload = try {1721                VaultCodec.decode(decrypted)1722            } finally {1723                VaultCrypto.zero(decrypted)1724            }1725            val newSalt = VaultCrypto.generateSalt()1726            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1727            // Re-seal the existing payload under the new key/salt.1728            plaintext = VaultCodec.encode(payload)1729            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1730            // The escrow seals the slot-A PLAINTEXT, which a password change does not1731            // alter — only the wrapping changes — so carry it forward; rebuilding the1732            // StoredVault from scratch would silently drop it.1733            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1734            // content that a password change does not alter — only the slot-A1735            // wrapping rotates. The root-key escrow staying stable across password1736            // changes matches the browser (`vault.ts:363-364`).1737            val next = StoredVault(1738                version = slotA.version,1739                salt = b64(newSalt),1740                blob = blob,1741                recoveryEscrow = slotA.recoveryEscrow,1742                rootKeyEscrow = slotA.rootKeyEscrow,1743            )1744            // H-01: durably commit slot A + size-matched noise + version stamp1745            // before swapping the session key. A partial commit (new A on disk,1746            // old key in memory) would have the next save encrypt under the old1747            // key while matching disk on the new one.1748            val priorB = store.loadSlotB()1749            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1750            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1751            // inside the same stateLock critical section, so a concurrent lock()1752            // can't observe the in-between (zeroed but not yet reassigned).1753            if (mutateIfCurrentEpoch(expectedEpoch) {1754                oldKey?.let(VaultCrypto::zero); oldKey = null1755                derivedKey?.let(VaultCrypto::zero)1756                derivedKey = newKey; newKey = null1757            } == null) return false1758            VaultKeyRotationHooks.onRotated()1759            true1760        } catch (_: Exception) {1761            false1762        } finally {1763            oldKey?.let(VaultCrypto::zero)1764            newKey?.let(VaultCrypto::zero)1765            plaintext?.let(VaultCrypto::zero)1766        }1767    }17681769    /**1770     * Irreversible wipe. Locks the session and clears every vault key. The Pro1771     * license is in a separate store and is preserved by construction.1772     */1773    suspend fun destroyVault(): Boolean = mutex.withLock {1774        lock()1775        val cleared = store.clearVault()1776        store.clearDecoySlot()1777        cleared1778    }17791780    // ---- Entry CRUD -------------------------------------------------------17811782    /**1783     * A Peach entry id is a UUID. Import and restore carry ids from foreign1784     * files (another Peach install, a vendor export, a hand-written backup) and1785     * no reader bounds them, so an id that is not a well-formed Peach id is1786     * replaced here rather than trusted.1787     *1788     * This is the naturalisation seam, and it is deliberately the only place it1789     * happens. Minting costs at most one duplicate if the same vault is later1790     * restored on a device that still holds the old id — visible and fixable.1791     * The sync path must NOT do this: adopting a peer's id verbatim is the only1792     * thing that keeps two devices agreeing on which entry they are discussing,1793     * so reminting there would fork identity on every device, on every round.1794     */1795    private val entryIdShape = Regex("[A-Za-z0-9_-]{1,64}")17961797    private fun String?.normalizedEntryId(): String =1798        if (this != null && entryIdShape.matches(this)) this else UUID.randomUUID().toString()17991800    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1801        val epoch = requireUnlocked()1802        val stamp = VaultCrypto.monotonicEntryTimestamp()1803        val now = System.currentTimeMillis()1804        val entry = withDerivedLast4(draft.copy(1805            id = draft.id.normalizedEntryId(),1806            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1807            modifiedAt = stamp,1808            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1809        ))1810        // H-03: capture rollback AND write entryList under stateLock so a1811        // concurrent lock() cannot zero the session between the read and the write.1812        val rollback = mutateCurrentSession(epoch) {1813            val r = entryList.toMutableList()1814            entryList.add(entry)1815            r1816        } ?: throw IllegalStateException("vault_locked")1817        persistOrRollback(rollback, epoch)1818        entry1819    }18201821    /**1822     * Insert many entries with a single encrypt-and-write at the end.1823     *1824     * Importing a real vault through [addEntry] was quadratic: every entry took1825     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1826     * restore did 806 full-vault writes and hung the app long enough to ANR.1827     * Rolls back to the pre-import list if the one write fails, matching1828     * [addEntry]'s all-or-nothing behaviour.1829     */1830    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1831        val epoch = requireUnlocked()1832        if (drafts.isEmpty()) return 01833        val now = System.currentTimeMillis()1834        val ready = drafts.map { draft ->1835            val stamp = VaultCrypto.monotonicEntryTimestamp()1836            withDerivedLast4(1837                draft.copy(1838                    id = draft.id.normalizedEntryId(),1839                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1840                    modifiedAt = stamp,1841                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1842                ),1843            )1844        }1845        // H-03: capture rollback AND append every entry under stateLock.1846        val rollback = mutateCurrentSession(epoch) {1847            val r = entryList.toMutableList()1848            entryList.addAll(ready)1849            r1850        } ?: throw IllegalStateException("vault_locked")1851        persistOrRollback(rollback, epoch)1852        return drafts.size1853    }18541855    /**1856     * M-01: replace the whole active set with [newEntries] in one write.1857     *1858     * Import's REPLACE mode used to soft-delete every current entry one durable1859     * write at a time (swallowing each failure) and then batch-add, so a failed1860     * add left the old entries stranded in Trash with nothing restored. Staging1861     * the entire next list in memory and committing it through1862     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1863     * pre-import list — active entries included — is restored.1864     *1865     * Existing tombstones are preserved so paired devices still see the1866     * deletions they already know about.1867     */1868    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1869        val epoch = requireUnlocked()1870        val now = System.currentTimeMillis()1871        val ready = newEntries.map { draft ->1872            val stamp = VaultCrypto.monotonicEntryTimestamp()1873            withDerivedLast4(1874                draft.copy(1875                    id = draft.id.normalizedEntryId(),1876                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1877                    modifiedAt = stamp,1878                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1879                ),1880            )1881        }1882        // M-03: an incoming entry that shares an id with an existing live entry1883        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1884        // the old one and then appending the new one would leave the list with1885        // two entries of the same id — the tombstoned one would be returned by1886        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1887        // superseded entries from the tombstone set instead.1888        val incomingIds = ready.map { it.id }.toHashSet()1889        val rollback = mutateCurrentSession(epoch) {1890            val r = entryList.toMutableList()1891            val replaced = entryList.map { entry ->1892                if (entry.deletedAt != null) return@map entry1893                if (entry.id in incomingIds) return@map null1894                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1895                entry.copy(deletedAt = stamp, modifiedAt = stamp)1896            }.filterNotNull()1897            entryList = (replaced + ready).toMutableList()1898            r1899        } ?: throw IllegalStateException("vault_locked")1900        persistOrRollback(rollback, epoch)1901        return ready.size1902    }19031904    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1905        val epoch = requireUnlocked()1906        // H-03: read entryList, compute the patched entry, and write it under a1907        // single stateLock so a concurrent lock() cannot interleave between the1908        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1909        // list, or clobber the entry onto the wrong id after a relock).1910        val prepared = mutateCurrentSession(epoch) {1911            val idx = entryList.indexOfFirst { it.id == patch.id }1912            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1913            val current = entryList[idx]1914            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1915            // Only refresh passwordChangedAt when the password value actually changes.1916            val passwordChanged = patch.password != null && patch.password != current.password1917            // Capture the outgoing password (newest first) so the user can reveal/copy/1918            // restore it later. History is computed authoritatively from `current`, not1919            // the patch — updateEntry is a full-entry replace, so trusting patch's value1920            // would let a stale form clobber the real history. Record the age the old1921            // password carried, skip an empty prior, and skip an immediate exact dupe1922            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1923            val priorPassword = current.password1924            val shouldCapture = passwordChanged &&1925                !priorPassword.isNullOrEmpty() &&1926                current.passwordHistory?.firstOrNull()?.password != priorPassword1927            val nextHistory = if (shouldCapture) {1928                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1929                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1930            } else {1931                current.passwordHistory1932            }1933            val updated = withDerivedLast4(patch.copy(1934                createdAt = current.createdAt,1935                modifiedAt = stamp,1936                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1937                passwordHistory = nextHistory,1938            ))1939            val rollback = entryList.toMutableList()1940            entryList[idx] = updated1941            updated to rollback1942        } ?: throw IllegalStateException("vault_locked")1943        val (updated, rollback) = prepared1944        persistOrRollback(rollback, epoch)1945        updated1946    }19471948    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1949    suspend fun deleteEntry(id: String) = mutex.withLock {1950        val epoch = requireUnlocked()1951        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1952        // the list out from under the indexOfFirst.1953        val rollback = mutateCurrentSession(epoch) {1954            val idx = entryList.indexOfFirst { it.id == id }1955            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1956            val current = entryList[idx]1957            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1958            val prior = entryList.toMutableList()1959            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1960            prior1961        } ?: throw IllegalStateException("vault_locked")1962        persistOrRollback(rollback, epoch)1963    }19641965    suspend fun restoreEntry(id: String) = mutex.withLock {1966        val epoch = requireUnlocked()1967        val rollback = mutateCurrentSession(epoch) {1968            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1969            if (idx == -1) throw IllegalStateException("deleted entry not found")1970            val current = entryList[idx]1971            val prior = entryList.toMutableList()1972            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1973            prior1974        } ?: throw IllegalStateException("vault_locked")1975        persistOrRollback(rollback, epoch)1976    }19771978    /** Permanent delete — no tombstone, no recovery. */1979    suspend fun purgeEntry(id: String) = mutex.withLock {1980        val epoch = requireUnlocked()1981        val rollback = mutateCurrentSession(epoch) {1982            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1983            if (idx == -1) throw IllegalStateException("deleted entry not found")1984            val prior = entryList.toMutableList()1985            entryList.removeAt(idx)1986            prior1987        } ?: throw IllegalStateException("vault_locked")1988        persistOrRollback(rollback, epoch)1989    }19901991    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1992    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1993        val epoch = requireUnlocked()1994        // H-03: read+remove under stateLock so lock() can't reorder the remove.1995        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1996        val r = mutateCurrentSession(epoch) {1997            val rollback = entryList.toMutableList()1998            val before = entryList.size1999            entryList.removeAll { it.deletedAt != null }2000            R(rollback, before - entryList.size)2001        } ?: throw IllegalStateException("vault_locked")2002        if (r.removed > 0) persistOrRollback(r.rollback, epoch)2003        r.removed2004    }20052006    /** Browser parity: number of active entries that dedupe would move to trash. */2007    suspend fun countDuplicateEntries(): Int = mutex.withLock {2008        val epoch = requireUnlocked()2009        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so2010        // lock() can't race the read with state writes.2011        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }2012            ?: throw IllegalStateException("vault_locked")2013    }20142015    /**2016     * Browser parity: soft-delete duplicate entries, keeping the most recently2017     * modified member of each group. Refuse in decoy mode so the duress vault is2018     * not reshaped into a forensically distinct state.2019     */2020    suspend fun deduplicateEntries(): Int = mutex.withLock {2021        val epoch = requireUnlocked()2022        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)2023        val ready = mutateCurrentSession(epoch) {2024            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")2025            val active = entryList.filter { it.deletedAt == null }2026            val keepIds = mutableSetOf<String>()2027            active.groupBy { it.duplicateKey() }.values.forEach { group ->2028                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id2029            }2030            val rollback = entryList.toMutableList()2031            var removed = 02032            entryList = entryList.map { entry ->2033                if (entry.deletedAt == null && entry.id !in keepIds) {2034                    removed++2035                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)2036                    entry.copy(deletedAt = stamp, modifiedAt = stamp)2037                } else {2038                    entry2039                }2040            }.toMutableList()2041            R(rollback, removed)2042        } ?: throw IllegalStateException("vault_locked")2043        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)2044        ready.removed2045    }20462047    fun getEntries(): List<VaultEntry> = mutateState {2048        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }2049    }2050    fun getEntry(id: String): VaultEntry? = mutateState {2051        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }2052    }2053    fun getDeletedEntries(): List<VaultEntry> = mutateState {2054        if (!sessionDataAvailable) emptyList()2055        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }2056    }20572058    /** Active entries, optionally narrowed to those carrying [tagFilter]. */2059    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {2060        return mutateState {2061            if (!sessionDataAvailable) return@mutateState emptyList()2062            val active = entryList.filter { it.deletedAt == null }2063            if (tagFilter == null) return@mutateState active2064            val needle = tagFilter.trim().lowercase()2065            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }2066        }2067    }20682069    /** Sorted, de-duplicated tags across all active entries. */2070    fun getDistinctTags(): List<String> = mutateState {2071        if (!sessionDataAvailable) return@mutateState emptyList()2072        entryList.asSequence()2073            .filter { it.deletedAt == null }2074            .flatMap { it.tags?.asSequence() ?: emptySequence() }2075            .map { it.trim().lowercase() }2076            .filter { it.isNotEmpty() }2077            .distinct()2078            .sorted()2079            .toList()2080    }20812082    /**2083     * Distinct non-blank values for an identity field across active entries,2084     * most-frequently-used first. Supports "username" and "email"; unknown field2085     * names return an empty list. Values keep original casing (deduped2086     * case-insensitively) so a suggestion matches what the user typed.2087     */2088    fun getDistinctFieldValues(field: String): List<String> {2089        val select: (VaultEntry) -> String? = when (field) {2090            "username" -> { e -> e.username }2091            "email" -> { e -> e.email }2092            else -> return emptyList()2093        }2094        return mutateState {2095            if (!sessionDataAvailable) return@mutateState emptyList()2096            entryList.asSequence()2097                .filter { it.deletedAt == null }2098                .mapNotNull { select(it)?.trim() }2099                .filter { it.isNotEmpty() }2100                .groupingBy { it.lowercase() }2101                .aggregate { _, acc: Pair<String, Int>?, value, _ ->2102                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)2103                }2104                .values2105                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })2106                .map { it.first }2107        }2108    }21092110    fun getTombstones(): List<Tombstone> = mutateState {2111        if (!sessionDataAvailable) emptyList()2112        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }2113    }2114    fun getVaultVersion(): Long = store.version()2115    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots21162117    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {2118        val expectedEpoch = currentEpoch()2119        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false2120        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false2121        // H-04: snapshots now carry the duress slot too — restore preserves2122        // the at-rest triad (A/B/D), not just the A/B pair.2123        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)2124        val priorA = store.loadSlotA()2125        val priorB = store.loadSlotB()2126        val priorD = store.loadDecoySlot()2127        // H-02: capture pre-restore state as the recovery snapshot BEFORE any2128        // slot write — the old code took this snapshot, then stranded it on2129        // every failure, polluting history with a "restore" entry that never2130        // restored anything. The history below is rolled back together with2131        // the slots if the commit fails.2132        val priorHistory = store.loadVaultHistory()2133        // P1-4 (codex): the recovery snapshot must land before we commit the2134        // slot pair, otherwise a failed restore cannot be rolled back to the2135        // pre-restore history. If the history write itself fails, refuse to2136        // proceed — the alternative is a successful restore with no undo path.2137        beforeDurableCommit()2138        if (!takeVaultSnapshot("restore", expectedEpoch)) {2139            return false2140        }2141        // H-04: legacy snapshots without vault_d, or vault_d that fails to2142        // decode, fall back to a fake duress of slot A's size — presence-2143        // indistinguishable from any other freshly-restored vault.2144        val nextD: StoredVault = restoredD ?: run {2145            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 162146            val fakeBlob = ByteArray(target).also(rng::nextBytes)2147            val fakeSalt = VaultCrypto.generateSalt()2148            StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob))2149        }2150        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {2151            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }2152            return false2153        }2154        lock()2155        true2156    }21572158    /**2159     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.2160     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B2161     * (a restore preserves the duress-shaped blob from history), not a fresh2162     * noise record. Any failure rolls back so the at-rest pair never holds a2163     * restored slot A against a pre-restore slot B. Caller is responsible for2164     * restoring the pre-snapshot history on a false return.2165     *2166     * H-04: duress slot is now part of the atomic triad — restored last so2167     * a duress-write failure rolls the A/B pair back too.2168     */2169    private fun commitRestoreAtomic(2170        nextA: StoredVault,2171        nextB: String,2172        nextD: StoredVault,2173        priorA: StoredVault?,2174        priorB: String?,2175        priorD: StoredVault?,2176        expectedEpoch: Long,2177    ): Boolean {2178        return withEpochCommit(expectedEpoch) {2179            if (!store.writeSlotA(nextA)) return@withEpochCommit false2180            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it2181            // must trigger rollback; previously a throw escaped without restoring2182            // the prior slot A and history.2183            try {2184                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")2185                // H-04: duress slot is part of the restore triad. Failures here2186                // roll A+B back so the at-rest state stays consistent.2187                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")2188                store.stampLocalMutation()2189            } catch (_: Exception) {2190                reportRollbackFailures(slotRollback.restoreTriad(priorA, priorB, priorD))2191                return@withEpochCommit false2192            }2193            true2194        } ?: false2195    }21962197    // ---- Sync apply (does NOT advance the version) ------------------------21982199    /**2200     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists2201     * WITHOUT bumping the version counter, so a sync round cannot cascade into2202     * another outbound sync. Returns the number of locally-applied changes.2203     */2204    suspend fun applyMergedRemoteEntries(2205        remoteEntries: List<VaultEntry>,2206        remoteTombstones: List<Tombstone>,2207        expectedEpoch: Long? = null,2208    ): Int = mutex.withLock {2209        val epoch = expectedEpoch ?: requireUnlocked()2210        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02211        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS2212        // BEFORE any comparison against a local one. Without this a peer can2213        // send a far-future modifiedAt and permanently pin its version of an2214        // entry against every honest local edit. The browser clamps in the2215        // matching place (vault.ts applyMergedRemoteEntries).2216        val mergeNow = System.currentTimeMillis()2217        val clamped = remoteEntries.mapNotNull { entry ->2218            // MN-3: drop peer entries that decode cleanly but are semantically2219            // hollow (blank title, credential-less login, husk passkey) and2220            // re-cap passwordHistory before anything merges into the vault.2221            SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2222                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2223                deletedAt = entry.deletedAt?.let {2224                    SyncLimits.clampSyncTimestamp(it, mergeNow)2225                },2226            )2227        }2228        val tombMap = remoteTombstones.associate {2229            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2230        }2231        // H-03: build the new entryList under stateLock so a concurrent lock()2232        // cannot zero derivedKey/entryList between the byId read and the merge.2233        val applied = mutateCurrentSession(epoch) {2234            var n = 02235            val byId = entryList.associateBy { it.id }.toMutableMap()22362237            for (remote in clamped) {2238                val tomb = tombMap[remote.id]2239                if (tomb != null && tomb >= remote.modifiedAt) continue2240                val local = byId[remote.id]2241                if (local == null) {2242                    entryList.add(remote); byId[remote.id] = remote; n++2243                } else if (remote.modifiedAt > local.modifiedAt) {2244                    val i = entryList.indexOfFirst { it.id == local.id }2245                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2246                }2247            }22482249            for ((id, deletedAt) in tombMap) {2250                val local = byId[id] ?: continue2251                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2252                if (deletedAt < local.modifiedAt) continue2253                val i = entryList.indexOfFirst { it.id == id }2254                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2255            }2256            n2257        } ?: return@withLock 022582259        if (applied > 0) {2260            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2261            mutateIfCurrentEpoch(epoch) { publish() }2262            runCatching { LockedSyncSpoolHooks.refresh(this) }2263        }2264        applied2265    }22662267    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2268        // A decoy session edits an in-memory copy only — discarded at lock.2269        // The real vault's settings record is not the duress session's to2270        // change, and letting it through weakened capture protection on a plain2271        // in-app surface (PB-26-120).2272        if (mutateState { decoyMode }) {2273            val decoyEpoch = requireUnlocked()2274            mutateCurrentSession(decoyEpoch) { _settings.value = settings }2275                ?: throw IllegalStateException("vault_locked")2276            return@withLock2277        }2278        // Require a fully live session before exposing a settings mutation.  The2279        // epoch is captured with the read so a lock/new-session boundary cannot2280        // let stale work publish into the replacement session.2281        val epoch = requireUnlocked()2282        val prior = mutateCurrentSession(epoch) {2283            val old = _settings.value2284            _settings.value = settings2285            old2286        } ?: throw IllegalStateException("vault_locked")22872288        val committed = try {2289            beforeDurableCommit()2290            // The durable write is inside the same epoch fence as the in-memory2291            // publication.  If lock() won first, this returns null and no stale2292            // settings reach storage or the public StateFlow.2293            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2294        } catch (_: Exception) {2295            false2296        }2297        if (!committed) {2298            // A live-session storage failure rolls back the public value.  A2299            // stale epoch is already reset by lock()/the new session and must2300            // not be allowed to overwrite that safe state.2301            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2302            throw IllegalStateException("settings_write_failed")2303        }2304    }23052306    // ---- Recovery words (vault metadata) ----------------------------------23072308    /** True once the user has confirmed their recovery words for this vault. */2309    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }23102311    /**2312     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2313     * Persisted without bumping the version — recovery metadata is local-only and2314     * is not an entry mutation paired devices should sync against.2315     */2316    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2317        val epoch = requireUnlocked()2318        // H-03: hash + escrow-related state fields set under stateLock so a2319        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2320        // M-04: the flags have to be live before the escrows are built — both seal2321        // currentPayload() — so capture the prior pair and put it back if the write2322        // never lands. Otherwise the session reports words set against a slot A2323        // that still holds no escrow, and the recovery screen never offers a retry.2324        val prior = mutateCurrentSession(epoch) {2325            val priorPair = recoveryWordHash to recoveryWordsSet2326            recoveryWordHash = recoveryWordsHash(words)2327            recoveryWordsSet = true2328            priorPair2329        } ?: return@withLock false2330        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2331        // (cross-platform recovery of the sync root key) and Android's whole-payload2332        // escrow (offline forgot-password restore of entries). Both derive their key2333        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2334        val persisted = runCatching {2335            persistSlotAWithEscrows(2336                buildRecoveryEscrow(words),2337                buildRootKeyEscrow(words),2338                expectedEpoch = epoch,2339            )2340        }.getOrDefault(false)2341        if (!persisted) {2342            mutateIfCurrentEpoch(epoch) {2343                recoveryWordHash = prior.first2344                recoveryWordsSet = prior.second2345            }2346        }2347        persisted2348    }23492350    /** Verify entered words against the stored recovery hash. */2351    fun verifyRecoveryWords(words: List<String>): Boolean {2352        return mutateState {2353            val stored = recoveryWordHash ?: return@mutateState false2354            MessageDigest.isEqual(2355                stored.toByteArray(Charsets.UTF_8),2356                recoveryWordsHash(words).toByteArray(Charsets.UTF_8),2357            )2358        }2359    }23602361    // ---- Recovery escrow (forgot-password) --------------------------------23622363    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2364    fun hasRecoveryEscrow(): Boolean =2365        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false23662367    /**2368     * One-time self-heal: if this vault has a root key but no recovery escrow2369     * on disk (adopted vaults created before the escrow fix), build and persist2370     * it now. Called on every successful unlock; no-ops once the escrow exists.2371     */2372    private fun ensureRecoveryEscrow() {2373        if (hasRecoveryEscrow()) return2374        val rk = mutateState { rootKey?.copyOf() } ?: return2375        runCatching {2376            val words = rootKeyToWords(rk)2377            val escrow = buildRecoveryEscrow(words)2378            val rkEscrow = buildRootKeyEscrow(words)2379            if (escrow != null || rkEscrow != null) {2380                val prior = store.loadSlotA() ?: return@runCatching2381                store.writeSlotA(prior.copy(2382                    recoveryEscrow = escrow ?: prior.recoveryEscrow,2383                    rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2384                ))2385                mutateState {2386                    recoveryWordHash = recoveryWordsHash(words)2387                    recoveryWordsSet = true2388                }2389            }2390        }.also { VaultCrypto.zero(rk) }2391    }23922393    /**2394     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2395     * the master password. On success the slot-A plaintext sealed in the escrow is2396     * adopted as the live session (entries, rootKey, settings), but no Argon2id2397     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2398     * new password and finish unlocking. Returns false when there is no escrow, the2399     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2400     *2401     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2402     * detached vault key to return here — slot A is wrapped under the rotating2403     * Argon2id key — so this opens the escrowed payload directly.2404     */2405    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2406        val expectedEpoch = currentEpoch()2407        val slotA = store.loadSlotA() ?: return false2408        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false24092410        // Prefer Android's whole-payload escrow: a full offline restore of entries,2411        // root key, and settings. Present for Android-created vaults.2412        val fullEscrow = slotA.recoveryEscrow2413        if (fullEscrow != null) {2414            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2415            if (material != null) {2416                var keyBytes: ByteArray? = null2417                var plaintext: ByteArray? = null2418                var candidateRoot: ByteArray? = null2419                var stateCommitStarted = false2420                try {2421                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2422                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2423                    val payload = VaultCodec.decode(plaintext)2424                    // Stage every fallible operation before touching live session2425                    // fields.  In particular, a settings/provider/decode failure2426                    // cannot leave entries, flags, or a root key resident while the2427                    // public session remains locked.2428                    val candidateSettings = store.loadSettings()2429                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2430                        val decodedRoot = unb64(payload.rootKeyB64)2431                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2432                            // Do not let a malformed authenticated payload abandon2433                            // this decoded root-key candidate before the outer2434                            // finally block owns it.2435                            VaultCrypto.zero(decodedRoot)2436                            throw IllegalArgumentException("invalid recovery root key")2437                        }2438                        decodedRoot2439                    } else {2440                        generateRecoverableRootKey()2441                    }2442                    stateCommitStarted = true2443                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2444                        clearSessionStateLocked()2445                        entryList = payload.entries.toMutableList()2446                        authenticatedDuressConfigured = payload.duressConfigured2447                        _duressConfigured.value = payload.duressConfigured2448                        recoveryWordHash = payload.recoveryWordHash2449                        recoveryWordsSet = payload.recoveryWordsSet2450                        rootKey = candidateRoot2451                        candidateRoot = null2452                        _settings.value = candidateSettings2453                        publish()2454                        // This is the authenticated recovery half-unlock boundary:2455                        // only set it after every candidate field is installed.2456                        sessionDataAvailable = true2457                        _isUnlocked.value = false2458                        true2459                    }2460                    if (adopted == true) return@withLock true2461                } catch (_: Exception) {2462                    // Wrong words, malformed payloads, provider failures, and2463                    // settings failures all fall through to the browser-compatible2464                    // root-key escrow.  If the state commit had started, invalidate2465                    // that epoch so no partial candidate survives.2466                    if (stateCommitStarted) {2467                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2468                    }2469                } finally {2470                    plaintext?.let(VaultCrypto::zero)2471                    VaultCrypto.zero(material)2472                    keyBytes?.let(VaultCrypto::zero)2473                    candidateRoot?.let(VaultCrypto::zero)2474                }2475            }2476        }24772478        // Browser-format vault (only the root-key escrow): recover the 32-byte root2479        // key (sync identity) and reset the password; entries return via sync. This2480        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2481        val rootEscrow = slotA.rootKeyEscrow2482        if (rootEscrow != null) {2483            var candidateRoot: ByteArray? = null2484            var stateCommitStarted = false2485            try {2486                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2487                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2488                val candidateSettings = store.loadSettings()2489                stateCommitStarted = true2490                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2491                    clearSessionStateLocked()2492                    entryList = mutableListOf()2493                    authenticatedDuressConfigured = false2494                    _duressConfigured.value = false2495                    recoveryWordHash = recoveryWordsHash(words)2496                    recoveryWordsSet = true2497                    rootKey = candidateRoot2498                    candidateRoot = null2499                    _settings.value = candidateSettings2500                    publish()2501                    sessionDataAvailable = true2502                    _isUnlocked.value = false2503                    true2504                }2505                if (adopted == true) return@withLock true2506            } catch (_: Exception) {2507                if (stateCommitStarted) {2508                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2509                }2510            } finally {2511                candidateRoot?.let(VaultCrypto::zero)2512            }2513        }2514        return false2515    }25162517    /**2518     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2519     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2520     * words, mark the session fully unlocked, and publish. Returns false if called2521     * without a prior successful recovery unlock (no in-memory root key).2522     *2523     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2524     * into one step, since there is no detached vault key here.2525     */2526    suspend fun resetPasswordWithRecoveryWords(2527        words: List<String>,2528        newPassword: String,2529    ): Boolean = mutex.withLock {2530        val expectedEpoch = currentEpoch()2531        // H-03: read rootKey + entryList under stateLock before computing the new2532        // plaintext, then re-publish + swap the derived key under stateLock so a2533        // concurrent lock() cannot straddle the two fences.2534        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2535        val priorA = store.loadSlotA() ?: return false2536        var newKey: ByteArray? = null2537        var plaintext: ByteArray? = null2538        return try {2539            val newSalt = VaultCrypto.generateSalt()2540            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2541            plaintext = VaultCodec.encode(currentPayload())2542            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2543            // Rebuild both escrows against the recovered session so their salts rotate2544            // with the password reset; fall back to the prior escrows if rebuild fails2545            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2546            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2547            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2548            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2549            val next = StoredVault(2550                version = priorA.version,2551                salt = b64(newSalt),2552                blob = blob,2553                recoveryEscrow = freshRecovery,2554                rootKeyEscrow = freshRootKey,2555            )2556            // H-01: see changePassword — both rotations share the same2557            // commit-or-keep-old-key contract so a partial write can't strand2558            // the session on a key that no longer matches disk.2559            val priorB = store.loadSlotB()2560            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2561            // H-03 (P1-2 codex): zero the old live key and install the new one2562            // inside the same stateLock critical section so a concurrent lock()2563            // can't observe the in-between.2564            if (mutateIfCurrentEpoch(expectedEpoch) {2565                derivedKey?.let(VaultCrypto::zero)2566                derivedKey = newKey; newKey = null2567                publish()2568                _isUnlocked.value = true2569            } == null) return false2570            VaultKeyRotationHooks.onRotated()2571            true2572        } catch (_: Exception) {2573            false2574        } finally {2575            newKey?.let(VaultCrypto::zero)2576            plaintext?.let(VaultCrypto::zero)2577        }2578    }25792580    /**2581     * Seal the current slot-A plaintext under a key derived from the 12 BIP392582     * recovery [words] (Android offline-restore escrow). IKM is the browser's2583     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2584     * Returns null for a checksum-invalid phrase.2585     */2586    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2587        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2588        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2589        var keyBytes: ByteArray? = null2590        var plaintext: ByteArray? = null2591        return try {2592            val salt = VaultCrypto.generateSalt()2593            val recoveryKey = deriveRecoveryKey(material, salt)2594            keyBytes = recoveryKey.encoded2595            plaintext = VaultCodec.encode(currentPayload())2596            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2597            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2598        } finally {2599            VaultCrypto.zero(material)2600            keyBytes?.let(VaultCrypto::zero)2601            plaintext?.let(VaultCrypto::zero)2602        }2603    }26042605    /**2606     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2607     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2608     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2609     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2610     * the same root key on the browser. Returns null with no in-memory root key or2611     * a checksum-invalid phrase.2612     */2613    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2614        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2615        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2616        val rk = mutateState { rootKey?.copyOf() } ?: run {2617            VaultCrypto.zero(material)2618            return null2619        }2620        var keyBytes: ByteArray? = null2621        var plaintext: ByteArray? = null2622        return try {2623            val salt = VaultCrypto.generateSalt()2624            val recoveryKey = deriveRecoveryKey(material, salt)2625            keyBytes = recoveryKey.encoded2626            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2627            // combined = nonce(12) || ciphertext || tag(16)2628            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2629            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2630            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2631            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2632            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2633            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2634        } finally {2635            VaultCrypto.zero(rk)2636            VaultCrypto.zero(material)2637            keyBytes?.let(VaultCrypto::zero)2638            plaintext?.let(VaultCrypto::zero)2639        }2640    }26412642    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2643    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2644        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2645        var keyBytes: ByteArray? = null2646        var pt: ByteArray? = null2647        return try {2648            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2649            keyBytes = recoveryKey.encoded2650            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2651            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2652            hexToRootKey(String(pt, Charsets.US_ASCII))2653        } catch (_: Exception) {2654            null2655        } finally {2656            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2657            // IKM-equivalent of the vault identity. material+keyBytes were2658            // already zeroed; pt was leaking.2659            VaultCrypto.zero(material)2660            keyBytes?.let(VaultCrypto::zero)2661            pt?.let(VaultCrypto::zero)2662        }2663    }26642665    /**2666     * Re-seal slot A from the current in-memory state under the live key, carrying2667     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2668     * in decoy mode (recovery is never offered for the decoy) and persists without2669     * a version bump, like the recovery-words metadata write it replaces.2670     */2671    private fun persistSlotAWithEscrows(2672        recoveryEscrow: RecoveryEscrow?,2673        rootKeyEscrow: RootKeyEscrow?,2674        expectedEpoch: Long? = null,2675    ): Boolean {2676        val snapshot = snapshotForPersist(expectedEpoch)2677            ?: throw IllegalStateException("vault_locked_or_stale")2678        if (snapshot.decoy) {2679            VaultCrypto.zero(snapshot.key)2680            return false2681        }2682        try {2683            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2684            val plaintext = VaultCodec.encode(snapshot.payload)2685            val blob = try {2686                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2687            } finally {2688                // The escrow path used to drop this reference before zeroing it;2689                // keep it live through the fill on both success and failure.2690                VaultCrypto.zero(plaintext)2691            }2692            val next = prior.copy(2693                blob = blob,2694                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2695                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2696            )2697            beforeDurableCommit()2698            return withEpochCommit(snapshot.epoch) {2699                if (!store.writeSlotA(next)) return@withEpochCommit false2700                try {2701                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2702                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2703                    // the duress slot too. A real decoy is left alone; a fake is2704                    // re-sized to slot A's new ciphertext length.2705                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2706                } catch (e: Exception) {2707                    store.writeSlotA(prior)2708                    throw e2709                }2710                true2711            } ?: false2712        } finally {2713            VaultCrypto.zero(snapshot.key)2714        }2715    }27162717    // ---- Internals --------------------------------------------------------27182719    private fun requireUnlocked(): Long {2720        // H-03: read derivedKey atomically with [lock]'s reset; without it,2721        // [lock] from a background lifecycle event can run between this read2722        // and the caller's first state touch.2723        stateLock.lock()2724        try {2725            if (derivedKey == null) throw IllegalStateException("vault_locked")2726            return sessionEpoch2727        } finally {2728            stateLock.unlock()2729        }2730    }27312732    /** A locked or half-adopted session must not expose secret-bearing fields. */2733    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null27342735    private fun countDuplicateEntriesLocked(): Int {2736        val seen = mutableMapOf<String, VaultEntry>()2737        var duplicates = 02738        for (entry in entryList.filter { it.deletedAt == null }) {2739            val key = entry.duplicateKey()2740            val existing = seen[key]2741            if (existing == null) {2742                seen[key] = entry2743            } else {2744                duplicates++2745                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2746                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2747                if (entryStamp > existingStamp) seen[key] = entry2748            }2749        }2750        return duplicates2751    }27522753    private fun VaultEntry.duplicateKey(): String {2754        if (type == "note") return "note|${title.lowercase()}"2755        val passkeyData = passkey2756        if (passkeyData != null) {2757            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2758        }2759        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2760    }27612762    /**2763     * Capture the current slot pair (and metadata) into the history list.2764     * Returns true when the history write landed, false when it didn't — callers2765     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2766     * must check the result and roll back if it does not.2767     */2768    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2769        val epoch = expectedEpoch ?: currentEpoch()2770        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2771        val slotA = store.loadSlotA() ?: return false2772        val vaultA = VaultCodec.encodeSlot(slotA)2773        val vaultB = store.loadSlotB() ?: slotA.blob2774        // H-04: capture the duress slot so a restore preserves the at-rest2775        // triad, not just the A/B pair. The duress slot is always present2776        // (real or fake), so loadDecoySlot() returning null is a logic error2777        // — fall through to a fake duress in that case rather than silently2778        // losing the record from the snapshot.2779        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2780            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162781            val fakeBlob = ByteArray(target).also(rng::nextBytes)2782            val fakeSalt = VaultCrypto.generateSalt()2783            VaultCodec.encodeSlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob)))2784        }2785        val history = store.loadVaultHistory()2786        val latest = history.snapshots.firstOrNull()2787        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2788        val entryCount = mutateIfCurrentEpoch(epoch) {2789            if (derivedKey != null && !decoyMode) {2790                bucketEntryCount(entryList.count { it.deletedAt == null })2791            } else {2792                null2793            }2794        } ?: return false2795        return try {2796            val snapshot = VaultSnapshot(2797                id = UUID.randomUUID().toString(),2798                savedAt = System.currentTimeMillis(),2799                reason = reason,2800                vault_a = vaultA,2801                vault_b = vaultB,2802                vault_d = vaultD,2803                entryCount = entryCount,2804            )2805            // P1-4 (codex): return the history-write boolean to callers so a2806            // false return no longer leaves the snapshot out of history while2807            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2808            withEpochCommit(epoch) {2809                store.writeVaultHistory(2810                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2811                )2812            } ?: false2813        } catch (_: Exception) {2814            false2815        }2816    }28172818    private fun bucketEntryCount(n: Int): Int = when {2819        n <= 0 -> 02820        n <= 10 -> 102821        n <= 25 -> 252822        n <= 50 -> 502823        n <= 100 -> 1002824        n <= 250 -> 2502825        n <= 500 -> 5002826        else -> ((n + 499) / 500) * 5002827    }28282829    /**2830     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2831     * so it is stored explicitly and survives the sync wire format rather than being2832     * re-derived at render time. Non-card entries pass through unchanged.2833     *2834     * M-03: never overwrite a stored last4 with null. A card kept only as last42835     * (e.g. after a PeachScript import that had no PAN) must keep that value;2836     * re-deriving from an absent cardNumber would silently zero the field.2837     */2838    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2839        if (!entry.isCard()) return entry2840        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2841        val derived = if (digits.length >= 4) digits.takeLast(4) else null2842        return entry.copy(last4 = derived ?: entry.last4)2843    }28442845    /**2846     * On unlock, hard-delete trashed entries older than the retention window.2847     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2848     * (the app's "Never" option), so nothing is purged. Persists without a2849     * version bump and without touching the publish state (caller publishes).2850     */2851    private fun autoPurgeExpiredTrash() {2852        val days = _settings.value.trashRetentionDays ?: return2853        if (days <= 0) return2854        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2855        val before = entryList.size2856        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2857        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2858    }28592860    /**2861     * Browser parity (P1): merge orphaned passkey-only entries into matching2862     * login entries that share the same registrable domain. A passkey-only2863     * entry has passkey fields (rpId + credentialId) but no password. Runs on2864     * unlock so consolidation is idempotent and cheap.2865     */2866    private fun consolidatePasskeyEntries() {2867        val passkeyOnly = entryList.filter { entry ->2868            entry.deletedAt == null &&2869                entry.isLogin() &&2870                entry.password.isNullOrBlank() &&2871                !entry.rpId.isNullOrBlank() &&2872                !entry.credentialId.isNullOrBlank()2873        }2874        if (passkeyOnly.isEmpty()) return2875        var changed = false2876        val now = System.currentTimeMillis()2877        for (pkEntry in passkeyOnly) {2878            val rpId = pkEntry.rpId ?: continue2879            val target = entryList.firstOrNull { candidate ->2880                candidate.deletedAt == null &&2881                    candidate.id != pkEntry.id &&2882                    candidate.isLogin() &&2883                    candidate.passkey == null &&2884                    candidate.credentialId.isNullOrBlank() &&2885                    !candidate.password.isNullOrBlank() &&2886                    listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2887                        .any { com.peachpasswords.shared.extractDomain(it) == rpId }2888            } ?: continue2889            val idx = entryList.indexOfFirst { it.id == target.id }2890            if (idx < 0) continue2891            entryList[idx] = target.copy(2892                rpId = pkEntry.rpId,2893                rpName = pkEntry.rpName ?: target.rpName,2894                credentialId = pkEntry.credentialId,2895                publicKeyCose = pkEntry.publicKeyCose,2896                privateKeyJwk = pkEntry.privateKeyJwk,2897                userHandle = pkEntry.userHandle,2898                userName = pkEntry.userName,2899                userDisplayName = pkEntry.userDisplayName,2900                algorithm = pkEntry.algorithm,2901                signCount = pkEntry.signCount,2902                lastUsedAt = pkEntry.lastUsedAt,2903                modifiedAt = now,2904            )2905            // Soft-delete the orphan so it lands in trash (recoverable).2906            val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2907            if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2908            changed = true2909        }2910        if (changed) runCatching { persistSlotA(bumpVersion = false) }2911    }29122913    /** The slot-A plaintext envelope for the current in-memory session. */2914    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }29152916    private fun currentPayloadLocked(): VaultPayload =2917        VaultPayload(2918            entries = entryList.toList(),2919            duressConfigured = authenticatedDuressConfigured,2920            rootKeyB64 = rootKey?.let(::b64) ?: "",2921            recoveryWordHash = recoveryWordHash,2922            recoveryWordsSet = recoveryWordsSet,2923        )29242925    /**2926     * Adopt the payload's root key, lazily minting one for legacy payloads.2927     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2928     * a root key is not a mutation paired devices should sync against.2929     */2930    private fun ensureRootKey(payload: VaultPayload) {2931        if (payload.rootKeyB64.isNotBlank()) {2932            rootKey = unb64(payload.rootKeyB64)2933            return2934        }2935        rootKey = generateRecoverableRootKey()2936        runCatching { persistSlotA(bumpVersion = false) }2937    }29382939    private fun publish() {2940        _entries.value = entryList.filter { it.deletedAt == null }2941    }29422943    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2944        var plaintext = VaultCodec.encode(currentPayload())2945        return try {2946            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2947            StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = blob)2948        } finally {2949            // M-05: full vault plaintext is allocated here once per createVault.2950            // Zeroing in finally keeps the transient copy off the heap after the2951            // caller either succeeds or rolls back via lock().2952            VaultCrypto.zero(plaintext)2953        }2954    }29552956    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2957        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2958        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2959        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2960        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2961        val noise = ByteArray(approxBytes).also(rng::nextBytes)2962        return store.writeSlotB(b64(noise))2963    }29642965    /** H-04: the ciphertext length slot A is using right now, so the duress2966     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2967     *  length equals ciphertext length, so this also matches slot A's plaintext. */2968    private fun matchingCiphertextLength(slotA: StoredVault): Int =2969        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)29702971    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2972     *  bytes. Replaces a real decoy when the user clears duress, and is the2973     *  initial duress slot when no decoy is configured — so the record's2974     *  PRESENCE on disk is independent of authenticatedDuressConfigured, and its SIZE2975     *  matches slot A's blob length. */2976    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2977        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162978        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2979        val fakeSalt = VaultCrypto.generateSalt()2980        return store.writeDecoySlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob)))2981    }29822983    /** H-04: keep the duress record present on every persist. A real decoy is2984     *  left alone (it was padded at setDuressPassphrase time, and we don't have2985     *  the duress key here to re-seal it) — VaultStore re-pads both slot2986     *  records to the longer of the two on every write, which is what keeps the2987     *  pair the same length after slot A crosses a padding bucket2988     *  (PAI-2026-004). No real decoy → a fresh fake of slot A's size. */2989    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2990        val isConfigured = mutateState { authenticatedDuressConfigured }2991        return if (isConfigured) true else writeFakeDuressSlot(slotA)2992    }29932994    /**2995     * H-04: convert a browser-shaped duress slot to Android StoredVault2996     * without decrypting. The browser seals slot "b" under the duress2997     * passphrase; decrypting with the main password silently drops it.2998     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2999     * the salt from hex to base64.3000     */3001    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {3002        val salt = unhex(b.salt)3003        val nonce = unhex(b.nonce)3004        val ct = unhex(b.ciphertext)3005        val tag = unhex(b.tag)3006        val blob = ByteArray(nonce.size + ct.size + tag.size).also {3007            System.arraycopy(nonce, 0, it, 0, nonce.size)3008            System.arraycopy(ct, 0, it, nonce.size, ct.size)3009            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)3010        }3011        return StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = b64(blob))3012    }3013    /** Re-seal slot A from the current in-memory state under the live key. */3014    private fun persistSlotA(3015        bumpVersion: Boolean,3016        snapshotReason: String? = "local_save",3017        expectedEpoch: Long? = null,3018    ): Boolean {3019        val snapshot = snapshotForPersist(expectedEpoch)3020            ?: throw IllegalStateException("vault_locked_or_stale")3021        return try {3022            // Decoy sessions persist to the decoy slot only — never touch the real slot A.3023            if (snapshot.decoy) {3024                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")3025                val plaintext = VaultCodec.encode(snapshot.payload)3026                val blob = try {3027                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_B))3028                } finally {3029                    // Keep the last reference alive until the zero, including the3030                    // successful encrypt path (M-05).3031                    VaultCrypto.zero(plaintext)3032                }3033                beforeDurableCommit()3034                return withEpochCommit(snapshot.epoch) {3035                    store.writeDecoySlot(prior.copy(version = CURRENT_ANDROID_SLOT_VERSION, blob = blob))3036                } ?: false3037            }3038            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")3039            // H-01: captured BEFORE the noise write so a stamp failure can restore3040            // the prior slot B alongside the prior slot A. Slot B is always3041            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure3042            // leaves (new A, new B, old version) on disk — partial commit.3043            val priorB = store.loadSlotB()3044            beforeDurableCommit()3045            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false3046            val plaintext = VaultCodec.encode(snapshot.payload)3047            val blob = try {3048                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))3049            } finally {3050                // Do not drop [plaintext] before fill(0): the serialized vault3051                // contains every entry password and is the primary M-05 target.3052                VaultCrypto.zero(plaintext)3053            }3054            val next = prior.copy(blob = blob)3055            // The epoch check and the entire A/B/D/version commit share one brief3056            // stateLock critical section. lock() either wins before this block or3057            // waits until the already-committed write is complete; it can never3058            // clear the session between the check and the durable write.3059            withEpochCommit(snapshot.epoch) {3060                if (!store.writeSlotA(next)) return@withEpochCommit false3061                try {3062                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")3063                    // H-04: duress slot stays present (real one untouched, fake one3064                    // re-padded to slot A's current size on every persist).3065                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")3066                    if (bumpVersion) store.stampLocalMutation()3067                } catch (e: Exception) {3068                    store.writeSlotA(prior)3069                    if (priorB != null) runCatching { store.writeSlotB(priorB) }3070                    throw e3071                }3072                true3073            } ?: false3074        } finally {3075            // Snapshot keys are copies, so lock() may zero the live key while this3076            // operation is still deriving/encrypting; always scrub our copy too.3077            VaultCrypto.zero(snapshot.key)3078        }3079    }30803081    /**3082     * H-01: durably commit a slot-A rotation by writing slot A, its3083     * size-matched noise slot B, and the local-mutation stamp in order. Any3084     * failure rolls the previously-committed steps back so the at-rest pair3085     * matches what was there before the call. Returns true only when every3086     * step succeeded — callers must NOT swap their session key on false.3087     */3088    private fun commitVaultRotation(3089        priorA: StoredVault,3090        priorB: String?,3091        nextA: StoredVault,3092        expectedEpoch: Long,3093    ): Boolean {3094        beforeDurableCommit()3095        return withEpochCommit(expectedEpoch) {3096            if (!store.writeSlotA(nextA)) return@withEpochCommit false3097            // P1-1 (codex): the noise write's boolean AND any thrown exception from3098            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB3099            // escaped the helper and left slot A committed.3100            try {3101                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")3102                // H-04: a password rotation changes slot A's plaintext size (new3103                // salt → re-encrypted blob), so the duress slot must be re-padded3104                // alongside. writeCompanionDuressSlot leaves a real decoy alone3105                // and re-emits a fake of slot A's size otherwise.3106                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")3107                store.stampLocalMutation()3108            } catch (_: Exception) {3109                reportRotationRollback(slotRollback.restorePair(priorA, priorB))3110                return@withEpochCommit false3111            }3112            true3113        } ?: false3114    }31153116    /**3117     * Best-effort undo of the writes [commitVaultRotation] may have committed.3118     * Each step runs independently — a failed rollback must not mask the3119     * original commit failure. Log is a throwing stub off-device.3120     */3121    private fun reportRotationRollback(failures: List<String>) {3122        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }3123    }31243125    /** Persist a local mutation, rolling the in-memory list back on failure. */3126    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {3127        try {3128            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {3129                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }3130                throw IllegalStateException("storage_write_failed")3131            }3132        } catch (e: Exception) {3133            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }3134            throw e3135        }3136        // A lock can win in the small interval after the durable commit and3137        // before publication. The epoch check prevents pre-lock entries from3138        // being republished into a newly locked (or newly unlocked) session.3139        mutateIfCurrentEpoch(expectedEpoch) { publish() }3140        runCatching { LockedSyncSpoolHooks.refresh(this) }3141        runCatching { VaultMutationHooks.onCommitted() }3142    }3143}