VaultRepository.kt

139.5 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton2526/** Lenient parser for the Codex vault-blob wrapper only. */27private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)3132/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */33private const val PASSWORD_HISTORY_MAX = 5034private const val VAULT_HISTORY_MAX = 53536// How long the transient-background-lock suppression stays valid. If the37// launched system Activity never reports the expected lifecycle stop (picker38// crashed, user backed out into another app, OEM killed the picker process),39// the flag expires and the next genuine background event locks the vault40// rather than consuming a stale suppression.41private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4243sealed interface UnlockResult {44    data object Success : UnlockResult45    data object WrongPassword : UnlockResult46    data class Error(val reason: String) : UnlockResult47}4849sealed interface CreateVaultResult {50    data object Success : CreateVaultResult51    data class Error(val reason: String) : CreateVaultResult52}5354sealed interface AdoptVaultResult {55    data class Success(val vaultId: String) : AdoptVaultResult56    data class Error(val reason: String) : AdoptVaultResult57}5859/**60 * In-memory vault session backed by [VaultStore]. Faithful port of the browser61 * `vault.ts` semantics that matter for correctness:62 *63 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched64 *    noise blob so a fresh install shows two similarly-shaped records.65 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a66 *    deletion always wins last-write-wins against an in-flight edit.67 *  - Local writes bump the version counter; sync-applied writes do NOT (so a68 *    sync round never cascades into another outbound sync).69 *  - add/update/delete roll back in-memory state if the disk write fails.70 *  - destroyVault never touches the Pro license (separate store).71 */72@Singleton73class VaultRepository @Inject constructor(74    private val store: VaultStorage,75) {76    private val mutex = Mutex()77    // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend78    // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync79    // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so80    // lock() can serialize against an in-flight suspend op without blocking on81    // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and82    // take [stateLock] only around the state-write portions. Acquisition order83    // is always mutex → stateLock.84    private val stateLock = ReentrantLock()85    private val rng = SecureRandom()8687    /**88     * Monotonic session generation.  [lock] advances this before clearing any89     * secret state; work that captured the previous generation is therefore90     * stale even if it is still running its (non-suspending) storage step.91     */92    private var sessionEpoch: Long = 0L9394    private fun nextSessionEpoch(epoch: Long): Long =95        if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9697    /** Test seam for deterministic "lock while waiting to commit" races. */98    @VisibleForTesting99    internal var beforeDurableCommit: () -> Unit = {}100101    /** Test seam after a failed Codex rollback, while the epoch fence is held. */102    @VisibleForTesting103    internal var afterCodexRollback: () -> Unit = {}104105    /**106     * Run [block] with [stateLock] held. Used to fence the small state-write107     * portions of suspend operations against [lock] and [requireUnlocked]108     * racing from another thread.109     */110    private inline fun <T> mutateState(block: () -> T): T {111        stateLock.lock()112        return try { block() } finally { stateLock.unlock() }113    }114115    /** Run a durable commit only while the session generation is still live. */116    private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {117        stateLock.lock()118        return try {119            if (sessionEpoch != epoch) null else block()120        } finally {121            stateLock.unlock()122        }123    }124125    /** Test seam for a caller that must stage work before the epoch fence. */126    @VisibleForTesting127    internal var beforeSessionEpochCommit: () -> Unit = {}128129    /**130     * Test seam for the narrower H-03 race: a suspend mutation has authenticated131     * the session and staged its candidate state, but has not yet entered the132     * epoch-fenced in-memory write. Production callers leave this as a no-op.133     */134    @VisibleForTesting135    internal var beforeSessionStateMutation: () -> Unit = {}136137    /**138     * Run an external session-bound commit while the repository's epoch fence is139     * held. The callback is deliberately non-suspending: [lock] cannot advance140     * the epoch until the whole external batch has reached its linearization141     * point.142     */143    internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {144        beforeSessionEpochCommit()145        return withEpochCommit(epoch, block)146    }147148    private fun currentEpoch(): Long = mutateState { sessionEpoch }149150    private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =151        mutateState { if (sessionEpoch == epoch) block() else null }152153    /** Invoke the pre-state-write seam, then linearize only in the same epoch. */154    private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {155        beforeSessionStateMutation()156        return mutateIfCurrentEpoch(epoch, block)157    }158159    private data class PersistSnapshot(160        val epoch: Long,161        val key: ByteArray,162        val payload: VaultPayload,163        val decoy: Boolean,164    )165166    /** Copy all session material while fenced; callers own and must zero [key]. */167    private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {168        if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null169        val liveKey = derivedKey ?: return@mutateState null170        PersistSnapshot(171            epoch = sessionEpoch,172            key = liveKey.copyOf(),173            payload = currentPayloadLocked(),174            decoy = decoyMode,175        )176    }177178    private var derivedKey: ByteArray? = null179    private var rootKey: ByteArray? = null180    private var entryList: MutableList<VaultEntry> = mutableListOf()181    private var duressConfigured: Boolean = false182    private var recoveryWordHash: String? = null183    private var recoveryWordsSet: Boolean = false184    /** True when an authenticated vault payload is available for local reads. */185    private var sessionDataAvailable: Boolean = false186    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */187    private var decoyMode: Boolean = false188189    private val _isUnlocked = MutableStateFlow(false)190    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()191192193    /**194     * UI-only gate for sync-retained background mode. The vault stays unlocked195     * in memory (sync can proceed) but the UI shows the lock screen until the196     * user authenticates on resume. Cleared by a full [lock] or an explicit197     * [clearUiGate] from the lock screen.198     */199    private val _uiGateLocked = MutableStateFlow(false)200    val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()201202    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())203    /** Active (non-deleted) entryList. */204    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()205206    private val _settings = MutableStateFlow(VaultSettings())207    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()208209    /** Clear every in-memory session field while [stateLock] is held. */210    private fun clearSessionStateLocked() {211        derivedKey?.let(VaultCrypto::zero)212        rootKey?.let(VaultCrypto::zero)213        derivedKey = null214        rootKey = null215        entryList = mutableListOf()216        duressConfigured = false217        recoveryWordHash = null218        recoveryWordsSet = false219        sessionDataAvailable = false220        decoyMode = false221        _settings.value = VaultSettings()222        _entries.value = emptyList()223        _isUnlocked.value = false224    }225226    fun hasVault(): Boolean = store.hasVault()227228    // ---- Codex (printed paper backup) -------------------------------------229230    /**231     * The at-rest slot pair as base64 JSON `{"a": <StoredVault>, "b": <StoredVault>}` — the232     * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's233     * A/B naming where slot B is the duress decoy); the noise slot is local-only234     * and never crosses devices. Restorable on any device that has the master235     * password. Null when no vault exists.236     */237    fun codexVaultBlobBase64(): String? {238        val slotA = store.loadSlotA() ?: return null239        // H-04: duress slot is always present after createVault (real or fake),240        // so the wrapper can rely on it being loadable.241        val slotD = store.loadDecoySlot() ?: return null242        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""243        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))244    }245246    /**247     * Replace the local vault from a Codex blob, then unlock with [password].248     * Writes slot A (and a size-matched noise slot B) before unlocking so the249     * restored vault is at rest exactly as a freshly-created one would be.250     */251    /** A slot is stored either as a JSON string (browser) or inline object (Android). */252    private fun JsonElement?.asSlotJson(): String? = when {253        this == null || this is JsonNull -> null254        this is JsonPrimitive && isString -> content255        else -> toString()256    }257258    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {259        val expectedEpoch = currentEpoch()260        var candidateKey: ByteArray? = null261        var plaintext: ByteArray? = null262        return try {263            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)264            // Parsed as JSON, not matched with a regex. The wrapper is265            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which266            // generates most real Codexes — writes each slot as a JSON267            // *string*, with "b" present. A regex looking for an object268            // literal under "a" at the very end of the wrapper matches269            // neither of those, so every browser-generated Codex failed here270            // with "invalid_codex_blob" after decoding perfectly.271            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject272                ?: return UnlockResult.Error("invalid_codex_blob")273            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")274            // A Codex printed from the browser carries a browser-shaped slot,275            // which is the common case — most Codexes are printed there. It is276            // translated to Android's shape (see BrowserSlot) rather than277            // rejected. A translation failure is a wrong password, not a bad278            // blob: the codes decoded fine, the password just didn't open them.279            val slotA = VaultCodec.decodeSlot(slotJson)280                ?: BrowserSlot.parse(slotJson)?.let {281                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword282                }283                ?: return UnlockResult.Error("invalid_codex_blob")284            // C-01: authenticate the candidate ENTIRELY in memory before touching285            // storage. VaultCodec.decodeSlot is structural — an Android-shaped286            // Codex parses under ANY password — so writing slot A first and287            // unlocking afterwards destroyed the device's existing vault every288            // time the user mistyped the password.289            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))290            plaintext = decryptSlotABlob(slotA, candidateKey)291            // LV-1: a Codex printed from a vault sealed before NFC normalization292            // opens only under the raw password bytes. unlockLocked used to supply293            // this fallback; authenticating up front here has to keep it, or those294            // Codexes become unrestorable.295            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {296                VaultCrypto.zero(candidateKey)297                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))298                plaintext = decryptSlotABlob(slotA, candidateKey)299            }300            if (plaintext == null) return UnlockResult.WrongPassword301            val payload = try {302                VaultCodec.decode(plaintext)303            } catch (_: Exception) {304                return UnlockResult.Error("invalid_codex_blob")305            }306307            val priorA = store.loadSlotA()308            val priorB = store.loadSlotB()309            val priorD = store.loadDecoySlot()310            // Carry the real duress slot across when the Codex has one — the311            // browser writes "b" as its duress-shaped slot, and dropping it312            // here would silently destroy a configured decoy.313            // H-04: write browser B into the duress slot (not the noise slot),314            // translating browser-shaped slots to Android's StoredVault shape.315            val codexDuressJson = wrapper["b"].asSlotJson()316            val codexDuress: StoredVault? = codexDuressJson?.let {317                VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->318                    // H-04: pass-through — the browser duress slot is sealed319                    // under the duress passphrase, not the main password. Trying320                    // to decrypt with the slot-A password fails the GCM tag and321                    // silently drops a real decoy. Re-encode the browser hex322                    // fields as Android base64 salt/blob so tryUnlockDuress can323                    // still open it with the duress passphrase.324                    browserDuressPassthrough(b)325                }326            }327328            beforeDurableCommit()329            val committed = runCatching {330                withEpochCommit(expectedEpoch) {331                    try {332                        // Keep every slot write, including the inverse, under the333                        // same epoch fence. Releasing stateLock after a partial334                        // write and reacquiring it for rollback lets lock() advance335                        // the epoch in between and strand the incoming slot A.336                        if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")337                        // Noise slot is always size-matched to slot A — local-only,338                        // never written from a Codex.339                        if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")340                        // H-04: duress slot always present; real one from the Codex or341                        // a fake the size of slot A.342                        val duressOk = if (codexDuress != null) {343                            store.writeDecoySlot(codexDuress)344                        } else {345                            writeFakeDuressSlot(slotA)346                        }347                        if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")348                        store.stampLocalMutation()349                        true350                    } catch (_: Exception) {351                        reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))352                        // Keep the fence held through the final rollback353                        // observation. A concurrent lock() may queue here, but354                        // cannot invalidate the restored A/B/D triad.355                        afterCodexRollback()356                        false357                    }358                } ?: false359            }.getOrDefault(false)360            if (!committed) {361                return UnlockResult.Error("storage_write_failed")362            }363364            val ownedKey = candidateKey!!365            candidateKey = null // the session owns the key from here on366            adoptSlotA(ownedKey, payload, expectedEpoch)367        } catch (e: Exception) {368            // A raw kotlinx parse error ("Unexpected JSON token at offset N")369            // reaching the user says nothing about WHICH json failed — the370            // wrapper, or the payload decrypted out of it. Codex screens are371            // FLAG_SECURE so this can't be screenshotted either. Debug builds372            // log the structure around the failure; alphanumerics are masked so373            // no ciphertext or vault content lands in a log.374            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))375            UnlockResult.Error(e.message ?: "restore_failed")376        } finally {377            plaintext?.let(VaultCrypto::zero)378            candidateKey?.let(VaultCrypto::zero)379        }380    }381382    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */383    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =384        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()385386    /**387     * Undo a partially-committed Codex restore while [stateLock] is already held.388     * Every step is attempted independently so one failure cannot strand the389     * others. Callers that need epoch validation must enter through390     * [withEpochCommit] first; this helper deliberately does not reacquire the391     * lock, avoiding a reentrant gap between a failed write and its inverse.392     * With no prior vault the accurate inverse is having none at all, rather393     * than a half-written slot A that [hasVault] would report as a real vault.394     */395    private fun rollbackRestoreLocked(396        priorA: StoredVault?,397        priorB: String?,398        priorD: StoredVault? = null,399    ): List<String> {400        val failures = mutableListOf<String>()401        if (priorA == null) {402            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }403        } else {404            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }405                .onFailure { failures += "writeSlotA: $it" }406            if (priorB != null) {407                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }408                    .onFailure { failures += "writeSlotB: $it" }409            }410            // H-04: restore the prior duress slot — a half-committed restore that411            // left the duress record on the Codex side and slot A on the prior412            // side would put the at-rest pair in a state that never existed.413            if (priorD != null) {414                runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }415                    .onFailure { failures += "writeDecoySlot: $it" }416            } else {417                runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }418            }419        }420        return failures421    }422423    private fun reportRollbackFailures(failures: List<String>) {424        // Log is a throwing stub off-device, and a failed log must never mask425        // the rollback result the caller is about to report.426        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }427    }428429    /** Debug-only: structure of a failed Codex restore, with content masked. */430    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {431        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)432        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()433        val window = offset?.let {434            val from = (it - 40).coerceAtLeast(0)435            val to = (it + 40).coerceAtMost(wrapperJson.length)436            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")437        }438        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()439        val slot = wrapper?.get("a")440        buildString {441            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)442            append(" | blobBase64=").append(vaultBlobBase64.length)443            append(" wrapperChars=").append(wrapperJson.length)444            append(" wrapperParses=").append(wrapper != null)445            append(" keys=").append(wrapper?.keys)446            append(" slotAKind=").append(447                when {448                    slot == null -> "absent"449                    slot is JsonPrimitive && slot.isString -> "string"450                    slot is JsonObject -> "object:" + slot.keys451                    else -> slot::class.simpleName452                },453            )454            if (window != null) append(" masked@").append(offset).append("=").append(window)455        }456    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }457458    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {459        val expectedEpoch = currentEpoch()460        var key: ByteArray? = null461        return try {462            val salt = VaultCrypto.generateSalt()463            key = VaultCrypto.deriveKey(password, salt)464            if (mutateIfCurrentEpoch(expectedEpoch) {465                entryList = mutableListOf()466                duressConfigured = false467                recoveryWordHash = null468                recoveryWordsSet = false469                decoyMode = false470                sessionDataAvailable = false471                rootKey = generateRecoverableRootKey()472            } == null) return CreateVaultResult.Error("stale_create")473            // M-04: the fields above have to be live before sealSlotA, since it474            // seals currentPayload(). That stages a root key the session would475            // otherwise keep after a failed create — [lock] discards the whole476            // staged session on every non-success exit below.477            val slotA = sealSlotA(key!!, salt)478            // P1-3 (codex): every later step must roll the slot pair back if it479            // fails — leaving a half-built vault is worse than refusing to make it.480            beforeDurableCommit()481            val created = withEpochCommit(expectedEpoch) {482                if (!store.writeSlotA(slotA)) return@withEpochCommit false483                try {484                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")485                    // H-04: duress slot must be present from the start so its486                    // existence on disk is independent of duressConfigured.487                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")488                    store.stampLocalMutation()489                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")490                    true491                } catch (_: Exception) {492                    // Half-built vault on disk is worse than no vault — refuse to493                    // create if any step past slot A fails.494                    store.clearVault()495                    false496                }497            } ?: false498            if (!created) {499                lock()500                return CreateVaultResult.Error("storage_write_failed")501            }502            val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")503            if (mutateIfCurrentEpoch(expectedEpoch) {504                derivedKey = sessionKey505                key = null506                publish()507                _isUnlocked.value = true508                sessionDataAvailable = true509                sessionEpoch = nextSessionEpoch(sessionEpoch)510            } == null) return CreateVaultResult.Error("stale_create")511            CreateVaultResult.Success512        } catch (e: Exception) {513            lock()514            CreateVaultResult.Error(e.message ?: "create_failed")515        } finally {516            key?.let(VaultCrypto::zero)517        }518    }519520    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {521        val expectedEpoch = currentEpoch()522        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")523        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")524        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {525            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")526        }527        var adoptedRootKey: ByteArray? = null528        var nextDerivedKey: ByteArray? = null529        var sessionRootKey: ByteArray? = null530        return try {531            adoptedRootKey = hexToRootKey(rootKeyHex)532            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {533                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")534            }535            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)536            val salt = VaultCrypto.generateSalt()537            nextDerivedKey = VaultCrypto.deriveKey(password, salt)538            sessionRootKey = adoptedRootKey.copyOf()539            val payload = VaultPayload(540                entries = emptyList(),541                duressConfigured = false,542                rootKeyB64 = b64(sessionRootKey),543                recoveryWordHash = null,544                recoveryWordsSet = false,545            )546            val plaintext = VaultCodec.encode(payload)547            val blob = try {548                b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))549            } finally {550                VaultCrypto.zero(plaintext)551            }552            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)553            // P1-3 (codex): same partial-commit guard as createVault — any failure554            // past slot A wipes the half-built vault and refuses adoption.555            beforeDurableCommit()556            val created = withEpochCommit(expectedEpoch) {557                if (!store.writeSlotA(slotA)) return@withEpochCommit false558                try {559                    if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")560                    // H-04: duress slot must be present from the start so its561                    // existence on disk is independent of duressConfigured.562                    if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")563                    store.stampLocalMutation()564                    if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")565                    true566                } catch (_: Exception) {567                    store.clearVault()568                    false569                }570            } ?: false571            if (!created) return AdoptVaultResult.Error("storage_write_failed")572573            val adopted = mutateIfCurrentEpoch(expectedEpoch) {574                val previousDerivedKey = derivedKey575                val previousRootKey = rootKey576                derivedKey = nextDerivedKey577                rootKey = sessionRootKey578                nextDerivedKey = null579                sessionRootKey = null580                entryList = mutableListOf()581                duressConfigured = false582                recoveryWordHash = null583                recoveryWordsSet = false584                decoyMode = false585                sessionDataAvailable = true586                publish()587                _isUnlocked.value = true588                sessionEpoch = nextSessionEpoch(sessionEpoch)589                previousDerivedKey to previousRootKey590            }591            if (adopted == null) return AdoptVaultResult.Error("stale_adoption")592            val (previousDerivedKey, previousRootKey) = adopted593            previousDerivedKey?.let(VaultCrypto::zero)594            previousRootKey?.let(VaultCrypto::zero)595            // Write the recovery escrow so the forgot-password flow works on596            // this device. The words are deterministic from the root key and597            // were already confirmed on the source device.598            runCatching {599                val words = rootKeyToWords(rootKey!!)600                val escrow = buildRecoveryEscrow(words)601                val rkEscrow = buildRootKeyEscrow(words)602                if (escrow != null || rkEscrow != null) {603                    val prior = store.loadSlotA()604                    if (prior != null) {605                        store.writeSlotA(prior.copy(606                            recoveryEscrow = escrow ?: prior.recoveryEscrow,607                            rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,608                        ))609                    }610                    mutateState {611                        recoveryWordHash = recoveryWordsHash(words)612                        recoveryWordsSet = true613                    }614                }615            }616            AdoptVaultResult.Success(vaultId)617        } catch (e: Exception) {618            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")619        } finally {620            adoptedRootKey?.let(VaultCrypto::zero)621            nextDerivedKey?.let(VaultCrypto::zero)622            sessionRootKey?.let(VaultCrypto::zero)623        }624    }625626    suspend fun unlock(password: String): UnlockResult = mutex.withLock {627        val result = unlockLocked(password, currentEpoch())628        if (result is UnlockResult.Success) ensureRecoveryEscrow()629        result630    }631632    /** Unlock body without acquiring the mutex — callers must already hold it. */633    private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {634        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")635        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))636        val payload = try {637            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)638            try {639                VaultCodec.decode(plaintext)640            } finally {641                VaultCrypto.zero(plaintext)642            }643        } catch (_: Exception) {644            VaultCrypto.zero(key)645            // LV-1: a vault sealed before NFC normalization with a non-NFC646            // password derives a different key now. If the supplied password is647            // not already in NFC form, retry with the raw (un-normalized) bytes.648            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {649                val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)650                if (legacy != null) return legacy651            }652            return UnlockResult.WrongPassword653        }654        return adoptSlotA(key, payload, expectedEpoch)655    }656657    /**658     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw659     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed660     * under the NFC-normalized key — so every future unlock takes the normal661     * path. Returns null when the raw key also fails (genuinely wrong password)662     * OR when any step of the heal commits fails (the original slot A is restored663     * so disk and session stay aligned).664     */665    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {666        var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))667        var newKey: ByteArray? = null668        return try {669            val payload = try {670                val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)671                try {672                    VaultCodec.decode(plaintext)673                } finally {674                    VaultCrypto.zero(plaintext)675                }676            } catch (_: Exception) {677                return null678            }679            // Heal: re-seal slot A under the NFC key (mirrors changePassword's680            // re-seal). Escrows carry forward unchanged — only the wrapping rotates.681            val newSalt = VaultCrypto.generateSalt()682            newKey = VaultCrypto.deriveKey(password, newSalt)683            val plaintext = VaultCodec.encode(payload)684            val blob = try {685                b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))686            } finally {687                VaultCrypto.zero(plaintext)688            }689            val next = StoredVault(690                version = slotA.version,691                salt = b64(newSalt),692                blob = blob,693                recoveryEscrow = slotA.recoveryEscrow,694                rootKeyEscrow = slotA.rootKeyEscrow,695            )696            // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,697            // or stampLocalMutation must restore the prior slot A AND slot B so disk698            // and the eventual session key never disagree on the wrapping.699            val priorB = store.loadSlotB()700            beforeDurableCommit()701            val healed = withEpochCommit(expectedEpoch) {702                if (!store.writeSlotA(next)) {703                    false704                } else try {705                    if (!writeMatchingNoiseSlotB(next)) {706                        runCatching { store.writeSlotA(slotA) }707                        if (priorB != null) runCatching { store.writeSlotB(priorB) }708                        false709                    } else if (!writeCompanionDuressSlot(next)) {710                        runCatching { store.writeSlotA(slotA) }711                        if (priorB != null) runCatching { store.writeSlotB(priorB) }712                        false713                    } else {714                        store.stampLocalMutation()715                        true716                    }717                } catch (_: Exception) {718                    runCatching { store.writeSlotA(slotA) }719                    if (priorB != null) runCatching { store.writeSlotB(priorB) }720                    false721                }722            } ?: false723            if (healed) {724                VaultCrypto.zero(rawKey!!)725                rawKey = null726                val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot727                newKey = null728                result729            } else {730                VaultCrypto.zero(newKey!!)731                newKey = null732                val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key733                rawKey = null734                result735            }736        } finally {737            // Cover codec, encryption, storage, and stale-epoch failures before738            // either candidate key can be transferred into the live session.739            rawKey?.let(VaultCrypto::zero)740            newKey?.let(VaultCrypto::zero)741        }742    }743744    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */745    private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {746        return try {747            // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they748            // can throw after the fields above are live. There is no prior session749            // worth restoring — adoption has already replaced it — so a failed750            // adoption discards everything rather than leaving entries and keys751            // resident behind _isUnlocked=false.752            val adopted = mutateIfCurrentEpoch(expectedEpoch) {753                derivedKey = key754                decoyMode = false755                entryList = payload.entries.toMutableList()756                duressConfigured = payload.duressConfigured757                recoveryWordHash = payload.recoveryWordHash758                recoveryWordsSet = payload.recoveryWordsSet759                sessionDataAvailable = true760                _settings.value = store.loadSettings()761                ensureRootKey(payload)762                autoPurgeExpiredTrash()763                consolidatePasskeyEntries()764                publish()765                _isUnlocked.value = true766                // A successful adoption starts a new generation. A sibling767                // SlotAUnlock captured in the prior epoch will fail closed at768                // the next commit instead of replacing this session.769                sessionEpoch = nextSessionEpoch(sessionEpoch)770            }771            if (adopted == null) {772                VaultCrypto.zero(key)773                UnlockResult.Error("stale_unlock")774            } else {775                UnlockResult.Success776            }777        } catch (e: Exception) {778            // Do not call lock() here: a concurrent newer session may already779            // own this repository. Only invalidate the failed epoch's fields.780            mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }781            VaultCrypto.zero(key)782            UnlockResult.Error(e.message ?: "unlock_failed")783        }784    }785786    // ---- Constant-time unlock (MF-5) --------------------------------------787788    /**789     * A slot-A unlock that has been derived but not yet adopted. Holds the live790     * Argon2id key and decoded payload so the caller can derive several candidates791     * concurrently and commit only the winner. The key is owned by this token until792     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.793     */794    class SlotAUnlock internal constructor(795        key: ByteArray,796        internal val payload: VaultPayload,797        internal val epoch: Long,798    ) {799        private var ownedKey: ByteArray? = key800        private var consumed = false801802        /** Transfer the key exactly once to the adopter. */803        internal fun takeKey(): ByteArray? = synchronized(this) {804            if (consumed) return@synchronized null805            consumed = true806            ownedKey.also { ownedKey = null }807        }808809        /** Discard an uncommitted token without touching a transferred key. */810        internal fun discard() = synchronized(this) {811            if (consumed) return@synchronized812            consumed = true813            ownedKey?.let(VaultCrypto::zero)814            ownedKey = null815        }816    }817818    /**819     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.820     * Pure computation (no mutex), so it can run concurrently with other candidate821     * derivations before the caller decides which to commit (MF-5). Returns null on822     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).823     */824    fun tryUnlockSlotA(password: String): SlotAUnlock? {825        val epoch = currentEpoch()826        val slotA = store.loadSlotA() ?: return null827        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))828        return try {829            val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)830            val payload = try {831                VaultCodec.decode(plaintext)832            } finally {833                VaultCrypto.zero(plaintext)834            }835            SlotAUnlock(key, payload, epoch)836        } catch (_: Exception) {837            VaultCrypto.zero(key)838            null839        }840    }841842    /** Adopt a previously-derived [SlotAUnlock] as the live session. */843    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {844        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")845        adoptSlotA(key, unlock.payload, unlock.epoch)846    }847848    /** Zero a derived [SlotAUnlock] that will not be adopted. */849    fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()850851    // ---- Duress slot B (MF-6) ---------------------------------------------852853    /**854     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key855     * and decoded decoy payload so it can be derived concurrently with slot A and856     * only committed if the duress passphrase was the one entered (MF-5/MF-6).857     */858    class DuressUnlock internal constructor(859        key: ByteArray,860        internal val payload: VaultPayload,861        internal val epoch: Long,862    ) {863        private var ownedKey: ByteArray? = key864        private var consumed = false865866        internal fun takeKey(): ByteArray? = synchronized(this) {867            if (consumed) return@synchronized null868            consumed = true869            ownedKey.also { ownedKey = null }870        }871872        internal fun discard() = synchronized(this) {873            if (consumed) return@synchronized874            consumed = true875            ownedKey?.let(VaultCrypto::zero)876            ownedKey = null877        }878    }879880    /**881     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the882     * session. Success means the entered password is the duress passphrase that keys883     * slot B — indistinguishable from the real slot A at the lock screen. Pure884     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].885     * Returns null when no duress slot exists or the password is not the duress one.886     */887    fun tryUnlockDuress(password: String): DuressUnlock? {888        val epoch = currentEpoch()889        val slot = store.loadDecoySlot() ?: return null890        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))891        return try {892            val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)893            val payload = try {894                VaultCodec.decode(plaintext)895            } finally {896                VaultCrypto.zero(plaintext)897            }898            DuressUnlock(key, payload, epoch)899        } catch (_: Exception) {900            VaultCrypto.zero(key)901            null902        }903    }904905    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */906    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {907        val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")908        if (currentEpoch() != unlock.epoch) {909            VaultCrypto.zero(key)910            return@withLock UnlockResult.Error("stale_unlock")911        }912        return try {913            val adopted = mutateIfCurrentEpoch(unlock.epoch) {914                derivedKey = key915                decoyMode = true916                entryList = unlock.payload.entries.toMutableList()917                duressConfigured = false918                recoveryWordHash = unlock.payload.recoveryWordHash919                recoveryWordsSet = unlock.payload.recoveryWordsSet920                sessionDataAvailable = true921                rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)922                          else VaultCrypto.generateRootKey()923                _settings.value = store.loadSettings()924                publish()925                _isUnlocked.value = true926                sessionEpoch = nextSessionEpoch(sessionEpoch)927            }928            if (adopted == null) {929                VaultCrypto.zero(key)930                UnlockResult.Error("stale_unlock")931            } else {932                UnlockResult.Success933            }934        } catch (e: Exception) {935            // A payload/root/settings failure can occur after the candidate key936            // has been assigned to derivedKey. Tear down only this epoch so a937            // newer session is not disturbed, and scrub the token reference on938            // every error path.939            mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }940            VaultCrypto.zero(key)941            UnlockResult.Error(e.message ?: "decoy_unlock_failed")942        }943    }944945    /** Zero a derived [DuressUnlock] that will not be adopted. */946    fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()947948    /**949     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an950     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The951     * passphrase is never stored — only its ability to decrypt slot B proves it.952     * Requires the real vault to be unlocked and not itself a decoy session.953     */954    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {955        // H-03: read derivedKey/decoyMode atomically with any concurrent lock().956        val expectedEpoch = mutateState {957            if (derivedKey != null && !decoyMode) sessionEpoch else null958        } ?: return false959        // Reject a duress passphrase that also opens the real vault: it would make the960        // lock screen ambiguous (both slots would decrypt for the same input).961        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }962        var key: ByteArray? = null963        var basePlaintext: ByteArray? = null964        return try {965            val salt = VaultCrypto.generateSalt()966            key = VaultCrypto.deriveKey(duressPassphrase, salt)967            // An empty decoy vault with its own fresh sync root key — nothing real leaks.968            val decoyRoot = VaultCrypto.generateRootKey()969            val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }970            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)971            basePlaintext = VaultCodec.encode(decoyPayload)972            // H-04: pad the duress plaintext to slot A's ciphertext length so the973            // duress blob matches slot A's blob on disk. VaultCodec.decode strips974            // trailing NULs, so the extra padding survives encrypt but never975            // reaches the JSON parser.976            // ponytail: only re-pads at configuration time — if slot A grows past977            // its initial bucket (entries cross 4096/16384/etc.), the real decoy978            // stays at the smaller bucket. Re-padding on every persist requires979            // keeping the duress passphrase in memory, which we don't.980            val slotA = store.loadSlotA()981            val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0982            val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!983            val blob = try {984                b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))985            } finally {986                VaultCrypto.zero(plaintext)987                // Padding creates a second array; scrub the original encoded988                // payload too rather than dropping its last reference untouched.989                if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)990                basePlaintext = null991                key?.let(VaultCrypto::zero)992                key = null993            }994            beforeDurableCommit()995            val ok = withEpochCommit(expectedEpoch) {996                store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))997            } ?: false998            if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {999                runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1000            } else if (ok) {1001                return false1002            }1003            ok1004        } catch (_: Exception) {1005            false1006        } finally {1007            // Cover failures before the encrypt block is entered (for example a1008            // codec/copy exception) as well as any early return after key derivation.1009            key?.let(VaultCrypto::zero)1010            basePlaintext?.let(VaultCrypto::zero)1011        }1012    }101310141015    /**1016     * Export the decoy slot as browser-shaped JSON for adoption snapshots.1017     *1018     * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all1019     * lowercase hex — identical to the browser's `duress_slot` wire format — so1020     * any receiver (browser or Android) can install it via the existing1021     * passthrough. Returns null when duress is not configured, no decoy exists,1022     * or any conversion failure occurs. Never decrypts.1023     */1024    suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1025        if (!duressConfigured) return null1026        val slot = store.loadDecoySlot() ?: return null1027        return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1028    }10291030    /**1031     * Convert an Android [StoredVault] decoy slot to browser-shaped JSON.1032     *1033     * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are1034     * split into lowercase hex fields matching [BrowserSlotA]. Intermediate1035     * byte arrays are zeroed in finally.1036     */1037    private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1038        // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS1039        // serialized — kotlinx-serialization's encodeDefaults = false would1040        // otherwise omit BrowserSlotA.version (its Kotlin default), and the1041        // browser's hasValidVaultSchema strictly requires `r.version === 1`,1042        // silently dropping the duress slot on Android→browser adoptions.1043        var salt: ByteArray? = null1044        var blob: ByteArray? = null1045        var nonce: ByteArray? = null1046        var ctAndTag: ByteArray? = null1047        var ct: ByteArray? = null1048        var tag: ByteArray? = null1049        try {1050            salt = unb64(slot.salt)1051            blob = unb64(slot.blob)1052            nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1053            ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1054            ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1055            tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1056            val obj = JsonObject(mapOf(1057                "version" to JsonPrimitive(1),1058                "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1059                "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1060                "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1061                "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1062            ))1063            return obj.toString()1064        } finally {1065            salt?.let(VaultCrypto::zero)1066            blob?.let(VaultCrypto::zero)1067            nonce?.let(VaultCrypto::zero)1068            ctAndTag?.let(VaultCrypto::zero)1069            ct?.let(VaultCrypto::zero)1070            tag?.let(VaultCrypto::zero)1071        }1072    }10731074    /**1075     * Install a browser-shaped duress slot received through adoption.1076     *1077     * Parses [json] as a [BrowserSlotA], re-encodes it to Android's1078     * [StoredVault] via [browserDuressPassthrough] (no decryption), and1079     * writes the result to the decoy slot. Epoch-fenced like the Codex1080     * restore path so a concurrent lock() cannot strand the write.1081     *1082     * Returns false on invalid input, no live session, or write failure.1083     */1084    suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1085        val b = BrowserSlot.parse(json) ?: return false1086        var stored: StoredVault? = null1087        var salt: ByteArray? = null1088        var nonce: ByteArray? = null1089        var ct: ByteArray? = null1090        var tag: ByteArray? = null1091        var blob: ByteArray? = null1092        try {1093            salt = unhex(b.salt)1094            nonce = unhex(b.nonce)1095            ct = unhex(b.ciphertext)1096            tag = unhex(b.tag)1097            blob = ByteArray(nonce.size + ct.size + tag.size).also {1098                System.arraycopy(nonce, 0, it, 0, nonce.size)1099                System.arraycopy(ct, 0, it, nonce.size, ct.size)1100                System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1101            }1102            stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1103            val expectedEpoch = sessionEpoch1104            beforeDurableCommit()1105            withEpochCommit(expectedEpoch) {1106                store.writeDecoySlot(stored)1107            } ?: false1108        } catch (_: Exception) {1109            false1110        } finally {1111            salt?.let(VaultCrypto::zero)1112            nonce?.let(VaultCrypto::zero)1113            ct?.let(VaultCrypto::zero)1114            tag?.let(VaultCrypto::zero)1115            blob?.let(VaultCrypto::zero)1116        }1117    }11181119    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */1120    suspend fun clearDuress(): Boolean = mutex.withLock {1121        // H-04: presence-indistinguishable — replace the real decoy with a fake1122        // blob of slot A's size rather than removing the duress record. The1123        // record stays on disk at the same length, so an at-rest observer1124        // cannot tell "duress cleared" from "no duress ever configured".1125        val expectedEpoch = currentEpoch()1126        val replaced = withEpochCommit(expectedEpoch) {1127            store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1128                ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1129        } ?: false1130        if (!replaced) return@withLock false1131        // H-03: stateLock-fence the derivedKey/decoyMode read AND the1132        // duressConfigured write so a concurrent lock() cannot land between them.1133        if (!isSessionEpochCurrent(expectedEpoch)) return false1134        val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1135        if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1136            runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1137        }1138        true1139    }11401141    /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */1142    private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1143        var current: Throwable? = error1144        while (current != null) {1145            if (current is javax.crypto.AEADBadTagException) return true1146            // Some Android providers expose a GCM tag failure as the parent1147            // BadPaddingException rather than the AEAD-specific subtype.  A1148            // short ciphertext is an IllegalBlockSizeException and therefore1149            // remains an internal/malformed-payload Error below.1150            if (current is javax.crypto.BadPaddingException) return true1151            current = current.cause1152        }1153        return false1154    }11551156    /** Unlock with an already-derived AES-256 key (biometric path). */1157    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1158        val expectedEpoch = currentEpoch()1159        val slotA = try {1160            store.loadSlotA()1161        } catch (_: Exception) {1162            return@withLock UnlockResult.Error("unlock_key_storage_failed")1163        } ?: return@withLock UnlockResult.Error("no_vault")1164        // Keep the caller-owned key separate from the staged session key. The1165        // biometric caller always zeroes its input in a finally block; adoption1166        // must therefore own a copy and use the same all-or-nothing path as a1167        // password unlock rather than publishing a partially decoded session.1168        var stagedKey: ByteArray? = key.copyOf()1169        return try {1170            val encrypted = try {1171                unb64(slotA.blob)1172            } catch (_: Exception) {1173                return@withLock UnlockResult.Error("invalid_vault_blob")1174            }1175            // A GCM record must include nonce + authentication tag.  Providers1176            // differ on whether a shorter record is reported as BadPadding or1177            // AEADBadTag; reject it before decrypt so malformed storage cannot be1178            // misreported as a user-entered wrong key.1179            if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1180                return@withLock UnlockResult.Error("invalid_vault_blob")1181            }1182            val plaintext = try {1183                VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1184            } catch (e: Exception) {1185                if (isGcmAuthenticationFailure(e)) {1186                    return@withLock UnlockResult.WrongPassword1187                }1188                return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1189            }1190            val payload = try {1191                VaultCodec.decode(plaintext)1192            } catch (_: Exception) {1193                return@withLock UnlockResult.Error("invalid_vault_payload")1194            } finally {1195                VaultCrypto.zero(plaintext)1196            }1197            val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1198            val result = adoptSlotA(candidate, payload, expectedEpoch)1199            if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1200            result1201        } finally {1202            stagedKey?.let(VaultCrypto::zero)1203        }1204    }12051206    fun lock() {1207        // H-03: must observe derivedKey/entryList atomically with any in-flight1208        // suspend op (an addEntry mid-encrypt, an unlock adopting the payload,1209        // etc.). mutex cannot be acquired from this non-suspend caller; stateLock1210        // guards just the brief state reset. Advancing the epoch first invalidates1211        // every operation that captured this session before it reaches a later1212        // durable commit or publication.1213        stateLock.lock()1214        try {1215            sessionEpoch = nextSessionEpoch(sessionEpoch)1216            clearSessionStateLocked()1217            _uiGateLocked.value = false1218        } finally {1219            stateLock.unlock()1220        }1221    }12221223    /**1224     * Enter sync-retained background mode: the UI shows the lock screen but the1225     * vault stays fully unlocked in memory so [PeachSyncService] can complete1226     * sync sessions. Called from [PeachApplication.onStop] when background sync1227     * is enabled. A subsequent full [lock] (auto-lock timer, manual lock)1228     * supersedes this and zeroes everything.1229     */1230    fun lockUiGate() {1231        if (_isUnlocked.value) _uiGateLocked.value = true1232    }12331234    /** Dismiss the UI gate after the user authenticates on resume. */1235    fun clearUiGate() {1236        _uiGateLocked.value = false1237    }12381239    @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12401241    // Test seam so the expiry branch is reachable from plain JUnit (SystemClock1242    // is a stub off-device).1243    @VisibleForTesting1244    internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12451246    /**1247     * Call immediately before launching a system Activity (a SAF file1248     * picker/save dialog, a share sheet) that briefly takes the whole process1249     * out of the foreground as an expected part of an in-app flow — not a1250     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the1251     * two apart on its own (it fires for both), and without this, exporting or1252     * importing a file silently locked and wiped the vault mid-flow the moment1253     * the picker took over, before its result ever reached the app.1254     *1255     * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]1256     * (M-07); a never-consumed stale flag cannot suppress a later real lock.1257     */1258    fun expectTransientBackground() {1259        suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1260    }12611262    /**1263     * Consumed once by the next background-lock check; see [expectTransientBackground].1264     * Returns false (and clears the flag) if the suppression has expired or was1265     * never set — a genuine background event must always lock.1266     */1267    fun consumeTransientBackgroundSuppression(): Boolean {1268        val until = suppressNextBackgroundLockUntil1269        val active = until != 0L && nowMs() < until1270        suppressNextBackgroundLockUntil = 0L1271        return active1272    }12731274    // ---- Sync identity ----------------------------------------------------12751276    /**1277     * The vault's stable sync master key (a copy). Caller must zero it. Backs1278     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry1279     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].1280     */1281    /**1282     * HOLE 5 — the duress vault never syncs.1283     *1284     * [commitDuressUnlock] gives the decoy session a root key so its own codex1285     * and recovery paths work, which means (unlike the browser, where the decoy1286     * path simply has no root key) the sync surface has to refuse explicitly.1287     * Without this the decoy vault would present a second, different vault_id1288     * to a paired device, and a coerced user opening it would make sync visibly1289     * fail on their other devices — a duress indicator, which is the one thing1290     * a duress vault must never be.1291     */1292    // M-04: the "null when locked" half of the contract was documented but never1293    // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see1294    // [unlockWithRecoveryWords], which installs a root key with no password key1295    // yet) and in a create that failed after staging. Sync must fail closed1296    // there, so the authoritative unlocked flag gates it, not just decoyMode.1297    internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12981299    internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1300        if (decoyMode || !_isUnlocked.value) null1301        else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1302    }13031304    internal fun isSessionEpochCurrent(epoch: Long): Boolean =1305        mutateState { sessionEpoch == epoch }13061307    internal fun unlockedSessionEpoch(): Long? = mutateState {1308        if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1309    }13101311    fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13121313    /**1314     * The current session's root key, decoy included. For local-only uses that1315     * must behave identically in a decoy session — Codex backup and recovery1316     * words — because a feature that errors under duress is itself a duress1317     * tell. In a decoy session this is the DECOY's root key (see1318     * [commitDuressUnlock]), never the real vault's, so neither surface can1319     * leak the real vault. Anything that leaves the device must use1320     * [syncMasterKey] instead.1321     */1322    fun sessionRootKey(): ByteArray? = mutateState {1323        if (!sessionDataAvailable) null else rootKey?.copyOf()1324    }13251326    /** Stable per-vault id two paired devices must share to sync. Null when1327     *  locked, and never present for the decoy vault (see [syncMasterKey]). */1328    fun syncVaultId(): String? = mutateState {1329        if (decoyMode || !_isUnlocked.value) null1330        else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1331    }13321333    /**1334     * Browser parity (MV-8): rotate the sync identity by replacing the stable1335     * root key. Entries remain under the master-password key, while sync1336     * credentials, vault_id, and recovery words all change.1337     */1338    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1339        val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1340            ?: return false1341        installRootKey(generateRecoverableRootKey(), expectedEpoch)1342    }13431344    /**1345     * Join the vault behind [rootKeyHex]: adopt that vault's identity while1346     * keeping every local entry.1347     *1348     * Sync identity (vault id, signing keys) derives from the root key, and1349     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently1350     * created vaults can never reconcile. Taking the other side's root key is1351     * what makes them one vault; from the next sync on, both entry sets merge.1352     *1353     * Entries survive because they are sealed under the password-derived key,1354     * not the root key — the same property [rotateSyncIdentity] relies on.1355     *1356     * CONSEQUENCE, which the caller must have disclosed and had confirmed:1357     * this device's previous recovery words stop working for good, and its new1358     * words are the source vault's words, because words are a deterministic1359     * encoding of the root key.1360     */1361    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1362        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1363            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1364        }1365        val expectedEpoch = mutateState {1366            when {1367                derivedKey == null -> null1368                decoyMode -> Long.MIN_VALUE1369                else -> sessionEpoch1370            }1371        }1372        if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1373        if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1374        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13751376        val incoming = hexToRootKey(rootKeyHex)1377        if (incoming.size != VaultCrypto.KEY_SIZE) {1378            VaultCrypto.zero(incoming)1379            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1380        }1381        // Refuse before committing rather than leave this device holding a vault1382        // whose recovery words cannot be typed back. generateRecoverableRootKey1383        // screens its own keys for this; a key arriving from a peer has not been.1384        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1385        if (!roundTrips) {1386            VaultCrypto.zero(incoming)1387            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1388        }13891390        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1391        return if (installRootKey(incoming, expectedEpoch)) {1392            AdoptVaultResult.Success(vaultId)1393        } else {1394            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1395        }1396    }13971398    /**1399     * The recovery words [rootKeyHex] will produce once joined, or null if it is1400     * not a usable root key. Lets the join flow show the user the words that are1401     * about to replace theirs *before* anything is committed.1402     */1403    fun previewRecoveryWords(rootKeyHex: String): List<String>? {1404        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1405        val bytes = hexToRootKey(rootKeyHex)1406        return try {1407            val words = rootKeyToWords(bytes)1408            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1409        } catch (_: Exception) {1410            null1411        } finally {1412            VaultCrypto.zero(bytes)1413        }1414    }14151416    /**1417     * Swap the vault's root key, keeping every entry, and re-escrow under the1418     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds1419     * [mutex].1420     */1421    private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1422        // H-03: snapshot the live fields under stateLock so the success and the1423        // rollback paths both see consistent prior/new state. Writing the new1424        // rootKey/recovery fields is inside its own mutateState at the end.1425        // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays1426        // — holding live references would let a zero-on-failure operation also1427        // wipe the session's still-live key arrays.1428        data class Snapshot(1429            val key: ByteArray,1430            val priorRootKey: ByteArray,1431            val priorRecoveryHash: String?,1432            val priorRecoverySet: Boolean,1433        )1434        val snap = mutateIfCurrentEpoch(expectedEpoch) {1435            val k = derivedKey ?: return@mutateIfCurrentEpoch null1436            if (decoyMode) return@mutateIfCurrentEpoch null1437            val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1438            Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1439        } ?: run {1440            VaultCrypto.zero(nextRootKey)1441            return false1442        }1443        val priorSlot = store.loadSlotA() ?: run {1444            VaultCrypto.zero(snap.key)1445            VaultCrypto.zero(snap.priorRootKey)1446            VaultCrypto.zero(nextRootKey)1447            return false1448        }1449        val nextWords = rootKeyToWords(nextRootKey)14501451        return try {1452            beforeDurableCommit()1453            if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1454            if (mutateIfCurrentEpoch(expectedEpoch) {1455                rootKey = nextRootKey1456                recoveryWordHash = recoveryWordsHash(nextWords)1457                recoveryWordsSet = false1458            } == null) throw IllegalStateException("stale_root_rotation")14591460            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1461                ?: throw IllegalStateException("recovery_escrow_failed")1462            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1463                ?: throw IllegalStateException("root_escrow_failed")1464            val plaintext = VaultCodec.encode(currentPayload())1465            val blob = try {1466                b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1467            } finally {1468                VaultCrypto.zero(plaintext)1469            }1470            val nextSlot = priorSlot.copy(1471                blob = blob,1472                recoveryEscrow = nextRecoveryEscrow,1473                rootKeyEscrow = nextRootKeyEscrow,1474            )1475            val committed = withEpochCommit(expectedEpoch) {1476                if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1477                try {1478                    if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1479                    // H-04: a root-key rotation re-seals slot A, so the duress slot1480                    // (real or fake) must be re-padded to the new ciphertext length.1481                    if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1482                    store.stampLocalMutation()1483                } catch (_: Exception) {1484                    store.writeSlotA(priorSlot)1485                    return@withEpochCommit false1486                }1487                true1488            } ?: false1489            if (!committed) throw IllegalStateException("storage_write_failed")1490            mutateIfCurrentEpoch(expectedEpoch) { publish() }1491            true1492        } catch (_: Exception) {1493            val restored = mutateIfCurrentEpoch(expectedEpoch) {1494                rootKey?.let(VaultCrypto::zero)1495                rootKey = snap.priorRootKey.copyOf()1496                recoveryWordHash = snap.priorRecoveryHash1497                recoveryWordsSet = snap.priorRecoverySet1498            }1499            if (restored != null) {1500                withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1501            }1502            false1503        } finally {1504            VaultCrypto.zero(snap.key)1505            VaultCrypto.zero(snap.priorRootKey)1506            // On success nextRootKey is owned by rootKey. On every other path1507            // it is not, including a stale epoch after lock(); scrub it while1508            // the local reference is still available.1509            val stillOwnedBySession = mutateState {1510                sessionEpoch == expectedEpoch && rootKey === nextRootKey1511            }1512            if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1513        }1514    }15151516    /**1517     * A copy of the live Argon2id-derived vault key, for biometric escrow only1518     * (wrapped under a hardware KeyStore key on biometric enable). Null when1519     * locked. Caller must [VaultCrypto.zero] it after use.1520     */1521    fun currentVaultKey(): ByteArray? = mutateState {1522        if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1523    }15241525    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1526        val expectedEpoch = currentEpoch()1527        val slotA = store.loadSlotA() ?: return false1528        var oldKey: ByteArray? = null1529        var newKey: ByteArray? = null1530        var plaintext: ByteArray? = null1531        return try {1532            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1533            // Keep ownership of the decrypted vault plaintext until decoding has1534            // finished.  Passing decrypt() inline made the byte array unreachable1535            // before the finally block, so a successful or failed password change1536            // could leave the full plaintext resident in the heap.1537            val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1538            val payload = try {1539                VaultCodec.decode(decrypted)1540            } finally {1541                VaultCrypto.zero(decrypted)1542            }1543            val newSalt = VaultCrypto.generateSalt()1544            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1545            // Re-seal the existing payload under the new key/salt.1546            plaintext = VaultCodec.encode(payload)1547            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1548            // The escrow seals the slot-A PLAINTEXT, which a password change does not1549            // alter — only the wrapping changes — so carry it forward; rebuilding the1550            // StoredVault from scratch would silently drop it.1551            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key1552            // content that a password change does not alter — only the slot-A1553            // wrapping rotates. The root-key escrow staying stable across password1554            // changes matches the browser (`vault.ts:363-364`).1555            val next = StoredVault(1556                version = slotA.version,1557                salt = b64(newSalt),1558                blob = blob,1559                recoveryEscrow = slotA.recoveryEscrow,1560                rootKeyEscrow = slotA.rootKeyEscrow,1561            )1562            // H-01: durably commit slot A + size-matched noise + version stamp1563            // before swapping the session key. A partial commit (new A on disk,1564            // old key in memory) would have the next save encrypt under the old1565            // key while matching disk on the new one.1566            val priorB = store.loadSlotB()1567            if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1568            // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one1569            // inside the same stateLock critical section, so a concurrent lock()1570            // can't observe the in-between (zeroed but not yet reassigned).1571            if (mutateIfCurrentEpoch(expectedEpoch) {1572                oldKey?.let(VaultCrypto::zero); oldKey = null1573                derivedKey?.let(VaultCrypto::zero)1574                derivedKey = newKey; newKey = null1575            } == null) return false1576            true1577        } catch (_: Exception) {1578            false1579        } finally {1580            oldKey?.let(VaultCrypto::zero)1581            newKey?.let(VaultCrypto::zero)1582            plaintext?.let(VaultCrypto::zero)1583        }1584    }15851586    /**1587     * Irreversible wipe. Locks the session and clears every vault key. The Pro1588     * license is in a separate store and is preserved by construction.1589     */1590    suspend fun destroyVault() = mutex.withLock {1591        lock()1592        store.clearVault()1593        store.clearDecoySlot()1594    }15951596    // ---- Entry CRUD -------------------------------------------------------15971598    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1599        val epoch = requireUnlocked()1600        val stamp = VaultCrypto.monotonicEntryTimestamp()1601        val now = System.currentTimeMillis()1602        val entry = withDerivedLast4(draft.copy(1603            id = draft.id.ifBlank { UUID.randomUUID().toString() },1604            createdAt = if (draft.createdAt > 0) draft.createdAt else now,1605            modifiedAt = stamp,1606            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1607        ))1608        // H-03: capture rollback AND write entryList under stateLock so a1609        // concurrent lock() cannot zero the session between the read and the write.1610        val rollback = mutateCurrentSession(epoch) {1611            val r = entryList.toMutableList()1612            entryList.add(entry)1613            r1614        } ?: throw IllegalStateException("vault_locked")1615        persistOrRollback(rollback, epoch)1616        entry1617    }16181619    /**1620     * Insert many entries with a single encrypt-and-write at the end.1621     *1622     * Importing a real vault through [addEntry] was quadratic: every entry took1623     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry1624     * restore did 806 full-vault writes and hung the app long enough to ANR.1625     * Rolls back to the pre-import list if the one write fails, matching1626     * [addEntry]'s all-or-nothing behaviour.1627     */1628    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1629        val epoch = requireUnlocked()1630        if (drafts.isEmpty()) return 01631        val now = System.currentTimeMillis()1632        val ready = drafts.map { draft ->1633            val stamp = VaultCrypto.monotonicEntryTimestamp()1634            withDerivedLast4(1635                draft.copy(1636                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1637                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1638                    modifiedAt = stamp,1639                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1640                ),1641            )1642        }1643        // H-03: capture rollback AND append every entry under stateLock.1644        val rollback = mutateCurrentSession(epoch) {1645            val r = entryList.toMutableList()1646            entryList.addAll(ready)1647            r1648        } ?: throw IllegalStateException("vault_locked")1649        persistOrRollback(rollback, epoch)1650        return drafts.size1651    }16521653    /**1654     * M-01: replace the whole active set with [newEntries] in one write.1655     *1656     * Import's REPLACE mode used to soft-delete every current entry one durable1657     * write at a time (swallowing each failure) and then batch-add, so a failed1658     * add left the old entries stranded in Trash with nothing restored. Staging1659     * the entire next list in memory and committing it through1660     * [persistOrRollback] makes the swap all-or-nothing: on any failure the1661     * pre-import list — active entries included — is restored.1662     *1663     * Existing tombstones are preserved so paired devices still see the1664     * deletions they already know about.1665     */1666    suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1667        val epoch = requireUnlocked()1668        val now = System.currentTimeMillis()1669        val ready = newEntries.map { draft ->1670            val stamp = VaultCrypto.monotonicEntryTimestamp()1671            withDerivedLast4(1672                draft.copy(1673                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1674                    createdAt = if (draft.createdAt > 0) draft.createdAt else now,1675                    modifiedAt = stamp,1676                    passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1677                ),1678            )1679        }1680        // M-03: an incoming entry that shares an id with an existing live entry1681        // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1682        // the old one and then appending the new one would leave the list with1683        // two entries of the same id — the tombstoned one would be returned by1684        // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1685        // superseded entries from the tombstone set instead.1686        val incomingIds = ready.map { it.id }.toHashSet()1687        val rollback = mutateCurrentSession(epoch) {1688            val r = entryList.toMutableList()1689            val replaced = entryList.map { entry ->1690                if (entry.deletedAt != null) return@map entry1691                if (entry.id in incomingIds) return@map null1692                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1693                entry.copy(deletedAt = stamp, modifiedAt = stamp)1694            }.filterNotNull()1695            entryList = (replaced + ready).toMutableList()1696            r1697        } ?: throw IllegalStateException("vault_locked")1698        persistOrRollback(rollback, epoch)1699        return ready.size1700    }17011702    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1703        val epoch = requireUnlocked()1704        // H-03: read entryList, compute the patched entry, and write it under a1705        // single stateLock so a concurrent lock() cannot interleave between the1706        // lookup and the assignment (would IndexOutOfBoundsException on a shrunk1707        // list, or clobber the entry onto the wrong id after a relock).1708        val prepared = mutateCurrentSession(epoch) {1709            val idx = entryList.indexOfFirst { it.id == patch.id }1710            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1711            val current = entryList[idx]1712            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1713            // Only refresh passwordChangedAt when the password value actually changes.1714            val passwordChanged = patch.password != null && patch.password != current.password1715            // Capture the outgoing password (newest first) so the user can reveal/copy/1716            // restore it later. History is computed authoritatively from `current`, not1717            // the patch — updateEntry is a full-entry replace, so trusting patch's value1718            // would let a stale form clobber the real history. Record the age the old1719            // password carried, skip an empty prior, and skip an immediate exact dupe1720            // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.1721            val priorPassword = current.password1722            val shouldCapture = passwordChanged &&1723                !priorPassword.isNullOrEmpty() &&1724                current.passwordHistory?.firstOrNull()?.password != priorPassword1725            val nextHistory = if (shouldCapture) {1726                (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1727                    (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1728            } else {1729                current.passwordHistory1730            }1731            val updated = withDerivedLast4(patch.copy(1732                createdAt = current.createdAt,1733                modifiedAt = stamp,1734                passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1735                passwordHistory = nextHistory,1736            ))1737            val rollback = entryList.toMutableList()1738            entryList[idx] = updated1739            updated to rollback1740        } ?: throw IllegalStateException("vault_locked")1741        val (updated, rollback) = prepared1742        persistOrRollback(rollback, epoch)1743        updated1744    }17451746    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */1747    suspend fun deleteEntry(id: String) = mutex.withLock {1748        val epoch = requireUnlocked()1749        // H-03: lookup + tombstone write under stateLock so lock() cannot shrink1750        // the list out from under the indexOfFirst.1751        val rollback = mutateCurrentSession(epoch) {1752            val idx = entryList.indexOfFirst { it.id == id }1753            if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1754            val current = entryList[idx]1755            val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1756            val prior = entryList.toMutableList()1757            entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1758            prior1759        } ?: throw IllegalStateException("vault_locked")1760        persistOrRollback(rollback, epoch)1761    }17621763    suspend fun restoreEntry(id: String) = mutex.withLock {1764        val epoch = requireUnlocked()1765        val rollback = mutateCurrentSession(epoch) {1766            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1767            if (idx == -1) throw IllegalStateException("deleted entry not found")1768            val current = entryList[idx]1769            val prior = entryList.toMutableList()1770            entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1771            prior1772        } ?: throw IllegalStateException("vault_locked")1773        persistOrRollback(rollback, epoch)1774    }17751776    /** Permanent delete — no tombstone, no recovery. */1777    suspend fun purgeEntry(id: String) = mutex.withLock {1778        val epoch = requireUnlocked()1779        val rollback = mutateCurrentSession(epoch) {1780            val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1781            if (idx == -1) throw IllegalStateException("deleted entry not found")1782            val prior = entryList.toMutableList()1783            entryList.removeAt(idx)1784            prior1785        } ?: throw IllegalStateException("vault_locked")1786        persistOrRollback(rollback, epoch)1787    }17881789    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1790    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1791        val epoch = requireUnlocked()1792        // H-03: read+remove under stateLock so lock() can't reorder the remove.1793        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1794        val r = mutateCurrentSession(epoch) {1795            val rollback = entryList.toMutableList()1796            val before = entryList.size1797            entryList.removeAll { it.deletedAt != null }1798            R(rollback, before - entryList.size)1799        } ?: throw IllegalStateException("vault_locked")1800        if (r.removed > 0) persistOrRollback(r.rollback, epoch)1801        r.removed1802    }18031804    /** Browser parity: number of active entries that dedupe would move to trash. */1805    suspend fun countDuplicateEntries(): Int = mutex.withLock {1806        val epoch = requireUnlocked()1807        // countDuplicateEntriesLocked reads entryList; H-03 fences the read so1808        // lock() can't race the read with state writes.1809        mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1810            ?: throw IllegalStateException("vault_locked")1811    }18121813    /**1814     * Browser parity: soft-delete duplicate entries, keeping the most recently1815     * modified member of each group. Refuse in decoy mode so the duress vault is1816     * not reshaped into a forensically distinct state.1817     */1818    suspend fun deduplicateEntries(): Int = mutex.withLock {1819        val epoch = requireUnlocked()1820        data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1821        val ready = mutateCurrentSession(epoch) {1822            if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1823            val active = entryList.filter { it.deletedAt == null }1824            val keepIds = mutableSetOf<String>()1825            active.groupBy { it.duplicateKey() }.values.forEach { group ->1826                keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1827            }1828            val rollback = entryList.toMutableList()1829            var removed = 01830            entryList = entryList.map { entry ->1831                if (entry.deletedAt == null && entry.id !in keepIds) {1832                    removed++1833                    val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1834                    entry.copy(deletedAt = stamp, modifiedAt = stamp)1835                } else {1836                    entry1837                }1838            }.toMutableList()1839            R(rollback, removed)1840        } ?: throw IllegalStateException("vault_locked")1841        if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1842        ready.removed1843    }18441845    fun getEntries(): List<VaultEntry> = mutateState {1846        if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1847    }1848    fun getEntry(id: String): VaultEntry? = mutateState {1849        if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1850    }1851    fun getDeletedEntries(): List<VaultEntry> = mutateState {1852        if (!sessionDataAvailable) emptyList()1853        else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1854    }18551856    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1857    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1858        return mutateState {1859            if (!sessionDataAvailable) return@mutateState emptyList()1860            val active = entryList.filter { it.deletedAt == null }1861            if (tagFilter == null) return@mutateState active1862            val needle = tagFilter.trim().lowercase()1863            active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1864        }1865    }18661867    /** Sorted, de-duplicated tags across all active entries. */1868    fun getDistinctTags(): List<String> = mutateState {1869        if (!sessionDataAvailable) return@mutateState emptyList()1870        entryList.asSequence()1871            .filter { it.deletedAt == null }1872            .flatMap { it.tags?.asSequence() ?: emptySequence() }1873            .map { it.trim().lowercase() }1874            .filter { it.isNotEmpty() }1875            .distinct()1876            .sorted()1877            .toList()1878    }18791880    /**1881     * Distinct non-blank values for an identity field across active entries,1882     * most-frequently-used first. Supports "username" and "email"; unknown field1883     * names return an empty list. Values keep original casing (deduped1884     * case-insensitively) so a suggestion matches what the user typed.1885     */1886    fun getDistinctFieldValues(field: String): List<String> {1887        val select: (VaultEntry) -> String? = when (field) {1888            "username" -> { e -> e.username }1889            "email" -> { e -> e.email }1890            else -> return emptyList()1891        }1892        return mutateState {1893            if (!sessionDataAvailable) return@mutateState emptyList()1894            entryList.asSequence()1895                .filter { it.deletedAt == null }1896                .mapNotNull { select(it)?.trim() }1897                .filter { it.isNotEmpty() }1898                .groupingBy { it.lowercase() }1899                .aggregate { _, acc: Pair<String, Int>?, value, _ ->1900                    Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1901                }1902                .values1903                .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1904                .map { it.first }1905        }1906    }19071908    fun getTombstones(): List<Tombstone> = mutateState {1909        if (!sessionDataAvailable) emptyList()1910        else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1911    }1912    fun getVaultVersion(): Long = store.version()1913    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19141915    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1916        val expectedEpoch = currentEpoch()1917        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1918        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1919        // H-04: snapshots now carry the duress slot too — restore preserves1920        // the at-rest triad (A/B/D), not just the A/B pair.1921        val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1922        val priorA = store.loadSlotA()1923        val priorB = store.loadSlotB()1924        val priorD = store.loadDecoySlot()1925        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1926        // slot write — the old code took this snapshot, then stranded it on1927        // every failure, polluting history with a "restore" entry that never1928        // restored anything. The history below is rolled back together with1929        // the slots if the commit fails.1930        val priorHistory = store.loadVaultHistory()1931        // P1-4 (codex): the recovery snapshot must land before we commit the1932        // slot pair, otherwise a failed restore cannot be rolled back to the1933        // pre-restore history. If the history write itself fails, refuse to1934        // proceed — the alternative is a successful restore with no undo path.1935        beforeDurableCommit()1936        if (!takeVaultSnapshot("restore", expectedEpoch)) {1937            return false1938        }1939        // H-04: legacy snapshots without vault_d, or vault_d that fails to1940        // decode, fall back to a fake duress of slot A's size — presence-1941        // indistinguishable from any other freshly-restored vault.1942        val nextD: StoredVault = restoredD ?: run {1943            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161944            val fakeBlob = ByteArray(target).also(rng::nextBytes)1945            val fakeSalt = VaultCrypto.generateSalt()1946            StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1947        }1948        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1949            withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1950            return false1951        }1952        lock()1953        true1954    }19551956    /**1957     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1958     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1959     * (a restore preserves the duress-shaped blob from history), not a fresh1960     * noise record. Any failure rolls back so the at-rest pair never holds a1961     * restored slot A against a pre-restore slot B. Caller is responsible for1962     * restoring the pre-snapshot history on a false return.1963     *1964     * H-04: duress slot is now part of the atomic triad — restored last so1965     * a duress-write failure rolls the A/B pair back too.1966     */1967    private fun commitRestoreAtomic(1968        nextA: StoredVault,1969        nextB: String,1970        nextD: StoredVault,1971        priorA: StoredVault?,1972        priorB: String?,1973        priorD: StoredVault?,1974        expectedEpoch: Long,1975    ): Boolean {1976        return withEpochCommit(expectedEpoch) {1977            if (!store.writeSlotA(nextA)) return@withEpochCommit false1978            // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1979            // must trigger rollback; previously a throw escaped without restoring1980            // the prior slot A and history.1981            try {1982                if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1983                // H-04: duress slot is part of the restore triad. Failures here1984                // roll A+B back so the at-rest state stays consistent.1985                if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1986                store.stampLocalMutation()1987            } catch (_: Exception) {1988                reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1989                return@withEpochCommit false1990            }1991            true1992        } ?: false1993    }19941995    // ---- Sync apply (does NOT advance the version) ------------------------19961997    /**1998     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1999     * WITHOUT bumping the version counter, so a sync round cannot cascade into2000     * another outbound sync. Returns the number of locally-applied changes.2001     */2002    suspend fun applyMergedRemoteEntries(2003        remoteEntries: List<VaultEntry>,2004        remoteTombstones: List<Tombstone>,2005        expectedEpoch: Long? = null,2006    ): Int = mutex.withLock {2007        val epoch = expectedEpoch ?: requireUnlocked()2008        if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02009        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS2010        // BEFORE any comparison against a local one. Without this a peer can2011        // send a far-future modifiedAt and permanently pin its version of an2012        // entry against every honest local edit. The browser clamps in the2013        // matching place (vault.ts applyMergedRemoteEntries).2014        val mergeNow = System.currentTimeMillis()2015        val clamped = remoteEntries.mapNotNull { entry ->2016            // MN-3: drop peer entries that decode cleanly but are semantically2017            // hollow (blank title, credential-less login, husk passkey) and2018            // re-cap passwordHistory before anything merges into the vault.2019            SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2020                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2021                deletedAt = entry.deletedAt?.let {2022                    SyncLimits.clampSyncTimestamp(it, mergeNow)2023                },2024            )2025        }2026        val tombMap = remoteTombstones.associate {2027            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2028        }2029        // H-03: build the new entryList under stateLock so a concurrent lock()2030        // cannot zero derivedKey/entryList between the byId read and the merge.2031        val applied = mutateCurrentSession(epoch) {2032            var n = 02033            val byId = entryList.associateBy { it.id }.toMutableMap()20342035            for (remote in clamped) {2036                val tomb = tombMap[remote.id]2037                if (tomb != null && tomb >= remote.modifiedAt) continue2038                val local = byId[remote.id]2039                if (local == null) {2040                    entryList.add(remote); byId[remote.id] = remote; n++2041                } else if (remote.modifiedAt > local.modifiedAt) {2042                    val i = entryList.indexOfFirst { it.id == local.id }2043                    if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2044                }2045            }20462047            for ((id, deletedAt) in tombMap) {2048                val local = byId[id] ?: continue2049                if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2050                if (deletedAt < local.modifiedAt) continue2051                val i = entryList.indexOfFirst { it.id == id }2052                if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2053            }2054            n2055        } ?: return@withLock 020562057        if (applied > 0) {2058            persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2059            mutateIfCurrentEpoch(epoch) { publish() }2060        }2061        applied2062    }20632064    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2065        // Require a fully live session before exposing a settings mutation.  The2066        // epoch is captured with the read so a lock/new-session boundary cannot2067        // let stale work publish into the replacement session.2068        val epoch = requireUnlocked()2069        val prior = mutateCurrentSession(epoch) {2070            val old = _settings.value2071            _settings.value = settings2072            old2073        } ?: throw IllegalStateException("vault_locked")20742075        val committed = try {2076            beforeDurableCommit()2077            // The durable write is inside the same epoch fence as the in-memory2078            // publication.  If lock() won first, this returns null and no stale2079            // settings reach storage or the public StateFlow.2080            withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2081        } catch (_: Exception) {2082            false2083        }2084        if (!committed) {2085            // A live-session storage failure rolls back the public value.  A2086            // stale epoch is already reset by lock()/the new session and must2087            // not be allowed to overwrite that safe state.2088            mutateIfCurrentEpoch(epoch) { _settings.value = prior }2089            throw IllegalStateException("settings_write_failed")2090        }2091    }20922093    // ---- Recovery words (vault metadata) ----------------------------------20942095    /** True once the user has confirmed their recovery words for this vault. */2096    fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20972098    /**2099     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.2100     * Persisted without bumping the version — recovery metadata is local-only and2101     * is not an entry mutation paired devices should sync against.2102     */2103    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2104        val epoch = requireUnlocked()2105        // H-03: hash + escrow-related state fields set under stateLock so a2106        // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow.2107        // M-04: the flags have to be live before the escrows are built — both seal2108        // currentPayload() — so capture the prior pair and put it back if the write2109        // never lands. Otherwise the session reports words set against a slot A2110        // that still holds no escrow, and the recovery screen never offers a retry.2111        val prior = mutateCurrentSession(epoch) {2112            val priorPair = recoveryWordHash to recoveryWordsSet2113            recoveryWordHash = recoveryWordsHash(words)2114            recoveryWordsSet = true2115            priorPair2116        } ?: return@withLock false2117        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow2118        // (cross-platform recovery of the sync root key) and Android's whole-payload2119        // escrow (offline forgot-password restore of entries). Both derive their key2120        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.2121        val persisted = runCatching {2122            persistSlotAWithEscrows(2123                buildRecoveryEscrow(words),2124                buildRootKeyEscrow(words),2125                expectedEpoch = epoch,2126            )2127        }.getOrDefault(false)2128        if (!persisted) {2129            mutateIfCurrentEpoch(epoch) {2130                recoveryWordHash = prior.first2131                recoveryWordsSet = prior.second2132            }2133        }2134        persisted2135    }21362137    /** Verify entered words against the stored recovery hash. */2138    fun verifyRecoveryWords(words: List<String>): Boolean {2139        return mutateState {2140            val stored = recoveryWordHash ?: return@mutateState false2141            stored == recoveryWordsHash(words)2142        }2143    }21442145    // ---- Recovery escrow (forgot-password) --------------------------------21462147    /** True at rest if this vault has any recovery escrow the recovery flow can use. */2148    fun hasRecoveryEscrow(): Boolean =2149        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21502151    /**2152     * One-time self-heal: if this vault has a root key but no recovery escrow2153     * on disk (adopted vaults created before the escrow fix), build and persist2154     * it now. Called on every successful unlock; no-ops once the escrow exists.2155     */2156    private fun ensureRecoveryEscrow() {2157        if (hasRecoveryEscrow()) return2158        val rk = mutateState { rootKey?.copyOf() } ?: return2159        runCatching {2160            val words = rootKeyToWords(rk)2161            val escrow = buildRecoveryEscrow(words)2162            val rkEscrow = buildRootKeyEscrow(words)2163            if (escrow != null || rkEscrow != null) {2164                val prior = store.loadSlotA() ?: return@runCatching2165                store.writeSlotA(prior.copy(2166                    recoveryEscrow = escrow ?: prior.recoveryEscrow,2167                    rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2168                ))2169                mutateState {2170                    recoveryWordHash = recoveryWordsHash(words)2171                    recoveryWordsSet = true2172                }2173            }2174        }.also { VaultCrypto.zero(rk) }2175    }21762177    /**2178     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of2179     * the master password. On success the slot-A plaintext sealed in the escrow is2180     * adopted as the live session (entries, rootKey, settings), but no Argon2id2181     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a2182     * new password and finish unlocking. Returns false when there is no escrow, the2183     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).2184     *2185     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no2186     * detached vault key to return here — slot A is wrapped under the rotating2187     * Argon2id key — so this opens the escrowed payload directly.2188     */2189    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2190        val expectedEpoch = currentEpoch()2191        val slotA = store.loadSlotA() ?: return false2192        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21932194        // Prefer Android's whole-payload escrow: a full offline restore of entries,2195        // root key, and settings. Present for Android-created vaults.2196        val fullEscrow = slotA.recoveryEscrow2197        if (fullEscrow != null) {2198            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2199            if (material != null) {2200                var keyBytes: ByteArray? = null2201                var plaintext: ByteArray? = null2202                var candidateRoot: ByteArray? = null2203                var stateCommitStarted = false2204                try {2205                    keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2206                    plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2207                    val payload = VaultCodec.decode(plaintext)2208                    // Stage every fallible operation before touching live session2209                    // fields.  In particular, a settings/provider/decode failure2210                    // cannot leave entries, flags, or a root key resident while the2211                    // public session remains locked.2212                    val candidateSettings = store.loadSettings()2213                    candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2214                        val decodedRoot = unb64(payload.rootKeyB64)2215                        if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2216                            // Do not let a malformed authenticated payload abandon2217                            // this decoded root-key candidate before the outer2218                            // finally block owns it.2219                            VaultCrypto.zero(decodedRoot)2220                            throw IllegalArgumentException("invalid recovery root key")2221                        }2222                        decodedRoot2223                    } else {2224                        generateRecoverableRootKey()2225                    }2226                    stateCommitStarted = true2227                    val adopted = mutateIfCurrentEpoch(expectedEpoch) {2228                        clearSessionStateLocked()2229                        entryList = payload.entries.toMutableList()2230                        duressConfigured = payload.duressConfigured2231                        recoveryWordHash = payload.recoveryWordHash2232                        recoveryWordsSet = payload.recoveryWordsSet2233                        rootKey = candidateRoot2234                        candidateRoot = null2235                        _settings.value = candidateSettings2236                        publish()2237                        // This is the authenticated recovery half-unlock boundary:2238                        // only set it after every candidate field is installed.2239                        sessionDataAvailable = true2240                        _isUnlocked.value = false2241                        true2242                    }2243                    if (adopted == true) return@withLock true2244                } catch (_: Exception) {2245                    // Wrong words, malformed payloads, provider failures, and2246                    // settings failures all fall through to the browser-compatible2247                    // root-key escrow.  If the state commit had started, invalidate2248                    // that epoch so no partial candidate survives.2249                    if (stateCommitStarted) {2250                        mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2251                    }2252                } finally {2253                    plaintext?.let(VaultCrypto::zero)2254                    VaultCrypto.zero(material)2255                    keyBytes?.let(VaultCrypto::zero)2256                    candidateRoot?.let(VaultCrypto::zero)2257                }2258            }2259        }22602261        // Browser-format vault (only the root-key escrow): recover the 32-byte root2262        // key (sync identity) and reset the password; entries return via sync. This2263        // is exactly the browser's recovery semantics (`messages.ts:312-347`).2264        val rootEscrow = slotA.rootKeyEscrow2265        if (rootEscrow != null) {2266            var candidateRoot: ByteArray? = null2267            var stateCommitStarted = false2268            try {2269                candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2270                require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2271                val candidateSettings = store.loadSettings()2272                stateCommitStarted = true2273                val adopted = mutateIfCurrentEpoch(expectedEpoch) {2274                    clearSessionStateLocked()2275                    entryList = mutableListOf()2276                    duressConfigured = false2277                    recoveryWordHash = recoveryWordsHash(words)2278                    recoveryWordsSet = true2279                    rootKey = candidateRoot2280                    candidateRoot = null2281                    _settings.value = candidateSettings2282                    publish()2283                    sessionDataAvailable = true2284                    _isUnlocked.value = false2285                    true2286                }2287                if (adopted == true) return@withLock true2288            } catch (_: Exception) {2289                if (stateCommitStarted) {2290                    mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2291                }2292            } finally {2293                candidateRoot?.let(VaultCrypto::zero)2294            }2295        }2296        return false2297    }22982299    /**2300     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:2301     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same2302     * words, mark the session fully unlocked, and publish. Returns false if called2303     * without a prior successful recovery unlock (no in-memory root key).2304     *2305     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`2306     * into one step, since there is no detached vault key here.2307     */2308    suspend fun resetPasswordWithRecoveryWords(2309        words: List<String>,2310        newPassword: String,2311    ): Boolean = mutex.withLock {2312        val expectedEpoch = currentEpoch()2313        // H-03: read rootKey + entryList under stateLock before computing the new2314        // plaintext, then re-publish + swap the derived key under stateLock so a2315        // concurrent lock() cannot straddle the two fences.2316        if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2317        val priorA = store.loadSlotA() ?: return false2318        var newKey: ByteArray? = null2319        var plaintext: ByteArray? = null2320        return try {2321            val newSalt = VaultCrypto.generateSalt()2322            newKey = VaultCrypto.deriveKey(newPassword, newSalt)2323            plaintext = VaultCodec.encode(currentPayload())2324            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2325            // Rebuild both escrows against the recovered session so their salts rotate2326            // with the password reset; fall back to the prior escrows if rebuild fails2327            // (e.g. a checksum-invalid phrase). The same 12 words keep working.2328            val valid = words.size == 12 && findInvalidWords(words).isEmpty()2329            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2330            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2331            val next = StoredVault(2332                version = priorA.version,2333                salt = b64(newSalt),2334                blob = blob,2335                recoveryEscrow = freshRecovery,2336                rootKeyEscrow = freshRootKey,2337            )2338            // H-01: see changePassword — both rotations share the same2339            // commit-or-keep-old-key contract so a partial write can't strand2340            // the session on a key that no longer matches disk.2341            val priorB = store.loadSlotB()2342            if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2343            // H-03 (P1-2 codex): zero the old live key and install the new one2344            // inside the same stateLock critical section so a concurrent lock()2345            // can't observe the in-between.2346            if (mutateIfCurrentEpoch(expectedEpoch) {2347                derivedKey?.let(VaultCrypto::zero)2348                derivedKey = newKey; newKey = null2349                publish()2350                _isUnlocked.value = true2351            } == null) return false2352            true2353        } catch (_: Exception) {2354            false2355        } finally {2356            newKey?.let(VaultCrypto::zero)2357            plaintext?.let(VaultCrypto::zero)2358        }2359    }23602361    /**2362     * Seal the current slot-A plaintext under a key derived from the 12 BIP392363     * recovery [words] (Android offline-restore escrow). IKM is the browser's2364     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].2365     * Returns null for a checksum-invalid phrase.2366     */2367    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2368        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2369        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2370        var keyBytes: ByteArray? = null2371        var plaintext: ByteArray? = null2372        return try {2373            val salt = VaultCrypto.generateSalt()2374            val recoveryKey = deriveRecoveryKey(material, salt)2375            keyBytes = recoveryKey.encoded2376            plaintext = VaultCodec.encode(currentPayload())2377            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2378            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2379        } finally {2380            VaultCrypto.zero(material)2381            keyBytes?.let(VaultCrypto::zero)2382            plaintext?.let(VaultCrypto::zero)2383        }2384    }23852386    /**2387     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,2388     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex2389     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,2390     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap2391     * the same root key on the browser. Returns null with no in-memory root key or2392     * a checksum-invalid phrase.2393     */2394    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2395        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2396        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2397        val rk = mutateState { rootKey?.copyOf() } ?: run {2398            VaultCrypto.zero(material)2399            return null2400        }2401        var keyBytes: ByteArray? = null2402        var plaintext: ByteArray? = null2403        return try {2404            val salt = VaultCrypto.generateSalt()2405            val recoveryKey = deriveRecoveryKey(material, salt)2406            keyBytes = recoveryKey.encoded2407            plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2408            // combined = nonce(12) || ciphertext || tag(16)2409            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2410            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2411            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2412            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2413            val ct = ctTag.copyOfRange(0, ctTag.size - 16)2414            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2415        } finally {2416            VaultCrypto.zero(rk)2417            VaultCrypto.zero(material)2418            keyBytes?.let(VaultCrypto::zero)2419            plaintext?.let(VaultCrypto::zero)2420        }2421    }24222423    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */2424    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2425        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2426        var keyBytes: ByteArray? = null2427        var pt: ByteArray? = null2428        return try {2429            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2430            keyBytes = recoveryKey.encoded2431            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2432            pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2433            hexToRootKey(String(pt, Charsets.US_ASCII))2434        } catch (_: Exception) {2435            null2436        } finally {2437            // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the2438            // IKM-equivalent of the vault identity. material+keyBytes were2439            // already zeroed; pt was leaking.2440            VaultCrypto.zero(material)2441            keyBytes?.let(VaultCrypto::zero)2442            pt?.let(VaultCrypto::zero)2443        }2444    }24452446    /**2447     * Re-seal slot A from the current in-memory state under the live key, carrying2448     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs2449     * in decoy mode (recovery is never offered for the decoy) and persists without2450     * a version bump, like the recovery-words metadata write it replaces.2451     */2452    private fun persistSlotAWithEscrows(2453        recoveryEscrow: RecoveryEscrow?,2454        rootKeyEscrow: RootKeyEscrow?,2455        expectedEpoch: Long? = null,2456    ): Boolean {2457        val snapshot = snapshotForPersist(expectedEpoch)2458            ?: throw IllegalStateException("vault_locked_or_stale")2459        if (snapshot.decoy) {2460            VaultCrypto.zero(snapshot.key)2461            return false2462        }2463        try {2464            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2465            val plaintext = VaultCodec.encode(snapshot.payload)2466            val blob = try {2467                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2468            } finally {2469                // The escrow path used to drop this reference before zeroing it;2470                // keep it live through the fill on both success and failure.2471                VaultCrypto.zero(plaintext)2472            }2473            val next = prior.copy(2474                blob = blob,2475                recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2476                rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2477            )2478            beforeDurableCommit()2479            return withEpochCommit(snapshot.epoch) {2480                if (!store.writeSlotA(next)) return@withEpochCommit false2481                try {2482                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2483                    // H-04: escrow rebuild changes slot A's plaintext size, so re-pad2484                    // the duress slot too. A real decoy is left alone; a fake is2485                    // re-sized to slot A's new ciphertext length.2486                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2487                } catch (e: Exception) {2488                    store.writeSlotA(prior)2489                    throw e2490                }2491                true2492            } ?: false2493        } finally {2494            VaultCrypto.zero(snapshot.key)2495        }2496    }24972498    // ---- Internals --------------------------------------------------------24992500    private fun requireUnlocked(): Long {2501        // H-03: read derivedKey atomically with [lock]'s reset; without it,2502        // [lock] from a background lifecycle event can run between this read2503        // and the caller's first state touch.2504        stateLock.lock()2505        try {2506            if (derivedKey == null) throw IllegalStateException("vault_locked")2507            return sessionEpoch2508        } finally {2509            stateLock.unlock()2510        }2511    }25122513    /** A locked or half-adopted session must not expose secret-bearing fields. */2514    private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25152516    private fun countDuplicateEntriesLocked(): Int {2517        val seen = mutableMapOf<String, VaultEntry>()2518        var duplicates = 02519        for (entry in entryList.filter { it.deletedAt == null }) {2520            val key = entry.duplicateKey()2521            val existing = seen[key]2522            if (existing == null) {2523                seen[key] = entry2524            } else {2525                duplicates++2526                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2527                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2528                if (entryStamp > existingStamp) seen[key] = entry2529            }2530        }2531        return duplicates2532    }25332534    private fun VaultEntry.duplicateKey(): String {2535        if (type == "note") return "note|${title.lowercase()}"2536        val passkeyData = passkey2537        if (passkeyData != null) {2538            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2539        }2540        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2541    }25422543    /**2544     * Capture the current slot pair (and metadata) into the history list.2545     * Returns true when the history write landed, false when it didn't — callers2546     * that depend on the snapshot existing (notably [restoreVaultSnapshot])2547     * must check the result and roll back if it does not.2548     */2549    private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2550        val epoch = expectedEpoch ?: currentEpoch()2551        if (mutateIfCurrentEpoch(epoch) { true } != true) return false2552        val slotA = store.loadSlotA() ?: return false2553        val vaultA = VaultCodec.encodeSlot(slotA)2554        val vaultB = store.loadSlotB() ?: slotA.blob2555        // H-04: capture the duress slot so a restore preserves the at-rest2556        // triad, not just the A/B pair. The duress slot is always present2557        // (real or fake), so loadDecoySlot() returning null is a logic error2558        // — fall through to a fake duress in that case rather than silently2559        // losing the record from the snapshot.2560        val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2561            val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162562            val fakeBlob = ByteArray(target).also(rng::nextBytes)2563            val fakeSalt = VaultCrypto.generateSalt()2564            VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2565        }2566        val history = store.loadVaultHistory()2567        val latest = history.snapshots.firstOrNull()2568        if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2569        val entryCount = mutateIfCurrentEpoch(epoch) {2570            if (derivedKey != null && !decoyMode) {2571                bucketEntryCount(entryList.count { it.deletedAt == null })2572            } else {2573                null2574            }2575        } ?: return false2576        return try {2577            val snapshot = VaultSnapshot(2578                id = UUID.randomUUID().toString(),2579                savedAt = System.currentTimeMillis(),2580                reason = reason,2581                vault_a = vaultA,2582                vault_b = vaultB,2583                vault_d = vaultD,2584                entryCount = entryCount,2585            )2586            // P1-4 (codex): return the history-write boolean to callers so a2587            // false return no longer leaves the snapshot out of history while2588            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.2589            withEpochCommit(epoch) {2590                store.writeVaultHistory(2591                    history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2592                )2593            } ?: false2594        } catch (_: Exception) {2595            false2596        }2597    }25982599    private fun bucketEntryCount(n: Int): Int = when {2600        n <= 0 -> 02601        n <= 10 -> 102602        n <= 25 -> 252603        n <= 50 -> 502604        n <= 100 -> 1002605        n <= 250 -> 2502606        n <= 500 -> 5002607        else -> ((n + 499) / 500) * 5002608    }26092610    /**2611     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),2612     * so it is stored explicitly and survives the sync wire format rather than being2613     * re-derived at render time. Non-card entries pass through unchanged.2614     *2615     * M-03: never overwrite a stored last4 with null. A card kept only as last42616     * (e.g. after a PeachScript import that had no PAN) must keep that value;2617     * re-deriving from an absent cardNumber would silently zero the field.2618     */2619    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2620        if (!entry.isCard()) return entry2621        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2622        val derived = if (digits.length >= 4) digits.takeLast(4) else null2623        return entry.copy(last4 = derived ?: entry.last4)2624    }26252626    /**2627     * On unlock, hard-delete trashed entries older than the retention window.2628     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"2629     * (the app's "Never" option), so nothing is purged. Persists without a2630     * version bump and without touching the publish state (caller publishes).2631     */2632    private fun autoPurgeExpiredTrash() {2633        val days = _settings.value.trashRetentionDays ?: return2634        if (days <= 0) return2635        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2636        val before = entryList.size2637        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2638        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2639    }26402641    /**2642     * Browser parity (P1): merge orphaned passkey-only entries into matching2643     * login entries that share the same registrable domain. A passkey-only2644     * entry has passkey fields (rpId + credentialId) but no password. Runs on2645     * unlock so consolidation is idempotent and cheap.2646     */2647    private fun consolidatePasskeyEntries() {2648        val passkeyOnly = entryList.filter { entry ->2649            entry.deletedAt == null &&2650                entry.isLogin() &&2651                entry.password.isNullOrBlank() &&2652                !entry.rpId.isNullOrBlank() &&2653                !entry.credentialId.isNullOrBlank()2654        }2655        if (passkeyOnly.isEmpty()) return2656        var changed = false2657        val now = System.currentTimeMillis()2658        for (pkEntry in passkeyOnly) {2659            val rpId = pkEntry.rpId ?: continue2660            val target = entryList.firstOrNull { candidate ->2661                candidate.deletedAt == null &&2662                    candidate.id != pkEntry.id &&2663                    candidate.isLogin() &&2664                    candidate.passkey == null &&2665                    candidate.credentialId.isNullOrBlank() &&2666                    !candidate.password.isNullOrBlank() &&2667                    listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2668                        .any { com.peachpasswords.shared.extractDomain(it) == rpId }2669            } ?: continue2670            val idx = entryList.indexOfFirst { it.id == target.id }2671            if (idx < 0) continue2672            entryList[idx] = target.copy(2673                rpId = pkEntry.rpId,2674                rpName = pkEntry.rpName ?: target.rpName,2675                credentialId = pkEntry.credentialId,2676                publicKeyCose = pkEntry.publicKeyCose,2677                privateKeyJwk = pkEntry.privateKeyJwk,2678                userHandle = pkEntry.userHandle,2679                userName = pkEntry.userName,2680                userDisplayName = pkEntry.userDisplayName,2681                algorithm = pkEntry.algorithm,2682                signCount = pkEntry.signCount,2683                lastUsedAt = pkEntry.lastUsedAt,2684                modifiedAt = now,2685            )2686            // Soft-delete the orphan so it lands in trash (recoverable).2687            val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2688            if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2689            changed = true2690        }2691        if (changed) runCatching { persistSlotA(bumpVersion = false) }2692    }26932694    /** The slot-A plaintext envelope for the current in-memory session. */2695    private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }26962697    private fun currentPayloadLocked(): VaultPayload =2698        VaultPayload(2699            entries = entryList.toList(),2700            duressConfigured = duressConfigured,2701            rootKeyB64 = rootKey?.let(::b64) ?: "",2702            recoveryWordHash = recoveryWordHash,2703            recoveryWordsSet = recoveryWordsSet,2704        )27052706    /**2707     * Adopt the payload's root key, lazily minting one for legacy payloads.2708     * A freshly-minted key is persisted WITHOUT bumping the version — adopting2709     * a root key is not a mutation paired devices should sync against.2710     */2711    private fun ensureRootKey(payload: VaultPayload) {2712        if (payload.rootKeyB64.isNotBlank()) {2713            rootKey = unb64(payload.rootKeyB64)2714            return2715        }2716        rootKey = generateRecoverableRootKey()2717        runCatching { persistSlotA(bumpVersion = false) }2718    }27192720    private fun publish() {2721        _entries.value = entryList.filter { it.deletedAt == null }2722    }27232724    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2725        var plaintext = VaultCodec.encode(currentPayload())2726        return try {2727            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2728            StoredVault(version = 1, salt = b64(salt), blob = blob)2729        } finally {2730            // M-05: full vault plaintext is allocated here once per createVault.2731            // Zeroing in finally keeps the transient copy off the heap after the2732            // caller either succeeds or rolls back via lock().2733            VaultCrypto.zero(plaintext)2734        }2735    }27362737    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2738        // Size the noise so its base64 length matches slot A's blob (MN-1): with the2739        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots2740        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.2741        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2742        val noise = ByteArray(approxBytes).also(rng::nextBytes)2743        return store.writeSlotB(b64(noise))2744    }27452746    /** H-04: the ciphertext length slot A is using right now, so the duress2747     *  slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext2748     *  length equals ciphertext length, so this also matches slot A's plaintext. */2749    private fun matchingCiphertextLength(slotA: StoredVault): Int =2750        (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27512752    /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength]2753     *  bytes. Replaces a real decoy when the user clears duress, and is the2754     *  initial duress slot when no decoy is configured — so the record's2755     *  PRESENCE on disk is independent of duressConfigured, and its SIZE2756     *  matches slot A's blob length. */2757    private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2758        val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162759        val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2760        val fakeSalt = VaultCrypto.generateSalt()2761        return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2762    }27632764    /** H-04: keep the duress record presence- and size-indistinguishable from2765     *  slot A on every persist. A real decoy is left alone (it was padded at2766     *  setDuressPassphrase time, and we don't have the duress key here to2767     *  re-pad on entry growth — that's an accepted residual tell, see the2768     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2769     *  A's size. */2770    private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2771        val isConfigured = mutateState { duressConfigured }2772        return if (isConfigured) true else writeFakeDuressSlot(slotA)2773    }27742775    /**2776     * H-04: convert a browser-shaped duress slot to Android StoredVault2777     * without decrypting. The browser seals slot "b" under the duress2778     * passphrase; decrypting with the main password silently drops it.2779     * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes2780     * the salt from hex to base64.2781     */2782    private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2783        val salt = unhex(b.salt)2784        val nonce = unhex(b.nonce)2785        val ct = unhex(b.ciphertext)2786        val tag = unhex(b.tag)2787        val blob = ByteArray(nonce.size + ct.size + tag.size).also {2788            System.arraycopy(nonce, 0, it, 0, nonce.size)2789            System.arraycopy(ct, 0, it, nonce.size, ct.size)2790            System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2791        }2792        return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2793    }27942795    /** Re-seal slot A from the current in-memory state under the live key. */2796    private fun persistSlotA(2797        bumpVersion: Boolean,2798        snapshotReason: String? = "local_save",2799        expectedEpoch: Long? = null,2800    ): Boolean {2801        val snapshot = snapshotForPersist(expectedEpoch)2802            ?: throw IllegalStateException("vault_locked_or_stale")2803        return try {2804            // Decoy sessions persist to the decoy slot only — never touch the real slot A.2805            if (snapshot.decoy) {2806                val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2807                val plaintext = VaultCodec.encode(snapshot.payload)2808                val blob = try {2809                    b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2810                } finally {2811                    // Keep the last reference alive until the zero, including the2812                    // successful encrypt path (M-05).2813                    VaultCrypto.zero(plaintext)2814                }2815                beforeDurableCommit()2816                return withEpochCommit(snapshot.epoch) {2817                    store.writeDecoySlot(prior.copy(blob = blob))2818                } ?: false2819            }2820            val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2821            // H-01: captured BEFORE the noise write so a stamp failure can restore2822            // the prior slot B alongside the prior slot A. Slot B is always2823            // overwritten by writeMatchingNoiseSlotB below, so a stamp failure2824            // leaves (new A, new B, old version) on disk — partial commit.2825            val priorB = store.loadSlotB()2826            beforeDurableCommit()2827            if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2828            val plaintext = VaultCodec.encode(snapshot.payload)2829            val blob = try {2830                b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2831            } finally {2832                // Do not drop [plaintext] before fill(0): the serialized vault2833                // contains every entry password and is the primary M-05 target.2834                VaultCrypto.zero(plaintext)2835            }2836            val next = prior.copy(blob = blob)2837            // The epoch check and the entire A/B/D/version commit share one brief2838            // stateLock critical section. lock() either wins before this block or2839            // waits until the already-committed write is complete; it can never2840            // clear the session between the check and the durable write.2841            withEpochCommit(snapshot.epoch) {2842                if (!store.writeSlotA(next)) return@withEpochCommit false2843                try {2844                    if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2845                    // H-04: duress slot stays present (real one untouched, fake one2846                    // re-padded to slot A's current size on every persist).2847                    if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2848                    if (bumpVersion) store.stampLocalMutation()2849                } catch (e: Exception) {2850                    store.writeSlotA(prior)2851                    if (priorB != null) runCatching { store.writeSlotB(priorB) }2852                    throw e2853                }2854                true2855            } ?: false2856        } finally {2857            // Snapshot keys are copies, so lock() may zero the live key while this2858            // operation is still deriving/encrypting; always scrub our copy too.2859            VaultCrypto.zero(snapshot.key)2860        }2861    }28622863    /**2864     * H-01: durably commit a slot-A rotation by writing slot A, its2865     * size-matched noise slot B, and the local-mutation stamp in order. Any2866     * failure rolls the previously-committed steps back so the at-rest pair2867     * matches what was there before the call. Returns true only when every2868     * step succeeded — callers must NOT swap their session key on false.2869     */2870    private fun commitVaultRotation(2871        priorA: StoredVault,2872        priorB: String?,2873        nextA: StoredVault,2874        expectedEpoch: Long,2875    ): Boolean {2876        beforeDurableCommit()2877        return withEpochCommit(expectedEpoch) {2878            if (!store.writeSlotA(nextA)) return@withEpochCommit false2879            // P1-1 (codex): the noise write's boolean AND any thrown exception from2880            // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB2881            // escaped the helper and left slot A committed.2882            try {2883                if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2884                // H-04: a password rotation changes slot A's plaintext size (new2885                // salt → re-encrypted blob), so the duress slot must be re-padded2886                // alongside. writeCompanionDuressSlot leaves a real decoy alone2887                // and re-emits a fake of slot A's size otherwise.2888                if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2889                store.stampLocalMutation()2890            } catch (_: Exception) {2891                rollbackRotation(priorA, priorB)2892                return@withEpochCommit false2893            }2894            true2895        } ?: false2896    }28972898    /**2899     * Best-effort undo of the writes [commitVaultRotation] may have committed.2900     * Each step runs independently — a failed rollback must not mask the2901     * original commit failure. Log is a throwing stub off-device.2902     */2903    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2904        val failures = mutableListOf<String>()2905        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2906            .onFailure { failures += "writeSlotA: $it" }2907        if (priorB != null) {2908            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2909                .onFailure { failures += "writeSlotB: $it" }2910        }2911        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2912    }29132914    /** Persist a local mutation, rolling the in-memory list back on failure. */2915    private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2916        try {2917            if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2918                mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2919                throw IllegalStateException("storage_write_failed")2920            }2921        } catch (e: Exception) {2922            mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2923            throw e2924        }2925        // A lock can win in the small interval after the durable commit and2926        // before publication. The epoch check prevents pre-lock entries from2927        // being republished into a newly locked (or newly unlocked) session.2928        mutateIfCurrentEpoch(expectedEpoch) { publish() }2929    }2930}