package com.peachpasswords.vault import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.security.SecureRandom import java.text.Normalizer import kotlinx.serialization.json.Json import android.util.Log import com.peachpasswords.BuildConfig import com.peachpasswords.sync.SyncLimits import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import java.util.Base64 import java.util.UUID import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject import javax.inject.Singleton /** Lenient parser for the Codex vault-blob wrapper only. */ private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true } private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes) private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s) /** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */ private const val PASSWORD_HISTORY_MAX = 50 private const val VAULT_HISTORY_MAX = 5 sealed interface UnlockResult { data object Success : UnlockResult data object WrongPassword : UnlockResult data class Error(val reason: String) : UnlockResult } sealed interface CreateVaultResult { data object Success : CreateVaultResult data class Error(val reason: String) : CreateVaultResult } sealed interface AdoptVaultResult { data class Success(val vaultId: String) : AdoptVaultResult data class Error(val reason: String) : AdoptVaultResult } /** * In-memory vault session backed by [VaultStore]. Faithful port of the browser * `vault.ts` semantics that matter for correctness: * * - Two-slot at rest: slot A is the real (encrypted) vault, slot B a size-matched * noise blob so a fresh install shows two similarly-shaped records. * - Every modifiedAt/deletedAt uses [VaultCrypto.monotonicEntryTimestamp] so a * deletion always wins last-write-wins against an in-flight edit. * - Local writes bump the version counter; sync-applied writes do NOT (so a * sync round never cascades into another outbound sync). * - add/update/delete roll back in-memory state if the disk write fails. * - destroyVault never touches the Pro license (separate store). */ @Singleton class VaultRepository @Inject constructor( private val store: VaultStorage, ) { private val mutex = Mutex() // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so // lock() can serialize against an in-flight suspend op without blocking on // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and // take [stateLock] only around the state-write portions. Acquisition order // is always mutex → stateLock. private val stateLock = ReentrantLock() private val rng = SecureRandom() /** * Run [block] with [stateLock] held. Used to fence the small state-write * portions of suspend operations against [lock] and [requireUnlocked] * racing from another thread. */ private inline fun mutateState(block: () -> T): T { stateLock.lock() return try { block() } finally { stateLock.unlock() } } private var derivedKey: ByteArray? = null private var rootKey: ByteArray? = null private var entryList: MutableList = mutableListOf() private var duressConfigured: Boolean = false private var recoveryWordHash: String? = null private var recoveryWordsSet: Boolean = false /** True while a decoy (duress) session is active — persistence targets the decoy slot. */ private var decoyMode: Boolean = false private val _isUnlocked = MutableStateFlow(false) val isUnlocked: StateFlow = _isUnlocked.asStateFlow() private val _entries = MutableStateFlow>(emptyList()) /** Active (non-deleted) entryList. */ val entries: StateFlow> = _entries.asStateFlow() private val _settings = MutableStateFlow(VaultSettings()) val settings: StateFlow = _settings.asStateFlow() fun hasVault(): Boolean = store.hasVault() // ---- Codex (printed paper backup) ------------------------------------- /** * The at-rest slot-A envelope as base64 JSON `{"a": }` — the * Codex payload's vaultBlob. Slot B is noise, so it is omitted. Restorable * on any device that has the master password. Null when no vault exists. */ fun codexVaultBlobBase64(): String? { val slotA = store.loadSlotA() ?: return null val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}""" return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8)) } /** * Replace the local vault from a Codex blob, then unlock with [password]. * Writes slot A (and a size-matched noise slot B) before unlocking so the * restored vault is at rest exactly as a freshly-created one would be. */ /** A slot is stored either as a JSON string (browser) or inline object (Android). */ private fun JsonElement?.asSlotJson(): String? = when { this == null || this is JsonNull -> null this is JsonPrimitive && isString -> content else -> toString() } suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock { var candidateKey: ByteArray? = null var plaintext: ByteArray? = null return try { val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8) // Parsed as JSON, not matched with a regex. The wrapper is // `{ "a": , "b": }` and the browser — which // generates most real Codexes — writes each slot as a JSON // *string*, with "b" present. A regex looking for an object // literal under "a" at the very end of the wrapper matches // neither of those, so every browser-generated Codex failed here // with "invalid_codex_blob" after decoding perfectly. val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject ?: return UnlockResult.Error("invalid_codex_blob") val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob") // A Codex printed from the browser carries a browser-shaped slot, // which is the common case — most Codexes are printed there. It is // translated to Android's shape (see BrowserSlot) rather than // rejected. A translation failure is a wrong password, not a bad // blob: the codes decoded fine, the password just didn't open them. val slotA = VaultCodec.decodeSlot(slotJson) ?: BrowserSlot.parse(slotJson)?.let { BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword } ?: return UnlockResult.Error("invalid_codex_blob") // C-01: authenticate the candidate ENTIRELY in memory before touching // storage. VaultCodec.decodeSlot is structural — an Android-shaped // Codex parses under ANY password — so writing slot A first and // unlocking afterwards destroyed the device's existing vault every // time the user mistyped the password. candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt)) plaintext = decryptSlotABlob(slotA, candidateKey) // LV-1: a Codex printed from a vault sealed before NFC normalization // opens only under the raw password bytes. unlockLocked used to supply // this fallback; authenticating up front here has to keep it, or those // Codexes become unrestorable. if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) { VaultCrypto.zero(candidateKey) candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt)) plaintext = decryptSlotABlob(slotA, candidateKey) } if (plaintext == null) return UnlockResult.WrongPassword val payload = try { VaultCodec.decode(plaintext) } catch (_: Exception) { return UnlockResult.Error("invalid_codex_blob") } val priorA = store.loadSlotA() val priorB = store.loadSlotB() // Carry the real slot B across when the Codex has one — it holds a // duress vault, which noise would silently destroy. val codexSlotB = wrapper["b"].asSlotJson() if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed") val committed = runCatching { val wroteB = if (codexSlotB != null) { store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8))) } else { writeMatchingNoiseSlotB(slotA) } if (!wroteB) throw IllegalStateException("storage_write_failed") store.stampLocalMutation() }.isSuccess if (!committed) { rollbackRestore(priorA, priorB) return UnlockResult.Error("storage_write_failed") } val ownedKey = candidateKey!! candidateKey = null // the session owns the key from here on adoptSlotA(ownedKey, payload) } catch (e: Exception) { // A raw kotlinx parse error ("Unexpected JSON token at offset N") // reaching the user says nothing about WHICH json failed — the // wrapper, or the payload decrypted out of it. Codex screens are // FLAG_SECURE so this can't be screenshotted either. Debug builds // log the structure around the failure; alphanumerics are masked so // no ciphertext or vault content lands in a log. if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e)) UnlockResult.Error(e.message ?: "restore_failed") } finally { plaintext?.let(VaultCrypto::zero) candidateKey?.let(VaultCrypto::zero) } } /** Slot-A plaintext under [key], or null when the GCM tag fails (wrong password). */ private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? = runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull() /** * Undo a partially-committed Codex restore. Every step is attempted * independently so one failure cannot strand the others. With no prior vault * the accurate inverse is having none at all, rather than a half-written * slot A that [hasVault] would report as a real vault. */ private fun rollbackRestore(priorA: StoredVault?, priorB: String?) { val failures = mutableListOf() if (priorA == null) { runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" } } else { runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" } .onFailure { failures += "writeSlotA: $it" } if (priorB != null) { runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" } .onFailure { failures += "writeSlotB: $it" } } } // Log is a throwing stub off-device, and a failed log must never mask // the rollback result the caller is about to report. if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") } } /** Debug-only: structure of a failed Codex restore, with content masked. */ private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching { val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8) val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull() val window = offset?.let { val from = (it - 40).coerceAtLeast(0) val to = (it + 40).coerceAtMost(wrapperJson.length) wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x") } val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull() val slot = wrapper?.get("a") buildString { append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message) append(" | blobBase64=").append(vaultBlobBase64.length) append(" wrapperChars=").append(wrapperJson.length) append(" wrapperParses=").append(wrapper != null) append(" keys=").append(wrapper?.keys) append(" slotAKind=").append( when { slot == null -> "absent" slot is JsonPrimitive && slot.isString -> "string" slot is JsonObject -> "object:" + slot.keys else -> slot::class.simpleName }, ) if (window != null) append(" masked@").append(offset).append("=").append(window) } }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" } suspend fun createVault(password: String): CreateVaultResult = mutex.withLock { return try { val salt = VaultCrypto.generateSalt() val key = VaultCrypto.deriveKey(password, salt) mutateState { entryList = mutableListOf() duressConfigured = false recoveryWordHash = null recoveryWordsSet = false decoyMode = false rootKey = generateRecoverableRootKey() } val slotA = sealSlotA(key, salt) if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed") // P1-3 (codex): every later step must roll the slot pair back if it // fails — leaving a half-built vault is worse than refusing to make it. val created = try { if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed") store.stampLocalMutation() if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed") true } catch (_: Exception) { // Half-built vault on disk is worse than no vault — refuse to // create if any step past slot A fails. store.clearVault() false } if (!created) return CreateVaultResult.Error("storage_write_failed") mutateState { derivedKey = key publish() _isUnlocked.value = true } CreateVaultResult.Success } catch (e: Exception) { CreateVaultResult.Error(e.message ?: "create_failed") } } suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock { if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD") if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS") if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) { return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars") } var adoptedRootKey: ByteArray? = null var nextDerivedKey: ByteArray? = null var sessionRootKey: ByteArray? = null return try { adoptedRootKey = hexToRootKey(rootKeyHex) if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) { return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes") } val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey) val salt = VaultCrypto.generateSalt() nextDerivedKey = VaultCrypto.deriveKey(password, salt) sessionRootKey = adoptedRootKey.copyOf() val payload = VaultPayload( entries = emptyList(), duressConfigured = false, rootKeyB64 = b64(sessionRootKey), recoveryWordHash = null, recoveryWordsSet = false, ) val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A)) val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob) if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed") // P1-3 (codex): same partial-commit guard as createVault — any failure // past slot A wipes the half-built vault and refuses adoption. val created = try { if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed") store.stampLocalMutation() if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed") true } catch (_: Exception) { store.clearVault() false } if (!created) return AdoptVaultResult.Error("storage_write_failed") val previousDerivedKey = derivedKey val previousRootKey = rootKey mutateState { derivedKey = nextDerivedKey rootKey = sessionRootKey nextDerivedKey = null sessionRootKey = null entryList = mutableListOf() duressConfigured = false recoveryWordHash = null recoveryWordsSet = false decoyMode = false publish() _isUnlocked.value = true } previousDerivedKey?.let(VaultCrypto::zero) previousRootKey?.let(VaultCrypto::zero) AdoptVaultResult.Success(vaultId) } catch (e: Exception) { AdoptVaultResult.Error(e.message ?: "adoption_create_failed") } finally { adoptedRootKey?.let(VaultCrypto::zero) nextDerivedKey?.let(VaultCrypto::zero) sessionRootKey?.let(VaultCrypto::zero) } } suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) } /** Unlock body without acquiring the mutex — callers must already hold it. */ private fun unlockLocked(password: String): UnlockResult { val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault") val key = VaultCrypto.deriveKey(password, unb64(slotA.salt)) val payload = try { VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)) } catch (_: Exception) { VaultCrypto.zero(key) // LV-1: a vault sealed before NFC normalization with a non-NFC // password derives a different key now. If the supplied password is // not already in NFC form, retry with the raw (un-normalized) bytes. if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) { val legacy = tryLegacyRawUnlock(password, slotA) if (legacy != null) return legacy } return UnlockResult.WrongPassword } return adoptSlotA(key, payload) } /** * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw * (un-normalized) password bytes. On success the slot is HEALED — re-sealed * under the NFC-normalized key — so every future unlock takes the normal * path. Returns null when the raw key also fails (genuinely wrong password) * OR when any step of the heal commits fails (the original slot A is restored * so disk and session stay aligned). */ private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? { val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt)) val payload = try { VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A)) } catch (_: Exception) { VaultCrypto.zero(rawKey) return null } // Heal: re-seal slot A under the NFC key (mirrors changePassword's // re-seal). Escrows carry forward unchanged — only the wrapping rotates. val newSalt = VaultCrypto.generateSalt() val newKey = VaultCrypto.deriveKey(password, newSalt) val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A)) val next = StoredVault( version = slotA.version, salt = b64(newSalt), blob = blob, recoveryEscrow = slotA.recoveryEscrow, rootKeyEscrow = slotA.rootKeyEscrow, ) // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB, // or stampLocalMutation must restore the prior slot A AND slot B so disk // and the eventual session key never disagree on the wrapping. val priorB = store.loadSlotB() val healed = if (!store.writeSlotA(next)) { false } else try { if (!writeMatchingNoiseSlotB(next)) { runCatching { store.writeSlotA(slotA) } if (priorB != null) runCatching { store.writeSlotB(priorB) } false } else { store.stampLocalMutation() true } } catch (_: Exception) { runCatching { store.writeSlotA(slotA) } if (priorB != null) runCatching { store.writeSlotB(priorB) } false } return if (healed) { VaultCrypto.zero(rawKey) adoptSlotA(newKey, payload) // session key now matches the re-sealed slot } else { VaultCrypto.zero(newKey) adoptSlotA(rawKey, payload) // heal failed — keep using the raw key } } /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */ private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult { return try { mutateState { derivedKey = key decoyMode = false entryList = payload.entries.toMutableList() duressConfigured = payload.duressConfigured recoveryWordHash = payload.recoveryWordHash recoveryWordsSet = payload.recoveryWordsSet _settings.value = store.loadSettings() ensureRootKey(payload) autoPurgeExpiredTrash() publish() _isUnlocked.value = true } UnlockResult.Success } catch (e: Exception) { UnlockResult.Error(e.message ?: "unlock_failed") } } // ---- Constant-time unlock (MF-5) -------------------------------------- /** * A slot-A unlock that has been derived but not yet adopted. Holds the live * Argon2id key and decoded payload so the caller can derive several candidates * concurrently and commit only the winner. The key is owned by this token until * [commitSlotAUnlock] adopts it or [discardSlotAUnlock] zeroes it. */ class SlotAUnlock internal constructor( internal val key: ByteArray, internal val payload: VaultPayload, ) /** * Derive the slot-A key and decrypt its payload WITHOUT mutating session state. * Pure computation (no mutex), so it can run concurrently with other candidate * derivations before the caller decides which to commit (MF-5). Returns null on * no-vault or wrong password (the derived key is zeroed on a tag mismatch). */ fun tryUnlockSlotA(password: String): SlotAUnlock? { val slotA = store.loadSlotA() ?: return null val key = VaultCrypto.deriveKey(password, unb64(slotA.salt)) return try { val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)) SlotAUnlock(key, payload) } catch (_: Exception) { VaultCrypto.zero(key) null } } /** Adopt a previously-derived [SlotAUnlock] as the live session. */ suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock { adoptSlotA(unlock.key, unlock.payload) } /** Zero a derived [SlotAUnlock] that will not be adopted. */ fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key) // ---- Duress slot B (MF-6) --------------------------------------------- /** * A duress-slot (slot B) unlock derived but not yet adopted. Holds the live key * and decoded decoy payload so it can be derived concurrently with slot A and * only committed if the duress passphrase was the one entered (MF-5/MF-6). */ class DuressUnlock internal constructor( internal val key: ByteArray, internal val payload: VaultPayload, ) /** * Try to decrypt the duress slot (slot B) with [password] WITHOUT mutating the * session. Success means the entered password is the duress passphrase that keys * slot B — indistinguishable from the real slot A at the lock screen. Pure * computation (no mutex), safe to run concurrently with [tryUnlockSlotA]. * Returns null when no duress slot exists or the password is not the duress one. */ fun tryUnlockDuress(password: String): DuressUnlock? { val slot = store.loadDecoySlot() ?: return null val key = VaultCrypto.deriveKey(password, unb64(slot.salt)) return try { val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)) DuressUnlock(key, payload) } catch (_: Exception) { VaultCrypto.zero(key) null } } /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */ suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock { return try { mutateState { derivedKey = unlock.key decoyMode = true entryList = unlock.payload.entries.toMutableList() duressConfigured = false recoveryWordHash = unlock.payload.recoveryWordHash recoveryWordsSet = unlock.payload.recoveryWordsSet rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64) else VaultCrypto.generateRootKey() _settings.value = store.loadSettings() publish() _isUnlocked.value = true } UnlockResult.Success } catch (e: Exception) { UnlockResult.Error(e.message ?: "decoy_unlock_failed") } } /** Zero a derived [DuressUnlock] that will not be adopted. */ fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key) /** * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The * passphrase is never stored — only its ability to decrypt slot B proves it. * Requires the real vault to be unlocked and not itself a decoy session. */ suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock { // H-03: read derivedKey/decoyMode atomically with any concurrent lock(). val unlockedAndReal = mutateState { derivedKey != null && !decoyMode } if (!unlockedAndReal) return false // Reject a duress passphrase that also opens the real vault: it would make the // lock screen ambiguous (both slots would decrypt for the same input). tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false } return try { val salt = VaultCrypto.generateSalt() val key = VaultCrypto.deriveKey(duressPassphrase, salt) // An empty decoy vault with its own fresh sync root key — nothing real leaks. val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey())) val plaintext = VaultCodec.encode(decoyPayload) val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)) VaultCrypto.zero(key) VaultCrypto.zero(plaintext) val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob)) if (ok) { mutateState { duressConfigured = true } runCatching { persistSlotA(bumpVersion = false) } } ok } catch (_: Exception) { false } } /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */ suspend fun clearDuress(): Boolean = mutex.withLock { store.clearDecoySlot() // H-03: stateLock-fence the derivedKey/decoyMode read AND the // duressConfigured write so a concurrent lock() cannot land between them. val shouldClear = mutateState { derivedKey != null && !decoyMode } if (shouldClear) { mutateState { duressConfigured = false } runCatching { persistSlotA(bumpVersion = false) } } true } /** Unlock with an already-derived AES-256 key (biometric path). */ suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock { val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault") return try { val payload = VaultCodec.decode( VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A), ) mutateState { derivedKey = key.copyOf() decoyMode = false entryList = payload.entries.toMutableList() duressConfigured = payload.duressConfigured recoveryWordHash = payload.recoveryWordHash recoveryWordsSet = payload.recoveryWordsSet _settings.value = store.loadSettings() ensureRootKey(payload) autoPurgeExpiredTrash() publish() _isUnlocked.value = true } UnlockResult.Success } catch (_: Exception) { UnlockResult.WrongPassword } } fun lock() { // H-03: must observe derivedKey/entryList atomically with any in-flight // suspend op (an addEntry mid-encrypt, an unlock adopting the payload, // etc.). mutex cannot be acquired from this non-suspend caller; stateLock // guards just the brief state reset. stateLock.lock() try { derivedKey?.let(VaultCrypto::zero) rootKey?.let(VaultCrypto::zero) derivedKey = null rootKey = null entryList = mutableListOf() duressConfigured = false recoveryWordHash = null recoveryWordsSet = false decoyMode = false _entries.value = emptyList() _isUnlocked.value = false } finally { stateLock.unlock() } } @Volatile private var suppressNextBackgroundLock = false /** * Call immediately before launching a system Activity (a SAF file * picker/save dialog, a share sheet) that briefly takes the whole process * out of the foreground as an expected part of an in-app flow — not a * genuine user backgrounding. ProcessLifecycleOwner.onStop can't tell the * two apart on its own (it fires for both), and without this, exporting or * importing a file silently locked and wiped the vault mid-flow the moment * the picker took over, before its result ever reached the app. */ fun expectTransientBackground() { suppressNextBackgroundLock = true } /** Consumed once by the next background-lock check; see [expectTransientBackground]. */ fun consumeTransientBackgroundSuppression(): Boolean { val suppressed = suppressNextBackgroundLock suppressNextBackgroundLock = false return suppressed } // ---- Sync identity ---------------------------------------------------- /** * The vault's stable sync master key (a copy). Caller must zero it. Backs * vault_id, the Noise static key, the Ed25519 manifest key, and per-entry * envelope keys. Null when locked. See [VaultPayload.rootKeyB64]. */ /** * HOLE 5 — the duress vault never syncs. * * [commitDuressUnlock] gives the decoy session a root key so its own codex * and recovery paths work, which means (unlike the browser, where the decoy * path simply has no root key) the sync surface has to refuse explicitly. * Without this the decoy vault would present a second, different vault_id * to a paired device, and a coerced user opening it would make sync visibly * fail on their other devices — a duress indicator, which is the one thing * a duress vault must never be. */ fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf() /** * The current session's root key, decoy included. For local-only uses that * must behave identically in a decoy session — Codex backup and recovery * words — because a feature that errors under duress is itself a duress * tell. In a decoy session this is the DECOY's root key (see * [commitDuressUnlock]), never the real vault's, so neither surface can * leak the real vault. Anything that leaves the device must use * [syncMasterKey] instead. */ fun sessionRootKey(): ByteArray? = rootKey?.copyOf() /** Stable per-vault id two paired devices must share to sync. Null when * locked, and never present for the decoy vault (see [syncMasterKey]). */ fun syncVaultId(): String? = if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) } /** * Browser parity (MV-8): rotate the sync identity by replacing the stable * root key. Entries remain under the master-password key, while sync * credentials, vault_id, and recovery words all change. */ suspend fun rotateSyncIdentity(): Boolean = mutex.withLock { installRootKey(generateRecoverableRootKey()) } /** * Join the vault behind [rootKeyHex]: adopt that vault's identity while * keeping every local entry. * * Sync identity (vault id, signing keys) derives from the root key, and * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently * created vaults can never reconcile. Taking the other side's root key is * what makes them one vault; from the next sync on, both entry sets merge. * * Entries survive because they are sealed under the password-derived key, * not the root key — the same property [rotateSyncIdentity] relies on. * * CONSEQUENCE, which the caller must have disclosed and had confirmed: * this device's previous recovery words stop working for good, and its new * words are the source vault's words, because words are a deterministic * encoding of the root key. */ suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock { if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) { return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars") } if (derivedKey == null) return AdoptVaultResult.Error("vault_locked") if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE") if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT") val incoming = hexToRootKey(rootKeyHex) if (incoming.size != VaultCrypto.KEY_SIZE) { VaultCrypto.zero(incoming) return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes") } // Refuse before committing rather than leave this device holding a vault // whose recovery words cannot be typed back. generateRecoverableRootKey // screens its own keys for this; a key arriving from a peer has not been. val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess if (!roundTrips) { VaultCrypto.zero(incoming) return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY") } val vaultId = com.peachpasswords.sync.computeVaultId(incoming) return if (installRootKey(incoming)) { AdoptVaultResult.Success(vaultId) } else { AdoptVaultResult.Error("JOIN_INSTALL_FAILED") } } /** * The recovery words [rootKeyHex] will produce once joined, or null if it is * not a usable root key. Lets the join flow show the user the words that are * about to replace theirs *before* anything is committed. */ fun previewRecoveryWords(rootKeyHex: String): List? { if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null val bytes = hexToRootKey(rootKeyHex) return try { val words = rootKeyToWords(bytes) if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null } catch (_: Exception) { null } finally { VaultCrypto.zero(bytes) } } /** * Swap the vault's root key, keeping every entry, and re-escrow under the * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds * [mutex]. */ private suspend fun installRootKey(nextRootKey: ByteArray): Boolean { // H-03: snapshot the live fields under stateLock so the success and the // rollback paths both see consistent prior/new state. Writing the new // rootKey/recovery fields is inside its own mutateState at the end. // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays // — holding live references would let a zero-on-failure operation also // wipe the session's still-live key arrays. data class Snapshot( val key: ByteArray, val priorRootKey: ByteArray, val priorRecoveryHash: String?, val priorRecoverySet: Boolean, ) val snap = mutateState { val k = derivedKey ?: return false if (decoyMode) return false val priorRoot = rootKey ?: return false Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet) } as Snapshot? ?: return false val priorSlot = store.loadSlotA() ?: run { VaultCrypto.zero(snap.priorRootKey) return false } val nextWords = rootKeyToWords(nextRootKey) return try { takeVaultSnapshot("local_save") mutateState { rootKey = nextRootKey recoveryWordHash = recoveryWordsHash(nextWords) recoveryWordsSet = false } val nextRecoveryEscrow = buildRecoveryEscrow(nextWords) ?: throw IllegalStateException("recovery_escrow_failed") val nextRootKeyEscrow = buildRootKeyEscrow(nextWords) ?: throw IllegalStateException("root_escrow_failed") val plaintext = VaultCodec.encode(currentPayload()) val blob = try { b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A)) } finally { VaultCrypto.zero(plaintext) } val nextSlot = priorSlot.copy( blob = blob, recoveryEscrow = nextRecoveryEscrow, rootKeyEscrow = nextRootKeyEscrow, ) if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed") try { writeMatchingNoiseSlotB(nextSlot) store.stampLocalMutation() } catch (e: Exception) { store.writeSlotA(priorSlot) throw e } VaultCrypto.zero(snap.priorRootKey) mutateState { publish() } true } catch (_: Exception) { mutateState { rootKey = snap.priorRootKey recoveryWordHash = snap.priorRecoveryHash recoveryWordsSet = snap.priorRecoverySet } VaultCrypto.zero(nextRootKey) store.writeSlotA(priorSlot) false } } /** * A copy of the live Argon2id-derived vault key, for biometric escrow only * (wrapped under a hardware KeyStore key on biometric enable). Null when * locked. Caller must [VaultCrypto.zero] it after use. */ fun currentVaultKey(): ByteArray? = derivedKey?.copyOf() suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock { val slotA = store.loadSlotA() ?: return false var oldKey: ByteArray? = null var newKey: ByteArray? = null var plaintext: ByteArray? = null return try { oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt)) val payload = VaultCodec.decode( VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A), ) val newSalt = VaultCrypto.generateSalt() newKey = VaultCrypto.deriveKey(newPassword, newSalt) // Re-seal the existing payload under the new key/salt. plaintext = VaultCodec.encode(payload) val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A)) // The escrow seals the slot-A PLAINTEXT, which a password change does not // alter — only the wrapping changes — so carry it forward; rebuilding the // StoredVault from scratch would silently drop it. // Carry BOTH escrows forward unchanged: each seals plaintext/root-key // content that a password change does not alter — only the slot-A // wrapping rotates. The root-key escrow staying stable across password // changes matches the browser (`vault.ts:363-364`). val next = StoredVault( version = slotA.version, salt = b64(newSalt), blob = blob, recoveryEscrow = slotA.recoveryEscrow, rootKeyEscrow = slotA.rootKeyEscrow, ) // H-01: durably commit slot A + size-matched noise + version stamp // before swapping the session key. A partial commit (new A on disk, // old key in memory) would have the next save encrypt under the old // key while matching disk on the new one. val priorB = store.loadSlotB() if (!commitVaultRotation(slotA, priorB, next)) return false // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one // inside the same stateLock critical section, so a concurrent lock() // can't observe the in-between (zeroed but not yet reassigned). mutateState { oldKey?.let(VaultCrypto::zero); oldKey = null derivedKey?.let(VaultCrypto::zero) derivedKey = newKey; newKey = null } true } catch (_: Exception) { false } finally { oldKey?.let(VaultCrypto::zero) newKey?.let(VaultCrypto::zero) plaintext?.let(VaultCrypto::zero) } } /** * Irreversible wipe. Locks the session and clears every vault key. The Pro * license is in a separate store and is preserved by construction. */ suspend fun destroyVault() = mutex.withLock { lock() store.clearVault() store.clearDecoySlot() } // ---- Entry CRUD ------------------------------------------------------- suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock { requireUnlocked() val stamp = VaultCrypto.monotonicEntryTimestamp() val now = System.currentTimeMillis() val entry = withDerivedLast4(draft.copy( id = draft.id.ifBlank { UUID.randomUUID().toString() }, createdAt = if (draft.createdAt > 0) draft.createdAt else now, modifiedAt = stamp, passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt, )) // H-03: capture rollback AND write entryList under stateLock so a // concurrent lock() cannot zero the session between the read and the write. val rollback = mutateState { val r = entryList.toMutableList() entryList.add(entry) r } persistOrRollback(rollback) entry } /** * Insert many entries with a single encrypt-and-write at the end. * * Importing a real vault through [addEntry] was quadratic: every entry took * the lock and re-encrypted and rewrote the whole vault, so an 806-entry * restore did 806 full-vault writes and hung the app long enough to ANR. * Rolls back to the pre-import list if the one write fails, matching * [addEntry]'s all-or-nothing behaviour. */ suspend fun addEntries(drafts: List): Int = mutex.withLock { requireUnlocked() if (drafts.isEmpty()) return 0 val now = System.currentTimeMillis() val ready = drafts.map { draft -> val stamp = VaultCrypto.monotonicEntryTimestamp() withDerivedLast4( draft.copy( id = draft.id.ifBlank { UUID.randomUUID().toString() }, createdAt = if (draft.createdAt > 0) draft.createdAt else now, modifiedAt = stamp, passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt, ), ) } // H-03: capture rollback AND append every entry under stateLock. val rollback = mutateState { val r = entryList.toMutableList() entryList.addAll(ready) r } persistOrRollback(rollback) return drafts.size } suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock { requireUnlocked() // H-03: read entryList, compute the patched entry, and write it under a // single stateLock so a concurrent lock() cannot interleave between the // lookup and the assignment (would IndexOutOfBoundsException on a shrunk // list, or clobber the entry onto the wrong id after a relock). var prepared: Pair>? = null mutateState { val idx = entryList.indexOfFirst { it.id == patch.id } if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found") val current = entryList[idx] val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt) // Only refresh passwordChangedAt when the password value actually changes. val passwordChanged = patch.password != null && patch.password != current.password // Capture the outgoing password (newest first) so the user can reveal/copy/ // restore it later. History is computed authoritatively from `current`, not // the patch — updateEntry is a full-entry replace, so trusting patch's value // would let a stale form clobber the real history. Record the age the old // password carried, skip an empty prior, and skip an immediate exact dupe // (e.g. restore-then-restore). Capped at PASSWORD_HISTORY_MAX. val priorPassword = current.password val shouldCapture = passwordChanged && !priorPassword.isNullOrEmpty() && current.passwordHistory?.firstOrNull()?.password != priorPassword val nextHistory = if (shouldCapture) { (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) + (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX) } else { current.passwordHistory } val updated = withDerivedLast4(patch.copy( createdAt = current.createdAt, modifiedAt = stamp, passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt, passwordHistory = nextHistory, )) val rollback = entryList.toMutableList() entryList[idx] = updated prepared = updated to rollback } val (updated, rollback) = prepared!! persistOrRollback(rollback) updated } /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */ suspend fun deleteEntry(id: String) = mutex.withLock { requireUnlocked() // H-03: lookup + tombstone write under stateLock so lock() cannot shrink // the list out from under the indexOfFirst. var rollback: MutableList? = null mutateState { val idx = entryList.indexOfFirst { it.id == id } if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found") val current = entryList[idx] val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt) rollback = entryList.toMutableList() entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp) } persistOrRollback(rollback!!) } suspend fun restoreEntry(id: String) = mutex.withLock { requireUnlocked() var rollback: MutableList? = null mutateState { val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null } if (idx == -1) throw IllegalStateException("deleted entry not found") val current = entryList[idx] rollback = entryList.toMutableList() entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)) } persistOrRollback(rollback!!) } /** Permanent delete — no tombstone, no recovery. */ suspend fun purgeEntry(id: String) = mutex.withLock { requireUnlocked() var rollback: MutableList? = null mutateState { val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null } if (idx == -1) throw IllegalStateException("deleted entry not found") rollback = entryList.toMutableList() entryList.removeAt(idx) } persistOrRollback(rollback!!) } /** Permanently empty the trash — hard-deletes every soft-deleted entry. */ suspend fun purgeDeletedEntries(): Int = mutex.withLock { requireUnlocked() // H-03: read+remove under stateLock so lock() can't reorder the remove. data class R(val rollback: MutableList, val removed: Int) val r = mutateState { val rollback = entryList.toMutableList() val before = entryList.size entryList.removeAll { it.deletedAt != null } R(rollback, before - entryList.size) } if (r.removed > 0) persistOrRollback(r.rollback) r.removed } /** Browser parity: number of active entries that dedupe would move to trash. */ suspend fun countDuplicateEntries(): Int = mutex.withLock { requireUnlocked() // countDuplicateEntriesLocked reads entryList; H-03 fences the read so // lock() can't race the read with state writes. mutateState { countDuplicateEntriesLocked() } } /** * Browser parity: soft-delete duplicate entries, keeping the most recently * modified member of each group. Refuse in decoy mode so the duress vault is * not reshaped into a forensically distinct state. */ suspend fun deduplicateEntries(): Int = mutex.withLock { requireUnlocked() // H-03: read the decoyMode flag and the entry list, then reassign // entryList, all under one stateLock. val decoy = mutateState { decoyMode } if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode") data class R(val rollback: MutableList, val removed: Int) val ready = mutateState { val active = entryList.filter { it.deletedAt == null } val keepIds = mutableSetOf() active.groupBy { it.duplicateKey() }.values.forEach { group -> keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id } val rollback = entryList.toMutableList() var removed = 0 entryList = entryList.map { entry -> if (entry.deletedAt == null && entry.id !in keepIds) { removed++ val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt) entry.copy(deletedAt = stamp, modifiedAt = stamp) } else { entry } }.toMutableList() R(rollback, removed) } if (ready.removed > 0) persistOrRollback(ready.rollback) ready.removed } fun getEntries(): List = entryList.filter { it.deletedAt == null } fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null } fun getDeletedEntries(): List = entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt } /** Active entries, optionally narrowed to those carrying [tagFilter]. */ fun getActiveEntries(tagFilter: String? = null): List { val active = entryList.filter { it.deletedAt == null } if (tagFilter == null) return active val needle = tagFilter.trim().lowercase() return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true } } /** Sorted, de-duplicated tags across all active entries. */ fun getDistinctTags(): List = entryList.asSequence() .filter { it.deletedAt == null } .flatMap { it.tags?.asSequence() ?: emptySequence() } .map { it.trim().lowercase() } .filter { it.isNotEmpty() } .distinct() .sorted() .toList() /** * Distinct non-blank values for an identity field across active entries, * most-frequently-used first. Supports "username" and "email"; unknown field * names return an empty list. Values keep original casing (deduped * case-insensitively) so a suggestion matches what the user typed. */ fun getDistinctFieldValues(field: String): List { val select: (VaultEntry) -> String? = when (field) { "username" -> { e -> e.username } "email" -> { e -> e.email } else -> return emptyList() } return entryList.asSequence() .filter { it.deletedAt == null } .mapNotNull { select(it)?.trim() } .filter { it.isNotEmpty() } .groupingBy { it.lowercase() } .aggregate { _, acc: Pair?, value, _ -> Pair(acc?.first ?: value, (acc?.second ?: 0) + 1) } .values .sortedWith(compareByDescending> { it.second }.thenBy { it.first.lowercase() }) .map { it.first } } fun getTombstones(): List = entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) } fun getVaultVersion(): Long = store.version() fun getVaultHistory(): List = store.loadVaultHistory().snapshots suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock { val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false val priorA = store.loadSlotA() val priorB = store.loadSlotB() // H-02: capture pre-restore state as the recovery snapshot BEFORE any // slot write — the old code took this snapshot, then stranded it on // every failure, polluting history with a "restore" entry that never // restored anything. The history below is rolled back together with // the slots if the commit fails. val priorHistory = store.loadVaultHistory() // P1-4 (codex): the recovery snapshot must land before we commit the // slot pair, otherwise a failed restore cannot be rolled back to the // pre-restore history. If the history write itself fails, refuse to // proceed — the alternative is a successful restore with no undo path. if (!takeVaultSnapshot("restore")) { return false } if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) { store.writeVaultHistory(priorHistory) return false } lock() true } /** * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore. * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B * (a restore preserves the duress-shaped blob from history), not a fresh * noise record. Any failure rolls back so the at-rest pair never holds a * restored slot A against a pre-restore slot B. Caller is responsible for * restoring the pre-snapshot history on a false return. */ private fun commitRestoreAtomic( nextA: StoredVault, nextB: String, priorA: StoredVault?, priorB: String?, ): Boolean { if (!store.writeSlotA(nextA)) return false // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it // must trigger rollback; previously a throw escaped without restoring // the prior slot A and history. try { if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed") store.stampLocalMutation() } catch (_: Exception) { rollbackRestore(priorA, priorB) return false } return true } // ---- Sync apply (does NOT advance the version) ------------------------ /** * Apply remote entryList + tombstones with per-entry last-write-wins. Persists * WITHOUT bumping the version counter, so a sync round cannot cascade into * another outbound sync. Returns the number of locally-applied changes. */ suspend fun applyMergedRemoteEntries( remoteEntries: List, remoteTombstones: List, ): Int = mutex.withLock { requireUnlocked() // HV-1: clamp every peer-supplied timestamp to now + MAX_CLOCK_SKEW_MS // BEFORE any comparison against a local one. Without this a peer can // send a far-future modifiedAt and permanently pin its version of an // entry against every honest local edit. The browser clamps in the // matching place (vault.ts applyMergedRemoteEntries). val mergeNow = System.currentTimeMillis() val clamped = remoteEntries.map { entry -> entry.copy( modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow), deletedAt = entry.deletedAt?.let { SyncLimits.clampSyncTimestamp(it, mergeNow) }, ) } val tombMap = remoteTombstones.associate { it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow) } // H-03: build the new entryList under stateLock so a concurrent lock() // cannot zero derivedKey/entryList between the byId read and the merge. val applied = mutateState { var n = 0 val byId = entryList.associateBy { it.id }.toMutableMap() for (remote in clamped) { val tomb = tombMap[remote.id] if (tomb != null && tomb >= remote.modifiedAt) continue val local = byId[remote.id] if (local == null) { entryList.add(remote); byId[remote.id] = remote; n++ } else if (remote.modifiedAt > local.modifiedAt) { val i = entryList.indexOfFirst { it.id == local.id } if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ } } } for ((id, deletedAt) in tombMap) { val local = byId[id] ?: continue if (local.deletedAt != null && local.deletedAt >= deletedAt) continue if (deletedAt < local.modifiedAt) continue val i = entryList.indexOfFirst { it.id == id } if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ } } n } if (applied > 0) { persistSlotA(bumpVersion = false, snapshotReason = "sync_received") mutateState { publish() } } applied } suspend fun updateSettings(settings: VaultSettings) = mutex.withLock { // _settings is a StateFlow (atomic write), but publish under stateLock so // any concurrent lock() observes either the old or the new value — not // ordering with the in-memory entries flip. mutateState { _settings.value = settings } store.writeSettings(settings) } // ---- Recovery words (vault metadata) ---------------------------------- /** True once the user has confirmed their recovery words for this vault. */ fun isRecoveryWordsSet(): Boolean = recoveryWordsSet /** * Record the BLAKE3 hash of the confirmed recovery words and mark them set. * Persisted without bumping the version — recovery metadata is local-only and * is not an entry mutation paired devices should sync against. */ suspend fun setRecoveryWords(words: List): Boolean = mutex.withLock { requireUnlocked() // H-03: hash + escrow-related state fields set under stateLock so a // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow. mutateState { recoveryWordHash = recoveryWordsHash(words) recoveryWordsSet = true } // Seal TWO escrows (MF-1): the browser-byte-compatible root-key escrow // (cross-platform recovery of the sync root key) and Android's whole-payload // escrow (offline forgot-password restore of entries). Both derive their key // from the same `wordsToRootKeyMaterial` IKM but use distinct AADs. runCatching { persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words)) }.getOrDefault(false) } /** Verify entered words against the stored recovery hash. */ fun verifyRecoveryWords(words: List): Boolean { val stored = recoveryWordHash ?: return false return stored == recoveryWordsHash(words) } // ---- Recovery escrow (forgot-password) -------------------------------- /** True at rest if this vault has any recovery escrow the recovery flow can use. */ fun hasRecoveryEscrow(): Boolean = store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false /** * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of * the master password. On success the slot-A plaintext sealed in the escrow is * adopted as the live session (entries, rootKey, settings), but no Argon2id * password key exists yet — call [resetPasswordWithRecoveryWords] next to set a * new password and finish unlocking. Returns false when there is no escrow, the * words fail the BIP39 checksum, or the words are wrong (GCM tag mismatch). * * Unlike the brief's `unlockWithRecoveryWords(): ByteArray?`, there is no * detached vault key to return here — slot A is wrapped under the rotating * Argon2id key — so this opens the escrowed payload directly. */ suspend fun unlockWithRecoveryWords(words: List): Boolean = mutex.withLock { val slotA = store.loadSlotA() ?: return false if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false // Prefer Android's whole-payload escrow: a full offline restore of entries, // root key, and settings. Present for Android-created vaults. slotA.recoveryEscrow?.let { escrow -> val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt)) val keyBytes = recoveryKey.encoded try { val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY) val payload = VaultCodec.decode(plaintext) // H-03: adoption is one atomic state-write — lock() cannot interleave. mutateState { decoyMode = false entryList = payload.entries.toMutableList() duressConfigured = payload.duressConfigured recoveryWordHash = payload.recoveryWordHash recoveryWordsSet = payload.recoveryWordsSet rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64) else generateRecoverableRootKey() _settings.value = store.loadSettings() } VaultCrypto.zero(plaintext) return true } catch (_: Exception) { // Wrong words against this escrow — fall through to the root-key escrow. } finally { VaultCrypto.zero(material) VaultCrypto.zero(keyBytes) } } // Browser-format vault (only the root-key escrow): recover the 32-byte root // key (sync identity) and reset the password; entries return via sync. This // is exactly the browser's recovery semantics (`messages.ts:312-347`). slotA.rootKeyEscrow?.let { escrow -> val rk = unsealRootKeyEscrow(words, escrow) ?: return false mutateState { decoyMode = false entryList = mutableListOf() duressConfigured = false recoveryWordHash = recoveryWordsHash(words) recoveryWordsSet = true rootKey = rk _settings.value = store.loadSettings() } return true } return false } /** * After [unlockWithRecoveryWords] succeeds, set a brand-new master password: * re-seal slot A under a fresh Argon2id key, rotate the escrow under the same * words, mark the session fully unlocked, and publish. Returns false if called * without a prior successful recovery unlock (no in-memory root key). * * Folds the brief's `resetPasswordWithRecoveryKey(vaultKey, …)` + `unlock(…)` * into one step, since there is no detached vault key here. */ suspend fun resetPasswordWithRecoveryWords( words: List, newPassword: String, ): Boolean = mutex.withLock { // H-03: read rootKey + entryList under stateLock before computing the new // plaintext, then re-publish + swap the derived key under stateLock so a // concurrent lock() cannot straddle the two fences. if (!mutateState { rootKey != null }) return false val priorA = store.loadSlotA() ?: return false var newKey: ByteArray? = null var plaintext: ByteArray? = null return try { val newSalt = VaultCrypto.generateSalt() newKey = VaultCrypto.deriveKey(newPassword, newSalt) plaintext = VaultCodec.encode(currentPayload()) val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A)) // Rebuild both escrows against the recovered session so their salts rotate // with the password reset; fall back to the prior escrows if rebuild fails // (e.g. a checksum-invalid phrase). The same 12 words keep working. val valid = words.size == 12 && findInvalidWords(words).isEmpty() val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow val next = StoredVault( version = priorA.version, salt = b64(newSalt), blob = blob, recoveryEscrow = freshRecovery, rootKeyEscrow = freshRootKey, ) // H-01: see changePassword — both rotations share the same // commit-or-keep-old-key contract so a partial write can't strand // the session on a key that no longer matches disk. val priorB = store.loadSlotB() if (!commitVaultRotation(priorA, priorB, next)) return false // H-03 (P1-2 codex): zero the old live key and install the new one // inside the same stateLock critical section so a concurrent lock() // can't observe the in-between. mutateState { derivedKey?.let(VaultCrypto::zero) derivedKey = newKey; newKey = null publish() _isUnlocked.value = true } true } catch (_: Exception) { false } finally { newKey?.let(VaultCrypto::zero) plaintext?.let(VaultCrypto::zero) } } /** * Seal the current slot-A plaintext under a key derived from the 12 BIP39 * recovery [words] (Android offline-restore escrow). IKM is the browser's * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY]. * Returns null for a checksum-invalid phrase. */ private fun buildRecoveryEscrow(words: List): RecoveryEscrow? { if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null val salt = VaultCrypto.generateSalt() val recoveryKey = deriveRecoveryKey(material, salt) val keyBytes = recoveryKey.encoded val plaintext = VaultCodec.encode(currentPayload()) return try { val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY) RecoveryEscrow(salt = b64(salt), blob = b64(sealed)) } finally { VaultCrypto.zero(material) VaultCrypto.zero(keyBytes) VaultCrypto.zero(plaintext) } } /** * Seal ONLY the 32-byte root key under a key the recovery [words] derive, * byte-for-byte the browser algorithm (MF-1): plaintext = lowercase ASCII hex * of the root key, IKM = `wordsToRootKeyMaterial`, HKDF salt random-16, * info `peach-recovery-v1`, AES-256-GCM AAD = 0x01. The same 12 words unwrap * the same root key on the browser. Returns null with no in-memory root key or * a checksum-invalid phrase. */ private fun buildRootKeyEscrow(words: List): RootKeyEscrow? { if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null val rk = rootKey ?: return null val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null val salt = VaultCrypto.generateSalt() val recoveryKey = deriveRecoveryKey(material, salt) val keyBytes = recoveryKey.encoded val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII) return try { // combined = nonce(12) || ciphertext || tag(16) val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY) val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE) val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size) val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size) val ct = ctTag.copyOfRange(0, ctTag.size - 16) RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag)) } finally { VaultCrypto.zero(material) VaultCrypto.zero(keyBytes) VaultCrypto.zero(plaintext) } } /** Unwrap the 32-byte root key from a browser-compatible [escrow]; null on wrong words. */ private fun unsealRootKeyEscrow(words: List, escrow: RootKeyEscrow): ByteArray? { val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt)) val keyBytes = recoveryKey.encoded return try { val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag) val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY) hexToRootKey(String(pt, Charsets.US_ASCII)) } catch (_: Exception) { null } finally { VaultCrypto.zero(material) VaultCrypto.zero(keyBytes) } } /** * Re-seal slot A from the current in-memory state under the live key, carrying * both [recoveryEscrow] and [rootKeyEscrow] onto the written record. Never runs * in decoy mode (recovery is never offered for the decoy) and persists without * a version bump, like the recovery-words metadata write it replaces. */ private fun persistSlotAWithEscrows( recoveryEscrow: RecoveryEscrow?, rootKeyEscrow: RootKeyEscrow?, ): Boolean { val key = derivedKey ?: throw IllegalStateException("vault_locked") if (decoyMode) return false val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A") val plaintext = VaultCodec.encode(currentPayload()) val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)) val next = prior.copy( blob = blob, recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow, rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow, ) if (!store.writeSlotA(next)) return false try { writeMatchingNoiseSlotB(next) } catch (e: Exception) { store.writeSlotA(prior) throw e } return true } // ---- Internals -------------------------------------------------------- private fun requireUnlocked() { // H-03: read derivedKey atomically with [lock]'s reset; without it, // [lock] from a background lifecycle event can run between this read // and the caller's first state touch. stateLock.lock() try { if (derivedKey == null) throw IllegalStateException("vault_locked") } finally { stateLock.unlock() } } private fun countDuplicateEntriesLocked(): Int { val seen = mutableMapOf() var duplicates = 0 for (entry in entryList.filter { it.deletedAt == null }) { val key = entry.duplicateKey() val existing = seen[key] if (existing == null) { seen[key] = entry } else { duplicates++ val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt if (entryStamp > existingStamp) seen[key] = entry } } return duplicates } private fun VaultEntry.duplicateKey(): String { if (type == "note") return "note|${title.lowercase()}" val passkeyData = passkey if (passkeyData != null) { return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}" } return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}" } /** * Capture the current slot pair (and metadata) into the history list. * Returns true when the history write landed, false when it didn't — callers * that depend on the snapshot existing (notably [restoreVaultSnapshot]) * must check the result and roll back if it does not. */ private fun takeVaultSnapshot(reason: String): Boolean { val slotA = store.loadSlotA() ?: return false val vaultA = VaultCodec.encodeSlot(slotA) val vaultB = store.loadSlotB() ?: slotA.blob val history = store.loadVaultHistory() val latest = history.snapshots.firstOrNull() if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true val entryCount = if (derivedKey != null && !decoyMode) { bucketEntryCount(entryList.count { it.deletedAt == null }) } else { null } return try { val snapshot = VaultSnapshot( id = UUID.randomUUID().toString(), savedAt = System.currentTimeMillis(), reason = reason, vault_a = vaultA, vault_b = vaultB, entryCount = entryCount, ) // P1-4 (codex): return the history-write boolean to callers so a // false return no longer leaves the snapshot out of history while // callers (notably restoreVaultSnapshot) proceed as if it succeeded. store.writeVaultHistory( history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)), ) } catch (_: Exception) { false } } private fun bucketEntryCount(n: Int): Int = when { n <= 0 -> 0 n <= 10 -> 10 n <= 25 -> 25 n <= 50 -> 50 n <= 100 -> 100 n <= 250 -> 250 n <= 500 -> 500 else -> ((n + 499) / 500) * 500 } /** * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3), * so it is stored explicitly and survives the sync wire format rather than being * re-derived at render time. Non-card entries pass through unchanged. */ private fun withDerivedLast4(entry: VaultEntry): VaultEntry { if (!entry.isCard()) return entry val digits = entry.cardNumber?.filter { it.isDigit() } ?: "" return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null) } /** * On unlock, hard-delete trashed entries older than the retention window. * A null or non-positive [VaultSettings.trashRetentionDays] means "never" * (the app's "Never" option), so nothing is purged. Persists without a * version bump and without touching the publish state (caller publishes). */ private fun autoPurgeExpiredTrash() { val days = _settings.value.trashRetentionDays ?: return if (days <= 0) return val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L val before = entryList.size entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff } if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) } } /** The slot-A plaintext envelope for the current in-memory session. */ private fun currentPayload(): VaultPayload = VaultPayload( entries = entryList.toList(), duressConfigured = duressConfigured, rootKeyB64 = rootKey?.let(::b64) ?: "", recoveryWordHash = recoveryWordHash, recoveryWordsSet = recoveryWordsSet, ) /** * Adopt the payload's root key, lazily minting one for legacy payloads. * A freshly-minted key is persisted WITHOUT bumping the version — adopting * a root key is not a mutation paired devices should sync against. */ private fun ensureRootKey(payload: VaultPayload) { if (payload.rootKeyB64.isNotBlank()) { rootKey = unb64(payload.rootKeyB64) return } rootKey = generateRecoverableRootKey() runCatching { persistSlotA(bumpVersion = false) } } private fun publish() { _entries.value = entryList.filter { it.deletedAt == null } } private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault { val plaintext = VaultCodec.encode(currentPayload()) val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)) return StoredVault(version = 1, salt = b64(salt), blob = blob) } private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean { // Size the noise so its base64 length matches slot A's blob (MN-1): with the // plaintext now bucket-padded, an at-rest observer sees two equal-length slots // and cannot pick the real one by size. b64(N bytes) ≈ N*4/3 chars. val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16) val noise = ByteArray(approxBytes).also(rng::nextBytes) return store.writeSlotB(b64(noise)) } /** Re-seal slot A from the current in-memory state under the live key. */ private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean { val key = derivedKey ?: throw IllegalStateException("vault_locked") // Decoy sessions persist to the decoy slot only — never touch the real slot A. if (decoyMode) { val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot") val plaintext = VaultCodec.encode(currentPayload()) val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)) return store.writeDecoySlot(prior.copy(blob = blob)) } val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A") // H-01: captured BEFORE the noise write so a stamp failure can restore // the prior slot B alongside the prior slot A. Slot B is always // overwritten by writeMatchingNoiseSlotB below, so a stamp failure // leaves (new A, new B, old version) on disk — partial commit. val priorB = store.loadSlotB() if (snapshotReason != null) takeVaultSnapshot(snapshotReason) val plaintext = VaultCodec.encode(currentPayload()) val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)) val next = prior.copy(blob = blob) if (!store.writeSlotA(next)) return false // Slot A committed — keep slot B size-matched, then advance the version // for local writes only. Restore both A and B if a later step fails. // P1-1 (codex): the noise write's boolean AND any thrown exception from // it must trigger rollback; previously the call sat outside the try. try { if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed") if (bumpVersion) store.stampLocalMutation() } catch (e: Exception) { store.writeSlotA(prior) if (priorB != null) runCatching { store.writeSlotB(priorB) } throw e } return true } /** * H-01: durably commit a slot-A rotation by writing slot A, its * size-matched noise slot B, and the local-mutation stamp in order. Any * failure rolls the previously-committed steps back so the at-rest pair * matches what was there before the call. Returns true only when every * step succeeded — callers must NOT swap their session key on false. */ private fun commitVaultRotation( priorA: StoredVault, priorB: String?, nextA: StoredVault, ): Boolean { if (!store.writeSlotA(nextA)) return false // P1-1 (codex): the noise write's boolean AND any thrown exception from // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB // escaped the helper and left slot A committed. try { if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed") store.stampLocalMutation() } catch (_: Exception) { rollbackRotation(priorA, priorB) return false } return true } /** * Best-effort undo of the writes [commitVaultRotation] may have committed. * Each step runs independently — a failed rollback must not mask the * original commit failure. Log is a throwing stub off-device. */ private fun rollbackRotation(priorA: StoredVault, priorB: String?) { val failures = mutableListOf() runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" } .onFailure { failures += "writeSlotA: $it" } if (priorB != null) { runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" } .onFailure { failures += "writeSlotB: $it" } } if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") } } /** Persist a local mutation, rolling the in-memory list back on failure. */ private fun persistOrRollback(rollback: MutableList) { try { if (!persistSlotA(bumpVersion = true)) { mutateState { entryList = rollback } throw IllegalStateException("storage_write_failed") } } catch (e: Exception) { mutateState { entryList = rollback } throw e } mutateState { publish() } } }