VaultRepository.kt

78.1 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.util.Log12import com.peachpasswords.BuildConfig13import com.peachpasswords.sync.SyncLimits14import kotlinx.serialization.json.JsonElement15import kotlinx.serialization.json.JsonNull16import kotlinx.serialization.json.JsonObject17import kotlinx.serialization.json.JsonPrimitive18import java.util.Base6419import java.util.UUID20import javax.inject.Inject21import javax.inject.Singleton2223/** Lenient parser for the Codex vault-blob wrapper only. */24private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2526private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)27private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)2829/** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */30private const val PASSWORD_HISTORY_MAX = 5031private const val VAULT_HISTORY_MAX = 53233sealed interface UnlockResult {34    data object Success : UnlockResult35    data object WrongPassword : UnlockResult36    data class Error(val reason: String) : UnlockResult37}3839sealed interface CreateVaultResult {40    data object Success : CreateVaultResult41    data class Error(val reason: String) : CreateVaultResult42}4344sealed interface AdoptVaultResult {45    data class Success(val vaultId: String) : AdoptVaultResult46    data class Error(val reason: String) : AdoptVaultResult47}4849/**50 * In-memory vault session backed by [VaultStore]. Faithful port of the browser51 * `vault.ts` semantics that matter for correctness:52 *53 *  - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched54 *    noise blob so a fresh install shows two similarly-shaped records.55 *  - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a56 *    deletion always wins last-write-wins against an in-flight edit.57 *  - Local writes bump the version counter; sync-applied writes do NOT (so a58 *    sync round never cascades into another outbound sync).59 *  - add/update/delete roll back in-memory state if the disk write fails.60 *  - destroyVault never touches the Pro license (separate store).61 */62@Singleton63class VaultRepository @Inject constructor(64    private val store: VaultStorage,65) {66    private val mutex = Mutex()67    private val rng = SecureRandom()6869    private var derivedKey: ByteArray? = null70    private var rootKey: ByteArray? = null71    private var entryList: MutableList<VaultEntry> = mutableListOf()72    private var duressConfigured: Boolean = false73    private var recoveryWordHash: String? = null74    private var recoveryWordsSet: Boolean = false75    /** True while a decoy (duress) session is active — persistence targets the decoy slot. */76    private var decoyMode: Boolean = false7778    private val _isUnlocked = MutableStateFlow(false)79    val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()8081    private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())82    /** Active (non-deleted) entryList. */83    val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8485    private val _settings = MutableStateFlow(VaultSettings())86    val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8788    fun hasVault(): Boolean = store.hasVault()8990    // ---- Codex (printed paper backup) -------------------------------------9192    /**93     * The at-rest slot-A envelope as base64 JSON `{"a": <StoredVault>}` — the94     * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable95     * on any device that has the master password. Null when no vault exists.96     */97    fun codexVaultBlobBase64(): String? {98        val slotA = store.loadSlotA() ?: return null99        val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""100        return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))101    }102103    /**104     * Replace the local vault from a Codex blob, then unlock with [password].105     * Writes slot A (and a size-matched noise slot B) before unlocking so the106     * restored vault is at rest exactly as a freshly-created one would be.107     */108    /** A slot is stored either as a JSON string (browser) or inline object (Android). */109    private fun JsonElement?.asSlotJson(): String? = when {110        this == null || this is JsonNull -> null111        this is JsonPrimitive && isString -> content112        else -> toString()113    }114115    suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {116        var candidateKey: ByteArray? = null117        var plaintext: ByteArray? = null118        return try {119            val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)120            // Parsed as JSON, not matched with a regex. The wrapper is121            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which122            // generates most real Codexes — writes each slot as a JSON123            // *string*, with "b" present. A regex looking for an object124            // literal under "a" at the very end of the wrapper matches125            // neither of those, so every browser-generated Codex failed here126            // with "invalid_codex_blob" after decoding perfectly.127            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject128                ?: return UnlockResult.Error("invalid_codex_blob")129            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")130            // A Codex printed from the browser carries a browser-shaped slot,131            // which is the common case — most Codexes are printed there. It is132            // translated to Android's shape (see BrowserSlot) rather than133            // rejected. A translation failure is a wrong password, not a bad134            // blob: the codes decoded fine, the password just didn't open them.135            val slotA = VaultCodec.decodeSlot(slotJson)136                ?: BrowserSlot.parse(slotJson)?.let {137                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword138                }139                ?: return UnlockResult.Error("invalid_codex_blob")140            // C-01: authenticate the candidate ENTIRELY in memory before touching141            // storage. VaultCodec.decodeSlot is structural — an Android-shaped142            // Codex parses under ANY password — so writing slot A first and143            // unlocking afterwards destroyed the device's existing vault every144            // time the user mistyped the password.145            candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))146            plaintext = decryptSlotABlob(slotA, candidateKey)147            // LV-1: a Codex printed from a vault sealed before NFC normalization148            // opens only under the raw password bytes. unlockLocked used to supply149            // this fallback; authenticating up front here has to keep it, or those150            // Codexes become unrestorable.151            if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {152                VaultCrypto.zero(candidateKey)153                candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))154                plaintext = decryptSlotABlob(slotA, candidateKey)155            }156            if (plaintext == null) return UnlockResult.WrongPassword157            val payload = try {158                VaultCodec.decode(plaintext)159            } catch (_: Exception) {160                return UnlockResult.Error("invalid_codex_blob")161            }162163            val priorA = store.loadSlotA()164            val priorB = store.loadSlotB()165            // Carry the real slot B across when the Codex has one — it holds a166            // duress vault, which noise would silently destroy.167            val codexSlotB = wrapper["b"].asSlotJson()168169            if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")170            val committed = runCatching {171                val wroteB = if (codexSlotB != null) {172                    store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))173                } else {174                    writeMatchingNoiseSlotB(slotA)175                }176                if (!wroteB) throw IllegalStateException("storage_write_failed")177                store.stampLocalMutation()178            }.isSuccess179            if (!committed) {180                rollbackRestore(priorA, priorB)181                return UnlockResult.Error("storage_write_failed")182            }183184            val ownedKey = candidateKey!!185            candidateKey = null // the session owns the key from here on186            adoptSlotA(ownedKey, payload)187        } catch (e: Exception) {188            // A raw kotlinx parse error ("Unexpected JSON token at offset N")189            // reaching the user says nothing about WHICH json failed — the190            // wrapper, or the payload decrypted out of it. Codex screens are191            // FLAG_SECURE so this can't be screenshotted either. Debug builds192            // log the structure around the failure; alphanumerics are masked so193            // no ciphertext or vault content lands in a log.194            if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))195            UnlockResult.Error(e.message ?: "restore_failed")196        } finally {197            plaintext?.let(VaultCrypto::zero)198            candidateKey?.let(VaultCrypto::zero)199        }200    }201202    /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */203    private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =204        runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()205206    /**207     * Undo a partially-committed Codex restore. Every step is attempted208     * independently so one failure cannot strand the others. With no prior vault209     * the accurate inverse is having none at all, rather than a half-written210     * slot A that [hasVault] would report as a real vault.211     */212    private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {213        val failures = mutableListOf<String>()214        if (priorA == null) {215            runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }216        } else {217            runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }218                .onFailure { failures += "writeSlotA: $it" }219            if (priorB != null) {220                runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }221                    .onFailure { failures += "writeSlotB: $it" }222            }223        }224        // Log is a throwing stub off-device, and a failed log must never mask225        // the rollback result the caller is about to report.226        if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }227    }228229    /** Debug-only: structure of a failed Codex restore, with content masked. */230    private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {231        val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)232        val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()233        val window = offset?.let {234            val from = (it - 40).coerceAtLeast(0)235            val to = (it + 40).coerceAtMost(wrapperJson.length)236            wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")237        }238        val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()239        val slot = wrapper?.get("a")240        buildString {241            append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)242            append(" | blobBase64=").append(vaultBlobBase64.length)243            append(" wrapperChars=").append(wrapperJson.length)244            append(" wrapperParses=").append(wrapper != null)245            append(" keys=").append(wrapper?.keys)246            append(" slotAKind=").append(247                when {248                    slot == null -> "absent"249                    slot is JsonPrimitive && slot.isString -> "string"250                    slot is JsonObject -> "object:" + slot.keys251                    else -> slot::class.simpleName252                },253            )254            if (window != null) append(" masked@").append(offset).append("=").append(window)255        }256    }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }257258    suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {259        return try {260            val salt = VaultCrypto.generateSalt()261            val key = VaultCrypto.deriveKey(password, salt)262            entryList = mutableListOf()263            duressConfigured = false264            recoveryWordHash = null265            recoveryWordsSet = false266            decoyMode = false267            rootKey = generateRecoverableRootKey()268            val slotA = sealSlotA(key, salt)269            if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")270            // P1-3 (codex): every later step must roll the slot pair back if it271            // fails — leaving a half-built vault is worse than refusing to make it.272            val created = try {273                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")274                store.stampLocalMutation()275                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")276                true277            } catch (_: Exception) {278                // Half-built vault on disk is worse than no vault — refuse to279                // create if any step past slot A fails.280                store.clearVault()281                false282            }283            if (!created) return CreateVaultResult.Error("storage_write_failed")284            derivedKey = key285            publish()286            _isUnlocked.value = true287            CreateVaultResult.Success288        } catch (e: Exception) {289            CreateVaultResult.Error(e.message ?: "create_failed")290        }291    }292293    suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {294        if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")295        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")296        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {297            return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")298        }299        var adoptedRootKey: ByteArray? = null300        var nextDerivedKey: ByteArray? = null301        var sessionRootKey: ByteArray? = null302        return try {303            adoptedRootKey = hexToRootKey(rootKeyHex)304            if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {305                return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")306            }307            val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)308            val salt = VaultCrypto.generateSalt()309            nextDerivedKey = VaultCrypto.deriveKey(password, salt)310            sessionRootKey = adoptedRootKey.copyOf()311            val payload = VaultPayload(312                entries = emptyList(),313                duressConfigured = false,314                rootKeyB64 = b64(sessionRootKey),315                recoveryWordHash = null,316                recoveryWordsSet = false,317            )318            val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))319            val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)320            if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")321            // P1-3 (codex): same partial-commit guard as createVault — any failure322            // past slot A wipes the half-built vault and refuses adoption.323            val created = try {324                if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")325                store.stampLocalMutation()326                if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")327                true328            } catch (_: Exception) {329                store.clearVault()330                false331            }332            if (!created) return AdoptVaultResult.Error("storage_write_failed")333334            val previousDerivedKey = derivedKey335            val previousRootKey = rootKey336            derivedKey = nextDerivedKey337            rootKey = sessionRootKey338            nextDerivedKey = null339            sessionRootKey = null340            entryList = mutableListOf()341            duressConfigured = false342            recoveryWordHash = null343            recoveryWordsSet = false344            decoyMode = false345            publish()346            _isUnlocked.value = true347            previousDerivedKey?.let(VaultCrypto::zero)348            previousRootKey?.let(VaultCrypto::zero)349            AdoptVaultResult.Success(vaultId)350        } catch (e: Exception) {351            AdoptVaultResult.Error(e.message ?: "adoption_create_failed")352        } finally {353            adoptedRootKey?.let(VaultCrypto::zero)354            nextDerivedKey?.let(VaultCrypto::zero)355            sessionRootKey?.let(VaultCrypto::zero)356        }357    }358359    suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }360361    /** Unlock body without acquiring the mutex — callers must already hold it. */362    private fun unlockLocked(password: String): UnlockResult {363        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")364        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))365        val payload = try {366            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))367        } catch (_: Exception) {368            VaultCrypto.zero(key)369            // LV-1: a vault sealed before NFC normalization with a non-NFC370            // password derives a different key now. If the supplied password is371            // not already in NFC form, retry with the raw (un-normalized) bytes.372            if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {373                val legacy = tryLegacyRawUnlock(password, slotA)374                if (legacy != null) return legacy375            }376            return UnlockResult.WrongPassword377        }378        return adoptSlotA(key, payload)379    }380381    /**382     * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw383     * (un-normalized) password bytes. On success the slot is HEALED — re-sealed384     * under the NFC-normalized key — so every future unlock takes the normal385     * path. Returns null when the raw key also fails (genuinely wrong password)386     * OR when any step of the heal commits fails (the original slot A is restored387     * so disk and session stay aligned).388     */389    private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {390        val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))391        val payload = try {392            VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))393        } catch (_: Exception) {394            VaultCrypto.zero(rawKey)395            return null396        }397        // Heal: re-seal slot A under the NFC key (mirrors changePassword's398        // re-seal). Escrows carry forward unchanged — only the wrapping rotates.399        val newSalt = VaultCrypto.generateSalt()400        val newKey = VaultCrypto.deriveKey(password, newSalt)401        val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))402        val next = StoredVault(403            version = slotA.version,404            salt = b64(newSalt),405            blob = blob,406            recoveryEscrow = slotA.recoveryEscrow,407            rootKeyEscrow = slotA.rootKeyEscrow,408        )409        // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB,410        // or stampLocalMutation must restore the prior slot A AND slot B so disk411        // and the eventual session key never disagree on the wrapping.412        val priorB = store.loadSlotB()413        val healed = if (!store.writeSlotA(next)) {414            false415        } else try {416            if (!writeMatchingNoiseSlotB(next)) {417                runCatching { store.writeSlotA(slotA) }418                if (priorB != null) runCatching { store.writeSlotB(priorB) }419                false420            } else {421                store.stampLocalMutation()422                true423            }424        } catch (_: Exception) {425            runCatching { store.writeSlotA(slotA) }426            if (priorB != null) runCatching { store.writeSlotB(priorB) }427            false428        }429        return if (healed) {430            VaultCrypto.zero(rawKey)431            adoptSlotA(newKey, payload) // session key now matches the re-sealed slot432        } else {433            VaultCrypto.zero(newKey)434            adoptSlotA(rawKey, payload) // heal failed — keep using the raw key435        }436    }437438    /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */439    private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {440        return try {441            derivedKey = key442            decoyMode = false443            entryList = payload.entries.toMutableList()444            duressConfigured = payload.duressConfigured445            recoveryWordHash = payload.recoveryWordHash446            recoveryWordsSet = payload.recoveryWordsSet447            _settings.value = store.loadSettings()448            ensureRootKey(payload)449            autoPurgeExpiredTrash()450            publish()451            _isUnlocked.value = true452            UnlockResult.Success453        } catch (e: Exception) {454            UnlockResult.Error(e.message ?: "unlock_failed")455        }456    }457458    // ---- Constant-time unlock (MF-5) --------------------------------------459460    /**461     * A slot-A unlock that has been derived but not yet adopted. Holds the live462     * Argon2id key and decoded payload so the caller can derive several candidates463     * concurrently and commit only the winner. The key is owned by this token until464     * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it.465     */466    class SlotAUnlock internal constructor(467        internal val key: ByteArray,468        internal val payload: VaultPayload,469    )470471    /**472     * Derive the slot-A key and decrypt its payload WITHOUT mutating session state.473     * Pure computation (no mutex), so it can run concurrently with other candidate474     * derivations before the caller decides which to commit (MF-5). Returns null on475     * no-vault or wrong password (the derived key is zeroed on a tag mismatch).476     */477    fun tryUnlockSlotA(password: String): SlotAUnlock? {478        val slotA = store.loadSlotA() ?: return null479        val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))480        return try {481            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))482            SlotAUnlock(key, payload)483        } catch (_: Exception) {484            VaultCrypto.zero(key)485            null486        }487    }488489    /** Adopt a previously-derived [SlotAUnlock] as the live session. */490    suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {491        adoptSlotA(unlock.key, unlock.payload)492    }493494    /** Zero a derived [SlotAUnlock] that will not be adopted. */495    fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)496497    // ---- Duress slot B (MF-6) ---------------------------------------------498499    /**500     * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key501     * and decoded decoy payload so it can be derived concurrently with slot A and502     * only committed if the duress passphrase was the one entered (MF-5/MF-6).503     */504    class DuressUnlock internal constructor(505        internal val key: ByteArray,506        internal val payload: VaultPayload,507    )508509    /**510     * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the511     * session. Success means the entered password is the duress passphrase that keys512     * slot B — indistinguishable from the real slot A at the lock screen. Pure513     * computation (no mutex), safe to run concurrently with [tryUnlockSlotA].514     * Returns null when no duress slot exists or the password is not the duress one.515     */516    fun tryUnlockDuress(password: String): DuressUnlock? {517        val slot = store.loadDecoySlot() ?: return null518        val key = VaultCrypto.deriveKey(password, unb64(slot.salt))519        return try {520            val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))521            DuressUnlock(key, payload)522        } catch (_: Exception) {523            VaultCrypto.zero(key)524            null525        }526    }527528    /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */529    suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {530        return try {531            derivedKey = unlock.key532            decoyMode = true533            entryList = unlock.payload.entries.toMutableList()534            duressConfigured = false535            recoveryWordHash = unlock.payload.recoveryWordHash536            recoveryWordsSet = unlock.payload.recoveryWordsSet537            rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)538                      else VaultCrypto.generateRootKey()539            _settings.value = store.loadSettings()540            publish()541            _isUnlocked.value = true542            UnlockResult.Success543        } catch (e: Exception) {544            UnlockResult.Error(e.message ?: "decoy_unlock_failed")545        }546    }547548    /** Zero a derived [DuressUnlock] that will not be adopted. */549    fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)550551    /**552     * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an553     * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The554     * passphrase is never stored — only its ability to decrypt slot B proves it.555     * Requires the real vault to be unlocked and not itself a decoy session.556     */557    suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {558        if (derivedKey == null || decoyMode) return false559        // Reject a duress passphrase that also opens the real vault: it would make the560        // lock screen ambiguous (both slots would decrypt for the same input).561        tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }562        return try {563            val salt = VaultCrypto.generateSalt()564            val key = VaultCrypto.deriveKey(duressPassphrase, salt)565            // An empty decoy vault with its own fresh sync root key — nothing real leaks.566            val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))567            val plaintext = VaultCodec.encode(decoyPayload)568            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))569            VaultCrypto.zero(key)570            VaultCrypto.zero(plaintext)571            val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))572            if (ok) {573                duressConfigured = true574                runCatching { persistSlotA(bumpVersion = false) }575            }576            ok577        } catch (_: Exception) {578            false579        }580    }581582    /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */583    suspend fun clearDuress(): Boolean = mutex.withLock {584        store.clearDecoySlot()585        if (derivedKey != null && !decoyMode) {586            duressConfigured = false587            runCatching { persistSlotA(bumpVersion = false) }588        }589        true590    }591592    /** Unlock with an already-derived AES-256 key (biometric path). */593    suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {594        val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")595        return try {596            val payload = VaultCodec.decode(597                VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),598            )599            derivedKey = key.copyOf()600            decoyMode = false601            entryList = payload.entries.toMutableList()602            duressConfigured = payload.duressConfigured603            recoveryWordHash = payload.recoveryWordHash604            recoveryWordsSet = payload.recoveryWordsSet605            _settings.value = store.loadSettings()606            ensureRootKey(payload)607            autoPurgeExpiredTrash()608            publish()609            _isUnlocked.value = true610            UnlockResult.Success611        } catch (_: Exception) {612            UnlockResult.WrongPassword613        }614    }615616    fun lock() {617        derivedKey?.let(VaultCrypto::zero)618        rootKey?.let(VaultCrypto::zero)619        derivedKey = null620        rootKey = null621        entryList = mutableListOf()622        duressConfigured = false623        recoveryWordHash = null624        recoveryWordsSet = false625        decoyMode = false626        _entries.value = emptyList()627        _isUnlocked.value = false628    }629630    @Volatile private var suppressNextBackgroundLock = false631632    /**633     * Call immediately before launching a system Activity (a SAF file634     * picker/save dialog, a share sheet) that briefly takes the whole process635     * out of the foreground as an expected part of an in-app flow — not a636     * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the637     * two apart on its own (it fires for both), and without this, exporting or638     * importing a file silently locked and wiped the vault mid-flow the moment639     * the picker took over, before its result ever reached the app.640     */641    fun expectTransientBackground() {642        suppressNextBackgroundLock = true643    }644645    /** Consumed once by the next background-lock check; see [expectTransientBackground]. */646    fun consumeTransientBackgroundSuppression(): Boolean {647        val suppressed = suppressNextBackgroundLock648        suppressNextBackgroundLock = false649        return suppressed650    }651652    // ---- Sync identity ----------------------------------------------------653654    /**655     * The vault's stable sync master key (a copy). Caller must zero it. Backs656     * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry657     * envelope keys. Null when locked. See [VaultPayload.rootKeyB64].658     */659    /**660     * HOLE 5 — the duress vault never syncs.661     *662     * [commitDuressUnlock] gives the decoy session a root key so its own codex663     * and recovery paths work, which means (unlike the browser, where the decoy664     * path simply has no root key) the sync surface has to refuse explicitly.665     * Without this the decoy vault would present a second, different vault_id666     * to a paired device, and a coerced user opening it would make sync visibly667     * fail on their other devices — a duress indicator, which is the one thing668     * a duress vault must never be.669     */670    fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf()671672    /**673     * The current session's root key, decoy included. For local-only uses that674     * must behave identically in a decoy session — Codex backup and recovery675     * words — because a feature that errors under duress is itself a duress676     * tell. In a decoy session this is the DECOY's root key (see677     * [commitDuressUnlock]), never the real vault's, so neither surface can678     * leak the real vault. Anything that leaves the device must use679     * [syncMasterKey] instead.680     */681    fun sessionRootKey(): ByteArray? = rootKey?.copyOf()682683    /** Stable per-vault id two paired devices must share to sync. Null when684     *  locked, and never present for the decoy vault (see [syncMasterKey]). */685    fun syncVaultId(): String? =686        if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }687688    /**689     * Browser parity (MV-8): rotate the sync identity by replacing the stable690     * root key. Entries remain under the master-password key, while sync691     * credentials, vault_id, and recovery words all change.692     */693    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {694        installRootKey(generateRecoverableRootKey())695    }696697    /**698     * Join the vault behind [rootKeyHex]: adopt that vault's identity while699     * keeping every local entry.700     *701     * Sync identity (vault id, signing keys) derives from the root key, and702     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently703     * created vaults can never reconcile. Taking the other side's root key is704     * what makes them one vault; from the next sync on, both entry sets merge.705     *706     * Entries survive because they are sealed under the password-derived key,707     * not the root key — the same property [rotateSyncIdentity] relies on.708     *709     * CONSEQUENCE, which the caller must have disclosed and had confirmed:710     * this device's previous recovery words stop working for good, and its new711     * words are the source vault's words, because words are a deterministic712     * encoding of the root key.713     */714    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {715        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {716            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")717        }718        if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")719        if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")720        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")721722        val incoming = hexToRootKey(rootKeyHex)723        if (incoming.size != VaultCrypto.KEY_SIZE) {724            VaultCrypto.zero(incoming)725            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")726        }727        // Refuse before committing rather than leave this device holding a vault728        // whose recovery words cannot be typed back. generateRecoverableRootKey729        // screens its own keys for this; a key arriving from a peer has not been.730        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess731        if (!roundTrips) {732            VaultCrypto.zero(incoming)733            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")734        }735736        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)737        return if (installRootKey(incoming)) {738            AdoptVaultResult.Success(vaultId)739        } else {740            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")741        }742    }743744    /**745     * The recovery words [rootKeyHex] will produce once joined, or null if it is746     * not a usable root key. Lets the join flow show the user the words that are747     * about to replace theirs *before* anything is committed.748     */749    fun previewRecoveryWords(rootKeyHex: String): List<String>? {750        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null751        val bytes = hexToRootKey(rootKeyHex)752        return try {753            val words = rootKeyToWords(bytes)754            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null755        } catch (_: Exception) {756            null757        } finally {758            VaultCrypto.zero(bytes)759        }760    }761762    /**763     * Swap the vault's root key, keeping every entry, and re-escrow under the764     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds765     * [mutex].766     */767    private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {768        val key = derivedKey ?: return false769        if (decoyMode) return false770        val priorSlot = store.loadSlotA() ?: return false771        val priorRootKey = rootKey ?: return false772        val priorRecoveryHash = recoveryWordHash773        val priorRecoverySet = recoveryWordsSet774        val nextWords = rootKeyToWords(nextRootKey)775776        return try {777            takeVaultSnapshot("local_save")778            rootKey = nextRootKey779            recoveryWordHash = recoveryWordsHash(nextWords)780            recoveryWordsSet = false781782            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)783                ?: throw IllegalStateException("recovery_escrow_failed")784            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)785                ?: throw IllegalStateException("root_escrow_failed")786            val plaintext = VaultCodec.encode(currentPayload())787            val blob = try {788                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))789            } finally {790                VaultCrypto.zero(plaintext)791            }792            val nextSlot = priorSlot.copy(793                blob = blob,794                recoveryEscrow = nextRecoveryEscrow,795                rootKeyEscrow = nextRootKeyEscrow,796            )797            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")798            try {799                writeMatchingNoiseSlotB(nextSlot)800                store.stampLocalMutation()801            } catch (e: Exception) {802                store.writeSlotA(priorSlot)803                throw e804            }805            VaultCrypto.zero(priorRootKey)806            publish()807            true808        } catch (_: Exception) {809            rootKey = priorRootKey810            recoveryWordHash = priorRecoveryHash811            recoveryWordsSet = priorRecoverySet812            VaultCrypto.zero(nextRootKey)813            store.writeSlotA(priorSlot)814            false815        }816    }817818    /**819     * A copy of the live Argon2id-derived vault key, for biometric escrow only820     * (wrapped under a hardware KeyStore key on biometric enable). Null when821     * locked. Caller must [VaultCrypto.zero] it after use.822     */823    fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()824825    suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {826        val slotA = store.loadSlotA() ?: return false827        var oldKey: ByteArray? = null828        var newKey: ByteArray? = null829        var plaintext: ByteArray? = null830        return try {831            oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))832            val payload = VaultCodec.decode(833                VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),834            )835            val newSalt = VaultCrypto.generateSalt()836            newKey = VaultCrypto.deriveKey(newPassword, newSalt)837            // Re-seal the existing payload under the new key/salt.838            plaintext = VaultCodec.encode(payload)839            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))840            // The escrow seals the slot-A PLAINTEXT, which a password change does not841            // alter — only the wrapping changes — so carry it forward; rebuilding the842            // StoredVault from scratch would silently drop it.843            // Carry BOTH escrows forward unchanged: each seals plaintext/root-key844            // content that a password change does not alter — only the slot-A845            // wrapping rotates. The root-key escrow staying stable across password846            // changes matches the browser (`vault.ts:363-364`).847            val next = StoredVault(848                version = slotA.version,849                salt = b64(newSalt),850                blob = blob,851                recoveryEscrow = slotA.recoveryEscrow,852                rootKeyEscrow = slotA.rootKeyEscrow,853            )854            // H-01: durably commit slot A + size-matched noise + version stamp855            // before swapping the session key. A partial commit (new A on disk,856            // old key in memory) would have the next save encrypt under the old857            // key while matching disk on the new one.858            val priorB = store.loadSlotB()859            if (!commitVaultRotation(slotA, priorB, next)) return false860            VaultCrypto.zero(oldKey); oldKey = null861            derivedKey?.let(VaultCrypto::zero)862            derivedKey = newKey; newKey = null863            true864        } catch (_: Exception) {865            false866        } finally {867            oldKey?.let(VaultCrypto::zero)868            newKey?.let(VaultCrypto::zero)869            plaintext?.let(VaultCrypto::zero)870        }871    }872873    /**874     * Irreversible wipe. Locks the session and clears every vault key. The Pro875     * license is in a separate store and is preserved by construction.876     */877    suspend fun destroyVault() = mutex.withLock {878        lock()879        store.clearVault()880        store.clearDecoySlot()881    }882883    // ---- Entry CRUD -------------------------------------------------------884885    suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {886        requireUnlocked()887        val stamp = VaultCrypto.monotonicEntryTimestamp()888        val now = System.currentTimeMillis()889        val entry = withDerivedLast4(draft.copy(890            id = draft.id.ifBlank { UUID.randomUUID().toString() },891            createdAt = if (draft.createdAt > 0) draft.createdAt else now,892            modifiedAt = stamp,893            passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,894        ))895        val rollback = entryList.toMutableList()896        entryList.add(entry)897        persistOrRollback(rollback)898        entry899    }900901    /**902     * Insert many entries with a single encrypt-and-write at the end.903     *904     * Importing a real vault through [addEntry] was quadratic: every entry took905     * the lock and re-encrypted and rewrote the whole vault, so an 806-entry906     * restore did 806 full-vault writes and hung the app long enough to ANR.907     * Rolls back to the pre-import list if the one write fails, matching908     * [addEntry]'s all-or-nothing behaviour.909     */910    suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {911        requireUnlocked()912        if (drafts.isEmpty()) return 0913        val rollback = entryList.toMutableList()914        val now = System.currentTimeMillis()915        for (draft in drafts) {916            val stamp = VaultCrypto.monotonicEntryTimestamp()917            entryList.add(918                withDerivedLast4(919                    draft.copy(920                        id = draft.id.ifBlank { UUID.randomUUID().toString() },921                        createdAt = if (draft.createdAt > 0) draft.createdAt else now,922                        modifiedAt = stamp,923                        passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,924                    ),925                ),926            )927        }928        persistOrRollback(rollback)929        return drafts.size930    }931932    suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {933        requireUnlocked()934        val idx = entryList.indexOfFirst { it.id == patch.id }935        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")936        val current = entryList[idx]937        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)938        // Only refresh passwordChangedAt when the password value actually changes.939        val passwordChanged = patch.password != null && patch.password != current.password940        // Capture the outgoing password (newest first) so the user can reveal/copy/941        // restore it later. History is computed authoritatively from `current`, not942        // the patch — updateEntry is a full-entry replace, so trusting patch's value943        // would let a stale form clobber the real history. Record the age the old944        // password carried, skip an empty prior, and skip an immediate exact dupe945        // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX.946        val priorPassword = current.password947        val shouldCapture = passwordChanged &&948            !priorPassword.isNullOrEmpty() &&949            current.passwordHistory?.firstOrNull()?.password != priorPassword950        val nextHistory = if (shouldCapture) {951            (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +952                (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)953        } else {954            current.passwordHistory955        }956        val updated = withDerivedLast4(patch.copy(957            createdAt = current.createdAt,958            modifiedAt = stamp,959            passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,960            passwordHistory = nextHistory,961        ))962        val rollback = entryList.toMutableList()963        entryList[idx] = updated964        persistOrRollback(rollback)965        updated966    }967968    /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */969    suspend fun deleteEntry(id: String) = mutex.withLock {970        requireUnlocked()971        val idx = entryList.indexOfFirst { it.id == id }972        if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")973        val current = entryList[idx]974        val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)975        val rollback = entryList.toMutableList()976        entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)977        persistOrRollback(rollback)978    }979980    suspend fun restoreEntry(id: String) = mutex.withLock {981        requireUnlocked()982        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }983        if (idx == -1) throw IllegalStateException("deleted entry not found")984        val current = entryList[idx]985        val rollback = entryList.toMutableList()986        entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))987        persistOrRollback(rollback)988    }989990    /** Permanent delete — no tombstone, no recovery. */991    suspend fun purgeEntry(id: String) = mutex.withLock {992        requireUnlocked()993        val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }994        if (idx == -1) throw IllegalStateException("deleted entry not found")995        val rollback = entryList.toMutableList()996        entryList.removeAt(idx)997        persistOrRollback(rollback)998    }9991000    /** Permanently empty the trash — hard-deletes every soft-deleted entry. */1001    suspend fun purgeDeletedEntries(): Int = mutex.withLock {1002        requireUnlocked()1003        val rollback = entryList.toMutableList()1004        val before = entryList.size1005        entryList.removeAll { it.deletedAt != null }1006        val removed = before - entryList.size1007        if (removed > 0) persistOrRollback(rollback)1008        removed1009    }10101011    /** Browser parity: number of active entries that dedupe would move to trash. */1012    suspend fun countDuplicateEntries(): Int = mutex.withLock {1013        requireUnlocked()1014        countDuplicateEntriesLocked()1015    }10161017    /**1018     * Browser parity: soft-delete duplicate entries, keeping the most recently1019     * modified member of each group. Refuse in decoy mode so the duress vault is1020     * not reshaped into a forensically distinct state.1021     */1022    suspend fun deduplicateEntries(): Int = mutex.withLock {1023        requireUnlocked()1024        if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1025        val active = entryList.filter { it.deletedAt == null }1026        val keepIds = mutableSetOf<String>()1027        active.groupBy { it.duplicateKey() }.values.forEach { group ->1028            keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1029        }10301031        val rollback = entryList.toMutableList()1032        var removed = 01033        entryList = entryList.map { entry ->1034            if (entry.deletedAt == null && entry.id !in keepIds) {1035                removed++1036                val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1037                entry.copy(deletedAt = stamp, modifiedAt = stamp)1038            } else {1039                entry1040            }1041        }.toMutableList()1042        if (removed > 0) persistOrRollback(rollback)1043        removed1044    }10451046    fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1047    fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1048    fun getDeletedEntries(): List<VaultEntry> =1049        entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }10501051    /** Active entries, optionally narrowed to those carrying [tagFilter]. */1052    fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1053        val active = entryList.filter { it.deletedAt == null }1054        if (tagFilter == null) return active1055        val needle = tagFilter.trim().lowercase()1056        return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1057    }10581059    /** Sorted, de-duplicated tags across all active entries. */1060    fun getDistinctTags(): List<String> =1061        entryList.asSequence()1062            .filter { it.deletedAt == null }1063            .flatMap { it.tags?.asSequence() ?: emptySequence() }1064            .map { it.trim().lowercase() }1065            .filter { it.isNotEmpty() }1066            .distinct()1067            .sorted()1068            .toList()10691070    /**1071     * Distinct non-blank values for an identity field across active entries,1072     * most-frequently-used first. Supports "username" and "email"; unknown field1073     * names return an empty list. Values keep original casing (deduped1074     * case-insensitively) so a suggestion matches what the user typed.1075     */1076    fun getDistinctFieldValues(field: String): List<String> {1077        val select: (VaultEntry) -> String? = when (field) {1078            "username" -> { e -> e.username }1079            "email" -> { e -> e.email }1080            else -> return emptyList()1081        }1082        return entryList.asSequence()1083            .filter { it.deletedAt == null }1084            .mapNotNull { select(it)?.trim() }1085            .filter { it.isNotEmpty() }1086            .groupingBy { it.lowercase() }1087            .aggregate { _, acc: Pair<String, Int>?, value, _ ->1088                Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1089            }1090            .values1091            .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1092            .map { it.first }1093    }10941095    fun getTombstones(): List<Tombstone> =1096        entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1097    fun getVaultVersion(): Long = store.version()1098    fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots10991100    suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1101        val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1102        val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1103        val priorA = store.loadSlotA()1104        val priorB = store.loadSlotB()1105        // H-02: capture pre-restore state as the recovery snapshot BEFORE any1106        // slot write — the old code took this snapshot, then stranded it on1107        // every failure, polluting history with a "restore" entry that never1108        // restored anything. The history below is rolled back together with1109        // the slots if the commit fails.1110        val priorHistory = store.loadVaultHistory()1111        // P1-4 (codex): the recovery snapshot must land before we commit the1112        // slot pair, otherwise a failed restore cannot be rolled back to the1113        // pre-restore history. If the history write itself fails, refuse to1114        // proceed — the alternative is a successful restore with no undo path.1115        if (!takeVaultSnapshot("restore")) {1116            return false1117        }1118        if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) {1119            store.writeVaultHistory(priorHistory)1120            return false1121        }1122        lock()1123        true1124    }11251126    /**1127     * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore.1128     * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B1129     * (a restore preserves the duress-shaped blob from history), not a fresh1130     * noise record. Any failure rolls back so the at-rest pair never holds a1131     * restored slot A against a pre-restore slot B. Caller is responsible for1132     * restoring the pre-snapshot history on a false return.1133     */1134    private fun commitRestoreAtomic(1135        nextA: StoredVault,1136        nextB: String,1137        priorA: StoredVault?,1138        priorB: String?,1139    ): Boolean {1140        if (!store.writeSlotA(nextA)) return false1141        // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it1142        // must trigger rollback; previously a throw escaped without restoring1143        // the prior slot A and history.1144        try {1145            if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1146            store.stampLocalMutation()1147        } catch (_: Exception) {1148            rollbackRestore(priorA, priorB)1149            return false1150        }1151        return true1152    }11531154    // ---- Sync apply (does NOT advance the version) ------------------------11551156    /**1157     * Apply remote entryList + tombstones with per-entry last-write-wins. Persists1158     * WITHOUT bumping the version counter, so a sync round cannot cascade into1159     * another outbound sync. Returns the number of locally-applied changes.1160     */1161    suspend fun applyMergedRemoteEntries(1162        remoteEntries: List<VaultEntry>,1163        remoteTombstones: List<Tombstone>,1164    ): Int = mutex.withLock {1165        requireUnlocked()1166        // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS1167        // BEFORE any comparison against a local one. Without this a peer can1168        // send a far-future modifiedAt and permanently pin its version of an1169        // entry against every honest local edit. The browser clamps in the1170        // matching place (vault.ts applyMergedRemoteEntries).1171        val mergeNow = System.currentTimeMillis()1172        val clamped = remoteEntries.map { entry ->1173            entry.copy(1174                modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1175                deletedAt = entry.deletedAt?.let {1176                    SyncLimits.clampSyncTimestamp(it, mergeNow)1177                },1178            )1179        }1180        val tombMap = remoteTombstones.associate {1181            it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1182        }1183        var applied = 01184        val byId = entryList.associateBy { it.id }.toMutableMap()11851186        for (remote in clamped) {1187            val tomb = tombMap[remote.id]1188            if (tomb != null && tomb >= remote.modifiedAt) continue1189            val local = byId[remote.id]1190            if (local == null) {1191                entryList.add(remote); byId[remote.id] = remote; applied++1192            } else if (remote.modifiedAt > local.modifiedAt) {1193                val i = entryList.indexOfFirst { it.id == local.id }1194                if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }1195            }1196        }11971198        for ((id, deletedAt) in tombMap) {1199            val local = byId[id] ?: continue1200            if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1201            if (deletedAt < local.modifiedAt) continue1202            val i = entryList.indexOfFirst { it.id == id }1203            if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }1204        }12051206        if (applied > 0) {1207            persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1208            publish()1209        }1210        applied1211    }12121213    suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1214        _settings.value = settings1215        store.writeSettings(settings)1216    }12171218    // ---- Recovery words (vault metadata) ----------------------------------12191220    /** True once the user has confirmed their recovery words for this vault. */1221    fun isRecoveryWordsSet(): Boolean = recoveryWordsSet12221223    /**1224     * Record the BLAKE3 hash of the confirmed recovery words and mark them set.1225     * Persisted without bumping the version — recovery metadata is local-only and1226     * is not an entry mutation paired devices should sync against.1227     */1228    suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1229        requireUnlocked()1230        recoveryWordHash = recoveryWordsHash(words)1231        recoveryWordsSet = true1232        // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow1233        // (cross-platform recovery of the sync root key) and Android's whole-payload1234        // escrow (offline forgot-password restore of entries). Both derive their key1235        // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs.1236        runCatching {1237            persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1238        }.getOrDefault(false)1239    }12401241    /** Verify entered words against the stored recovery hash. */1242    fun verifyRecoveryWords(words: List<String>): Boolean {1243        val stored = recoveryWordHash ?: return false1244        return stored == recoveryWordsHash(words)1245    }12461247    // ---- Recovery escrow (forgot-password) --------------------------------12481249    /** True at rest if this vault has any recovery escrow the recovery flow can use. */1250    fun hasRecoveryEscrow(): Boolean =1251        store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false12521253    /**1254     * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of1255     * the master password. On success the slot-A plaintext sealed in the escrow is1256     * adopted as the live session (entries, rootKey, settings), but no Argon2id1257     * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a1258     * new password and finish unlocking. Returns false when there is no escrow, the1259     * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch).1260     *1261     * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no1262     * detached vault key to return here — slot A is wrapped under the rotating1263     * Argon2id key — so this opens the escrowed payload directly.1264     */1265    suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1266        val slotA = store.loadSlotA() ?: return false1267        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false12681269        // Prefer Android's whole-payload escrow: a full offline restore of entries,1270        // root key, and settings. Present for Android-created vaults.1271        slotA.recoveryEscrow?.let { escrow ->1272            val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1273            val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1274            val keyBytes = recoveryKey.encoded1275            try {1276                val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1277                val payload = VaultCodec.decode(plaintext)1278                decoyMode = false1279                entryList = payload.entries.toMutableList()1280                duressConfigured = payload.duressConfigured1281                recoveryWordHash = payload.recoveryWordHash1282                recoveryWordsSet = payload.recoveryWordsSet1283                rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1284                          else generateRecoverableRootKey()1285                _settings.value = store.loadSettings()1286                VaultCrypto.zero(plaintext)1287                return true1288            } catch (_: Exception) {1289                // Wrong words against this escrow — fall through to the root-key escrow.1290            } finally {1291                VaultCrypto.zero(material)1292                VaultCrypto.zero(keyBytes)1293            }1294        }12951296        // Browser-format vault (only the root-key escrow): recover the 32-byte root1297        // key (sync identity) and reset the password; entries return via sync. This1298        // is exactly the browser's recovery semantics (`messages.ts:312-347`).1299        slotA.rootKeyEscrow?.let { escrow ->1300            val rk = unsealRootKeyEscrow(words, escrow) ?: return false1301            decoyMode = false1302            entryList = mutableListOf()1303            duressConfigured = false1304            recoveryWordHash = recoveryWordsHash(words)1305            recoveryWordsSet = true1306            rootKey = rk1307            _settings.value = store.loadSettings()1308            return true1309        }1310        return false1311    }13121313    /**1314     * After [unlockWithRecoveryWords] succeeds, set a brand-new master password:1315     * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same1316     * words, mark the session fully unlocked, and publish. Returns false if called1317     * without a prior successful recovery unlock (no in-memory root key).1318     *1319     * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)`1320     * into one step, since there is no detached vault key here.1321     */1322    suspend fun resetPasswordWithRecoveryWords(1323        words: List<String>,1324        newPassword: String,1325    ): Boolean = mutex.withLock {1326        if (rootKey == null) return false // must have recovered first1327        val priorA = store.loadSlotA() ?: return false1328        var newKey: ByteArray? = null1329        var plaintext: ByteArray? = null1330        return try {1331            val newSalt = VaultCrypto.generateSalt()1332            newKey = VaultCrypto.deriveKey(newPassword, newSalt)1333            plaintext = VaultCodec.encode(currentPayload())1334            val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1335            // Rebuild both escrows against the recovered session so their salts rotate1336            // with the password reset; fall back to the prior escrows if rebuild fails1337            // (e.g. a checksum-invalid phrase). The same 12 words keep working.1338            val valid = words.size == 12 && findInvalidWords(words).isEmpty()1339            val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1340            val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1341            val next = StoredVault(1342                version = priorA.version,1343                salt = b64(newSalt),1344                blob = blob,1345                recoveryEscrow = freshRecovery,1346                rootKeyEscrow = freshRootKey,1347            )1348            // H-01: see changePassword — both rotations share the same1349            // commit-or-keep-old-key contract so a partial write can't strand1350            // the session on a key that no longer matches disk.1351            val priorB = store.loadSlotB()1352            if (!commitVaultRotation(priorA, priorB, next)) return false1353            derivedKey?.let(VaultCrypto::zero)1354            derivedKey = newKey; newKey = null1355            publish()1356            _isUnlocked.value = true1357            true1358        } catch (_: Exception) {1359            false1360        } finally {1361            newKey?.let(VaultCrypto::zero)1362            plaintext?.let(VaultCrypto::zero)1363        }1364    }13651366    /**1367     * Seal the current slot-A plaintext under a key derived from the 12 BIP391368     * recovery [words] (Android offline-restore escrow). IKM is the browser's1369     * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY].1370     * Returns null for a checksum-invalid phrase.1371     */1372    private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1373        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1374        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1375        val salt = VaultCrypto.generateSalt()1376        val recoveryKey = deriveRecoveryKey(material, salt)1377        val keyBytes = recoveryKey.encoded1378        val plaintext = VaultCodec.encode(currentPayload())1379        return try {1380            val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1381            RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1382        } finally {1383            VaultCrypto.zero(material)1384            VaultCrypto.zero(keyBytes)1385            VaultCrypto.zero(plaintext)1386        }1387    }13881389    /**1390     * Seal ONLY the 32-byte root key under a key the recovery [words] derive,1391     * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex1392     * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16,1393     * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap1394     * the same root key on the browser. Returns null with no in-memory root key or1395     * a checksum-invalid phrase.1396     */1397    private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1398        if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1399        val rk = rootKey ?: return null1400        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1401        val salt = VaultCrypto.generateSalt()1402        val recoveryKey = deriveRecoveryKey(material, salt)1403        val keyBytes = recoveryKey.encoded1404        val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1405        return try {1406            // combined = nonce(12) || ciphertext || tag(16)1407            val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1408            val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1409            val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1410            val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1411            val ct = ctTag.copyOfRange(0, ctTag.size - 16)1412            RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1413        } finally {1414            VaultCrypto.zero(material)1415            VaultCrypto.zero(keyBytes)1416            VaultCrypto.zero(plaintext)1417        }1418    }14191420    /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */1421    private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1422        val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1423        val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1424        val keyBytes = recoveryKey.encoded1425        return try {1426            val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1427            val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1428            hexToRootKey(String(pt, Charsets.US_ASCII))1429        } catch (_: Exception) {1430            null1431        } finally {1432            VaultCrypto.zero(material)1433            VaultCrypto.zero(keyBytes)1434        }1435    }14361437    /**1438     * Re-seal slot A from the current in-memory state under the live key, carrying1439     * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs1440     * in decoy mode (recovery is never offered for the decoy) and persists without1441     * a version bump, like the recovery-words metadata write it replaces.1442     */1443    private fun persistSlotAWithEscrows(1444        recoveryEscrow: RecoveryEscrow?,1445        rootKeyEscrow: RootKeyEscrow?,1446    ): Boolean {1447        val key = derivedKey ?: throw IllegalStateException("vault_locked")1448        if (decoyMode) return false1449        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1450        val plaintext = VaultCodec.encode(currentPayload())1451        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1452        val next = prior.copy(1453            blob = blob,1454            recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1455            rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1456        )1457        if (!store.writeSlotA(next)) return false1458        try {1459            writeMatchingNoiseSlotB(next)1460        } catch (e: Exception) {1461            store.writeSlotA(prior)1462            throw e1463        }1464        return true1465    }14661467    // ---- Internals --------------------------------------------------------14681469    private fun requireUnlocked() {1470        if (derivedKey == null) throw IllegalStateException("vault_locked")1471    }14721473    private fun countDuplicateEntriesLocked(): Int {1474        val seen = mutableMapOf<String, VaultEntry>()1475        var duplicates = 01476        for (entry in entryList.filter { it.deletedAt == null }) {1477            val key = entry.duplicateKey()1478            val existing = seen[key]1479            if (existing == null) {1480                seen[key] = entry1481            } else {1482                duplicates++1483                val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1484                val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1485                if (entryStamp > existingStamp) seen[key] = entry1486            }1487        }1488        return duplicates1489    }14901491    private fun VaultEntry.duplicateKey(): String {1492        if (type == "note") return "note|${title.lowercase()}"1493        val passkeyData = passkey1494        if (passkeyData != null) {1495            return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1496        }1497        return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1498    }14991500    /**1501     * Capture the current slot pair (and metadata) into the history list.1502     * Returns true when the history write landed, false when it didn't — callers1503     * that depend on the snapshot existing (notably [restoreVaultSnapshot])1504     * must check the result and roll back if it does not.1505     */1506    private fun takeVaultSnapshot(reason: String): Boolean {1507        val slotA = store.loadSlotA() ?: return false1508        val vaultA = VaultCodec.encodeSlot(slotA)1509        val vaultB = store.loadSlotB() ?: slotA.blob1510        val history = store.loadVaultHistory()1511        val latest = history.snapshots.firstOrNull()1512        if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true1513        val entryCount = if (derivedKey != null && !decoyMode) {1514            bucketEntryCount(entryList.count { it.deletedAt == null })1515        } else {1516            null1517        }1518        return try {1519            val snapshot = VaultSnapshot(1520                id = UUID.randomUUID().toString(),1521                savedAt = System.currentTimeMillis(),1522                reason = reason,1523                vault_a = vaultA,1524                vault_b = vaultB,1525                entryCount = entryCount,1526            )1527            // P1-4 (codex): return the history-write boolean to callers so a1528            // false return no longer leaves the snapshot out of history while1529            // callers (notably restoreVaultSnapshot) proceed as if it succeeded.1530            store.writeVaultHistory(1531                history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1532            )1533        } catch (_: Exception) {1534            false1535        }1536    }15371538    private fun bucketEntryCount(n: Int): Int = when {1539        n <= 0 -> 01540        n <= 10 -> 101541        n <= 25 -> 251542        n <= 50 -> 501543        n <= 100 -> 1001544        n <= 250 -> 2501545        n <= 500 -> 5001546        else -> ((n + 499) / 500) * 5001547    }15481549    /**1550     * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),1551     * so it is stored explicitly and survives the sync wire format rather than being1552     * re-derived at render time. Non-card entries pass through unchanged.1553     */1554    private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1555        if (!entry.isCard()) return entry1556        val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1557        return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1558    }15591560    /**1561     * On unlock, hard-delete trashed entries older than the retention window.1562     * A null or non-positive [VaultSettings.trashRetentionDays] means "never"1563     * (the app's "Never" option), so nothing is purged. Persists without a1564     * version bump and without touching the publish state (caller publishes).1565     */1566    private fun autoPurgeExpiredTrash() {1567        val days = _settings.value.trashRetentionDays ?: return1568        if (days <= 0) return1569        val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1570        val before = entryList.size1571        entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1572        if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1573    }15741575    /** The slot-A plaintext envelope for the current in-memory session. */1576    private fun currentPayload(): VaultPayload =1577        VaultPayload(1578            entries = entryList.toList(),1579            duressConfigured = duressConfigured,1580            rootKeyB64 = rootKey?.let(::b64) ?: "",1581            recoveryWordHash = recoveryWordHash,1582            recoveryWordsSet = recoveryWordsSet,1583        )15841585    /**1586     * Adopt the payload's root key, lazily minting one for legacy payloads.1587     * A freshly-minted key is persisted WITHOUT bumping the version — adopting1588     * a root key is not a mutation paired devices should sync against.1589     */1590    private fun ensureRootKey(payload: VaultPayload) {1591        if (payload.rootKeyB64.isNotBlank()) {1592            rootKey = unb64(payload.rootKeyB64)1593            return1594        }1595        rootKey = generateRecoverableRootKey()1596        runCatching { persistSlotA(bumpVersion = false) }1597    }15981599    private fun publish() {1600        _entries.value = entryList.filter { it.deletedAt == null }1601    }16021603    private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1604        val plaintext = VaultCodec.encode(currentPayload())1605        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1606        return StoredVault(version = 1, salt = b64(salt), blob = blob)1607    }16081609    private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1610        // Size the noise so its base64 length matches slot A's blob (MN-1): with the1611        // plaintext now bucket-padded, an at-rest observer sees two equal-length slots1612        // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars.1613        val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1614        val noise = ByteArray(approxBytes).also(rng::nextBytes)1615        return store.writeSlotB(b64(noise))1616    }16171618    /** Re-seal slot A from the current in-memory state under the live key. */1619    private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1620        val key = derivedKey ?: throw IllegalStateException("vault_locked")1621        // Decoy sessions persist to the decoy slot only — never touch the real slot A.1622        if (decoyMode) {1623            val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1624            val plaintext = VaultCodec.encode(currentPayload())1625            val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1626            return store.writeDecoySlot(prior.copy(blob = blob))1627        }1628        val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1629        // H-01: captured BEFORE the noise write so a stamp failure can restore1630        // the prior slot B alongside the prior slot A. Slot B is always1631        // overwritten by writeMatchingNoiseSlotB below, so a stamp failure1632        // leaves (new A, new B, old version) on disk — partial commit.1633        val priorB = store.loadSlotB()1634        if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1635        val plaintext = VaultCodec.encode(currentPayload())1636        val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1637        val next = prior.copy(blob = blob)1638        if (!store.writeSlotA(next)) return false1639        // Slot A committed — keep slot B size-matched, then advance the version1640        // for local writes only. Restore both A and B if a later step fails.1641        // P1-1 (codex): the noise write's boolean AND any thrown exception from1642        // it must trigger rollback; previously the call sat outside the try.1643        try {1644            if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")1645            if (bumpVersion) store.stampLocalMutation()1646        } catch (e: Exception) {1647            store.writeSlotA(prior)1648            if (priorB != null) runCatching { store.writeSlotB(priorB) }1649            throw e1650        }1651        return true1652    }16531654    /**1655     * H-01: durably commit a slot-A rotation by writing slot A, its1656     * size-matched noise slot B, and the local-mutation stamp in order. Any1657     * failure rolls the previously-committed steps back so the at-rest pair1658     * matches what was there before the call. Returns true only when every1659     * step succeeded — callers must NOT swap their session key on false.1660     */1661    private fun commitVaultRotation(1662        priorA: StoredVault,1663        priorB: String?,1664        nextA: StoredVault,1665    ): Boolean {1666        if (!store.writeSlotA(nextA)) return false1667        // P1-1 (codex): the noise write's boolean AND any thrown exception from1668        // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB1669        // escaped the helper and left slot A committed.1670        try {1671            if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")1672            store.stampLocalMutation()1673        } catch (_: Exception) {1674            rollbackRotation(priorA, priorB)1675            return false1676        }1677        return true1678    }16791680    /**1681     * Best-effort undo of the writes [commitVaultRotation] may have committed.1682     * Each step runs independently — a failed rollback must not mask the1683     * original commit failure. Log is a throwing stub off-device.1684     */1685    private fun rollbackRotation(priorA: StoredVault, priorB: String?) {1686        val failures = mutableListOf<String>()1687        runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }1688            .onFailure { failures += "writeSlotA: $it" }1689        if (priorB != null) {1690            runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }1691                .onFailure { failures += "writeSlotB: $it" }1692        }1693        if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }1694    }16951696    /** Persist a local mutation, rolling the in-memory list back on failure. */1697    private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1698        try {1699            if (!persistSlotA(bumpVersion = true)) {1700                entryList = rollback1701                throw IllegalStateException("storage_write_failed")1702            }1703        } catch (e: Exception) {1704            entryList = rollback1705            throw e1706        }1707        publish()1708    }1709}