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 java.util.Base64 import java.util.UUID import javax.inject.Inject import javax.inject.Singleton 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() private val rng = SecureRandom() 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. */ suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock { return try { val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8) val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1) ?: return UnlockResult.Error("invalid_codex_blob") val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob") if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed") writeMatchingNoiseSlotB(slotA) store.stampLocalMutation() unlockLocked(password) } catch (e: Exception) { UnlockResult.Error(e.message ?: "restore_failed") } } suspend fun createVault(password: String): CreateVaultResult = mutex.withLock { return try { val salt = VaultCrypto.generateSalt() val key = VaultCrypto.deriveKey(password, salt) 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") writeMatchingNoiseSlotB(slotA) store.stampLocalMutation() store.writeSettings(_settings.value) 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 (!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") writeMatchingNoiseSlotB(slotA) store.stampLocalMutation() store.writeSettings(_settings.value) val previousDerivedKey = derivedKey val previousRootKey = rootKey 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). */ 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 healed = try { 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, ) if (store.writeSlotA(next)) { writeMatchingNoiseSlotB(next) store.stampLocalMutation() true } else { false } } catch (_: Exception) { 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 { 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 { 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 { if (derivedKey == null || decoyMode) 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) { 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() if (derivedKey != null && !decoyMode) { 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), ) 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() { 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 } @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]. */ fun syncMasterKey(): ByteArray? = rootKey?.copyOf() /** Stable per-vault id two paired devices must share to sync. Null when locked. */ fun syncVaultId(): String? = 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 { val key = derivedKey ?: return false if (decoyMode) return false val priorSlot = store.loadSlotA() ?: return false val priorRootKey = rootKey ?: return false val priorRecoveryHash = recoveryWordHash val priorRecoverySet = recoveryWordsSet val nextRootKey = generateRecoverableRootKey() val nextWords = rootKeyToWords(nextRootKey) try { takeVaultSnapshot("local_save") 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, 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(priorRootKey) publish() true } catch (_: Exception) { rootKey = priorRootKey recoveryWordHash = priorRecoveryHash recoveryWordsSet = 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 return try { val 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() val newKey = VaultCrypto.deriveKey(newPassword, newSalt) // Re-seal the existing payload under the new key/salt. val 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, ) if (!store.writeSlotA(next)) return false writeMatchingNoiseSlotB(next) store.stampLocalMutation() VaultCrypto.zero(oldKey) derivedKey?.let(VaultCrypto::zero) derivedKey = newKey true } catch (_: Exception) { false } } /** * 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, )) val rollback = entryList.toMutableList() entryList.add(entry) persistOrRollback(rollback) entry } suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock { requireUnlocked() 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 persistOrRollback(rollback) updated } /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */ suspend fun deleteEntry(id: String) = mutex.withLock { requireUnlocked() 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) val rollback = entryList.toMutableList() entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp) persistOrRollback(rollback) } suspend fun restoreEntry(id: String) = mutex.withLock { requireUnlocked() val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null } if (idx == -1) throw IllegalStateException("deleted entry not found") val current = entryList[idx] val 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() val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null } if (idx == -1) throw IllegalStateException("deleted entry not found") val 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() val rollback = entryList.toMutableList() val before = entryList.size entryList.removeAll { it.deletedAt != null } val removed = before - entryList.size if (removed > 0) persistOrRollback(rollback) removed } /** Browser parity: number of active entries that dedupe would move to trash. */ suspend fun countDuplicateEntries(): Int = mutex.withLock { requireUnlocked() 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() if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode") 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() if (removed > 0) persistOrRollback(rollback) 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 takeVaultSnapshot("restore") if (!store.writeSlotA(restoredA)) return false if (!store.writeSlotB(snapshot.vault_b)) return false store.stampLocalMutation() lock() 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() val tombMap = remoteTombstones.associate { it.id to it.deletedAt } var applied = 0 val byId = entryList.associateBy { it.id }.toMutableMap() for (remote in remoteEntries) { 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; applied++ } else if (remote.modifiedAt > local.modifiedAt) { val i = entryList.indexOfFirst { it.id == local.id } if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ } } } 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); applied++ } } if (applied > 0) { persistSlotA(bumpVersion = false, snapshotReason = "sync_received") publish() } applied } suspend fun updateSettings(settings: VaultSettings) = mutex.withLock { _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() 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) 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 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 { if (rootKey == null) return false // must have recovered first return try { val newSalt = VaultCrypto.generateSalt() val newKey = VaultCrypto.deriveKey(newPassword, newSalt) val plaintext = VaultCodec.encode(currentPayload()) val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A)) val prior = store.loadSlotA() // 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) ?: prior?.recoveryEscrow else prior?.recoveryEscrow val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow val next = StoredVault( version = prior?.version ?: 1, salt = b64(newSalt), blob = blob, recoveryEscrow = freshRecovery, rootKeyEscrow = freshRootKey, ) if (!store.writeSlotA(next)) return false writeMatchingNoiseSlotB(next) store.stampLocalMutation() derivedKey?.let(VaultCrypto::zero) derivedKey = newKey VaultCrypto.zero(plaintext) publish() _isUnlocked.value = true true } catch (_: Exception) { false } } /** * 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() { if (derivedKey == null) throw IllegalStateException("vault_locked") } 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()}" } private fun takeVaultSnapshot(reason: String) { runCatching { val slotA = store.loadSlotA() ?: return 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 val entryCount = if (derivedKey != null && !decoyMode) { bucketEntryCount(entryList.count { it.deletedAt == null }) } else { null } val snapshot = VaultSnapshot( id = UUID.randomUUID().toString(), savedAt = System.currentTimeMillis(), reason = reason, vault_a = vaultA, vault_b = vaultB, entryCount = entryCount, ) store.writeVaultHistory( history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)), ) } } 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) { // 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) 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") 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 slot A if a later step fails. try { writeMatchingNoiseSlotB(next) if (bumpVersion) store.stampLocalMutation() } catch (e: Exception) { store.writeSlotA(prior) throw e } return true } /** Persist a local mutation, rolling the in-memory list back on failure. */ private fun persistOrRollback(rollback: MutableList) { try { if (!persistSlotA(bumpVersion = true)) { entryList = rollback throw IllegalStateException("storage_write_failed") } } catch (e: Exception) { entryList = rollback throw e } publish() } }