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 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() 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. */ /** 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) 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 (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") 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]. */ /** * 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 { 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 nextWords = rootKeyToWords(nextRootKey) return 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 } /** * 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 rollback = entryList.toMutableList() val now = System.currentTimeMillis() for (draft in drafts) { val stamp = VaultCrypto.monotonicEntryTimestamp() entryList.add( 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, ), ), ) } persistOrRollback(rollback) return drafts.size } 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() // 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) } var applied = 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; 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): 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") 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() } }