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.MessageDigest import java.security.SecureRandom import java.text.Normalizer import kotlinx.serialization.json.Json import android.os.SystemClock import android.util.Log import androidx.annotation.VisibleForTesting import com.peachpasswords.BuildConfig import com.peachpasswords.sync.SyncLimits import com.peachpasswords.sync.LockedSyncSpoolHooks import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import java.util.Base64 import java.util.UUID import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject import javax.inject.Singleton /** Lenient parser for the Codex vault-blob wrapper only. */ private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true } private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes) private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s) /** Cap on retained past passwords per entry. Mirrors the browser PASSWORD_HISTORY_MAX. */ private const val PASSWORD_HISTORY_MAX = 50 private const val VAULT_HISTORY_MAX = 5 // How long the transient-background-lock suppression stays valid. If the // launched system Activity never reports the expected lifecycle stop (picker // crashed, user backed out into another app, OEM killed the picker process), // the flag expires and the next genuine background event locks the vault // rather than consuming a stale suppression. private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L 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 } /** * Slot-A key rotation signal. * * Every site that re-seals slot A under a different Argon2id key must fire this: * the PIN and biometric escrows each wrap a copy of the OLD key, and a stale copy * passes the hardware verifier and then fails the slot AEAD — so the user is told * the credential was accepted and then that the vault key is wrong. Registered * once in [com.peachpasswords.PeachApplication] (PB-26-126). */ internal object VaultKeyRotationHooks { @Volatile var onRotated: () -> Unit = {} } internal object VaultMutationHooks { @Volatile var onCommitted: () -> Unit = {} } /** * 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 slotRollback = VaultSlotRollback(store) private val mutex = Mutex() // H-03: lock() and requireUnlocked() cannot take [mutex] from non-suspend // callers (ProcessLifecycleOwner, ViewModels, VaultLockTimer). This sync // lock guards ONLY the brief read/write of derivedKey/rootKey/entryList so // lock() can serialize against an in-flight suspend op without blocking on // Argon2id or store I/O. Suspend ops hold [mutex] for their full body and // take [stateLock] only around the state-write portions. Acquisition order // is always mutex → stateLock. private val stateLock = ReentrantLock() private val rng = SecureRandom() /** * Monotonic session generation. [lock] advances this before clearing any * secret state; work that captured the previous generation is therefore * stale even if it is still running its (non-suspending) storage step. */ private var sessionEpoch: Long = 0L private fun nextSessionEpoch(epoch: Long): Long = if (epoch == Long.MAX_VALUE) 0L else epoch + 1L /** Test seam for deterministic "lock while waiting to commit" races. */ @VisibleForTesting internal var beforeDurableCommit: () -> Unit = {} /** Test seam after a failed Codex rollback, while the epoch fence is held. */ @VisibleForTesting internal var afterCodexRollback: () -> Unit = {} /** * Run [block] with [stateLock] held. Used to fence the small state-write * portions of suspend operations against [lock] and [requireUnlocked] * racing from another thread. */ private inline fun mutateState(block: () -> T): T { stateLock.lock() return try { block() } finally { stateLock.unlock() } } /** Run a durable commit only while the session generation is still live. */ private inline fun withEpochCommit(epoch: Long, block: () -> T): T? { stateLock.lock() return try { if (sessionEpoch != epoch) null else block() } finally { stateLock.unlock() } } /** Test seam for a caller that must stage work before the epoch fence. */ @VisibleForTesting internal var beforeSessionEpochCommit: () -> Unit = {} /** * Test seam for the narrower H-03 race: a suspend mutation has authenticated * the session and staged its candidate state, but has not yet entered the * epoch-fenced in-memory write. Production callers leave this as a no-op. */ @VisibleForTesting internal var beforeSessionStateMutation: () -> Unit = {} /** * Run an external session-bound commit while the repository's epoch fence is * held. The callback is deliberately non-suspending: [lock] cannot advance * the epoch until the whole external batch has reached its linearization * point. */ internal fun withSessionEpochCommit(epoch: Long, block: () -> T): T? { beforeSessionEpochCommit() return withEpochCommit(epoch, block) } private fun currentEpoch(): Long = mutateState { sessionEpoch } private inline fun mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? = mutateState { if (sessionEpoch == epoch) block() else null } /** Invoke the pre-state-write seam, then linearize only in the same epoch. */ private inline fun mutateCurrentSession(epoch: Long, block: () -> T): T? { beforeSessionStateMutation() return mutateIfCurrentEpoch(epoch, block) } private data class PersistSnapshot( val epoch: Long, val key: ByteArray, val payload: VaultPayload, val decoy: Boolean, ) /** Copy all session material while fenced; callers own and must zero [key]. */ private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState { if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null val liveKey = derivedKey ?: return@mutateState null PersistSnapshot( epoch = sessionEpoch, key = liveKey.copyOf(), payload = currentPayloadLocked(), decoy = decoyMode, ) } private var derivedKey: ByteArray? = null private var rootKey: ByteArray? = null private var entryList: MutableList = mutableListOf() private var authenticatedDuressConfigured: Boolean = false private var recoveryWordHash: String? = null private var recoveryWordsSet: Boolean = false /** True when an authenticated vault payload is available for local reads. */ private var sessionDataAvailable: 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 _duressConfigured = MutableStateFlow(false) /** Authenticated payload state; never loaded from plaintext settings. */ val duressConfigured: StateFlow = _duressConfigured.asStateFlow() /** * UI-only gate for sync-retained background mode. The vault stays unlocked * in memory (sync can proceed) but the UI shows the lock screen until the * user authenticates on resume. Cleared by a full [lock] or an explicit * [clearUiGate] from the lock screen. */ private val _uiGateLocked = MutableStateFlow(false) val uiGateLocked: StateFlow = _uiGateLocked.asStateFlow() private val _entries = MutableStateFlow>(emptyList()) /** Active (non-deleted) entryList. */ val entries: StateFlow> = _entries.asStateFlow() // Settings live in plain prefs, not inside the sealed vault — they hold no // secrets — so they are readable before unlock. Loading them here keeps the // lock screen at the user's chosen appearance instead of the defaults, which // otherwise snap to the real values the moment they authenticate. private val _settings = MutableStateFlow(store.loadSettings()) val settings: StateFlow = _settings.asStateFlow() /** Clear every in-memory session field while [stateLock] is held. */ private fun clearSessionStateLocked() { derivedKey?.let(VaultCrypto::zero) rootKey?.let(VaultCrypto::zero) derivedKey = null rootKey = null entryList = mutableListOf() authenticatedDuressConfigured = false _duressConfigured.value = false recoveryWordHash = null recoveryWordsSet = false sessionDataAvailable = false decoyMode = false _settings.value = store.loadSettings() _entries.value = emptyList() _isUnlocked.value = false } fun hasVault(): Boolean = store.hasVault() // ---- Codex (printed paper backup) ------------------------------------- /** * The at-rest slot pair as base64 JSON `{"a": , "b": }` — the * Codex payload's vaultBlob. "b" is the duress slot (matching the browser's * A/B naming where slot B is the duress decoy); the noise slot is local-only * and never crosses devices. Restorable on any device that has the master * password. Null when no vault exists. */ fun codexVaultBlobBase64(): String? { val slotA = store.loadSlotA() ?: return null // H-04: duress slot is always present after createVault (real or fake), // so the wrapper can rely on it being loadable. val slotD = store.loadDecoySlot() ?: return null val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}""" 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 { val expectedEpoch = currentEpoch() 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() val priorD = store.loadDecoySlot() // Carry the real duress slot across when the Codex has one — the // browser writes "b" as its duress-shaped slot, and dropping it // here would silently destroy a configured decoy. // H-04: write browser B into the duress slot (not the noise slot), // translating browser-shaped slots to Android's StoredVault shape. val codexDuressJson = wrapper["b"].asSlotJson() val codexDuress: StoredVault? = codexDuressJson?.let { VaultCodec.decodeSlot(it)?.let { slot -> if (slot.version != LEGACY_ANDROID_SLOT_VERSION && slot.version != CURRENT_ANDROID_SLOT_VERSION) null else slot } ?: BrowserSlot.parse(it)?.let { b -> browserDuressPassthrough(b) } } beforeDurableCommit() val committed = runCatching { withEpochCommit(expectedEpoch) { try { // Keep every slot write, including the inverse, under the // same epoch fence. Releasing stateLock after a partial // write and reacquiring it for rollback lets lock() advance // the epoch in between and strand the incoming slot A. if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed") // Noise slot is always size-matched to slot A — local-only, // never written from a Codex. if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed") // H-04: duress slot always present; real one from the Codex or // a fake the size of slot A. val duressOk = if (codexDuress != null) { store.writeDecoySlot(codexDuress) } else { writeFakeDuressSlot(slotA) } if (!duressOk) throw IllegalStateException("duress_slot_commit_failed") store.stampLocalMutation() true } catch (_: Exception) { reportRollbackFailures(slotRollback.restoreTriad(priorA, priorB, priorD)) // Keep the fence held through the final rollback // observation. A concurrent lock() may queue here, but // cannot invalidate the restored A/B/D triad. afterCodexRollback() false } } ?: false }.getOrDefault(false) if (!committed) { return UnlockResult.Error("storage_write_failed") } val ownedKey = candidateKey!! candidateKey = null // the session owns the key from here on VaultKeyRotationHooks.onRotated() adoptSlotA(ownedKey, payload, expectedEpoch) } 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() private fun reportRollbackFailures(failures: List) { // 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 { val expectedEpoch = currentEpoch() var key: ByteArray? = null return try { val salt = VaultCrypto.generateSalt() key = VaultCrypto.deriveKey(password, salt) if (mutateIfCurrentEpoch(expectedEpoch) { entryList = mutableListOf() authenticatedDuressConfigured = false _duressConfigured.value = false recoveryWordHash = null recoveryWordsSet = false decoyMode = false sessionDataAvailable = false rootKey = generateRecoverableRootKey() } == null) return CreateVaultResult.Error("stale_create") // M-04: the fields above have to be live before sealSlotA, since it // seals currentPayload(). That stages a root key the session would // otherwise keep after a failed create — [lock] discards the whole // staged session on every non-success exit below. val slotA = sealSlotA(key!!, salt) // P1-3 (codex): every later step must roll the slot pair back if it // fails — leaving a half-built vault is worse than refusing to make it. beforeDurableCommit() val created = withEpochCommit(expectedEpoch) { if (!store.writeSlotA(slotA)) return@withEpochCommit false try { if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed") // H-04: duress slot must be present from the start so its // existence on disk is independent of authenticatedDuressConfigured. if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed") store.stampLocalMutation() if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed") true } catch (_: Exception) { // Half-built vault on disk is worse than no vault — refuse to // create if any step past slot A fails. store.clearVault() false } } ?: false if (!created) { lock() return CreateVaultResult.Error("storage_write_failed") } val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing") if (mutateIfCurrentEpoch(expectedEpoch) { derivedKey = sessionKey key = null publish() _isUnlocked.value = true sessionDataAvailable = true sessionEpoch = nextSessionEpoch(sessionEpoch) } == null) return CreateVaultResult.Error("stale_create") CreateVaultResult.Success } catch (e: Exception) { lock() CreateVaultResult.Error(e.message ?: "create_failed") } finally { key?.let(VaultCrypto::zero) } } suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock { val expectedEpoch = currentEpoch() 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 plaintext = VaultCodec.encode(payload) val blob = try { b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A)) } finally { VaultCrypto.zero(plaintext) } val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob) // P1-3 (codex): same partial-commit guard as createVault — any failure // past slot A wipes the half-built vault and refuses adoption. beforeDurableCommit() val created = withEpochCommit(expectedEpoch) { if (!store.writeSlotA(slotA)) return@withEpochCommit false try { if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed") // H-04: duress slot must be present from the start so its // existence on disk is independent of authenticatedDuressConfigured. if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed") store.stampLocalMutation() if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed") true } catch (_: Exception) { store.clearVault() false } } ?: false if (!created) return AdoptVaultResult.Error("storage_write_failed") val adopted = mutateIfCurrentEpoch(expectedEpoch) { val previousDerivedKey = derivedKey val previousRootKey = rootKey derivedKey = nextDerivedKey rootKey = sessionRootKey nextDerivedKey = null sessionRootKey = null entryList = mutableListOf() authenticatedDuressConfigured = false _duressConfigured.value = false recoveryWordHash = null recoveryWordsSet = false decoyMode = false sessionDataAvailable = true publish() _isUnlocked.value = true sessionEpoch = nextSessionEpoch(sessionEpoch) previousDerivedKey to previousRootKey } if (adopted == null) return AdoptVaultResult.Error("stale_adoption") val (previousDerivedKey, previousRootKey) = adopted previousDerivedKey?.let(VaultCrypto::zero) previousRootKey?.let(VaultCrypto::zero) // Write the recovery escrow so the forgot-password flow works on // this device. The words are deterministic from the root key and // were already confirmed on the source device. runCatching { val words = rootKeyToWords(rootKey!!) val escrow = buildRecoveryEscrow(words) val rkEscrow = buildRootKeyEscrow(words) if (escrow != null || rkEscrow != null) { val prior = store.loadSlotA() if (prior != null) { store.writeSlotA(prior.copy( recoveryEscrow = escrow ?: prior.recoveryEscrow, rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow, )) } mutateState { recoveryWordHash = recoveryWordsHash(words) recoveryWordsSet = true } } } 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 { val result = unlockLocked(password, currentEpoch()) if (result is UnlockResult.Success) ensureRecoveryEscrow() result } /** Unlock body without acquiring the mutex — callers must already hold it. */ private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult { val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault") val key = VaultCrypto.deriveKey(password, unb64(slotA.salt)) val payload = try { val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A) try { VaultCodec.decode(plaintext) } finally { VaultCrypto.zero(plaintext) } } 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, expectedEpoch) if (legacy != null) return legacy } return UnlockResult.WrongPassword } return adoptSlotA(key, payload, expectedEpoch) } /** * LV-1 legacy fallback: unlock a slot-A vault that was sealed under the raw * (un-normalized) password bytes. On success the slot is HEALED — re-sealed * under the NFC-normalized key — so every future unlock takes the normal * path. Returns null when the raw key also fails (genuinely wrong password) * OR when any step of the heal commits fails (the original slot A is restored * so disk and session stay aligned). */ private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? { var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt)) var newKey: ByteArray? = null return try { val payload = try { val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A) try { VaultCodec.decode(plaintext) } finally { VaultCrypto.zero(plaintext) } } catch (_: Exception) { 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() newKey = VaultCrypto.deriveKey(password, newSalt) val plaintext = VaultCodec.encode(payload) val blob = try { b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A)) } finally { VaultCrypto.zero(plaintext) } val next = StoredVault( version = slotA.version, salt = b64(newSalt), blob = blob, recoveryEscrow = slotA.recoveryEscrow, rootKeyEscrow = slotA.rootKeyEscrow, ) // P1-1/P1-3/P1-2 (codex): any failure of writeSlotA, writeMatchingNoiseSlotB, // or stampLocalMutation must restore the prior slot A AND slot B so disk // and the eventual session key never disagree on the wrapping. val priorB = store.loadSlotB() beforeDurableCommit() val healed = withEpochCommit(expectedEpoch) { if (!store.writeSlotA(next)) { false } else try { if (!writeMatchingNoiseSlotB(next)) { runCatching { store.writeSlotA(slotA) } if (priorB != null) runCatching { store.writeSlotB(priorB) } false } else if (!writeCompanionDuressSlot(next)) { runCatching { store.writeSlotA(slotA) } if (priorB != null) runCatching { store.writeSlotB(priorB) } false } else { store.stampLocalMutation() true } } catch (_: Exception) { runCatching { store.writeSlotA(slotA) } if (priorB != null) runCatching { store.writeSlotB(priorB) } false } } ?: false if (healed) { VaultCrypto.zero(rawKey!!) rawKey = null val result = adoptSlotA(newKey!!, payload, expectedEpoch) // session key now matches the re-sealed slot newKey = null result } else { VaultCrypto.zero(newKey!!) newKey = null val result = adoptSlotA(rawKey!!, payload, expectedEpoch) // heal failed — keep using the raw key rawKey = null result } } finally { // Cover codec, encryption, storage, and stale-epoch failures before // either candidate key can be transferred into the live session. rawKey?.let(VaultCrypto::zero) newKey?.let(VaultCrypto::zero) } } /** Adopt a derived slot-A [key]/[payload] as the live (real-vault) session. */ private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult { return try { // M-04: ensureRootKey and autoPurgeExpiredTrash both persist, so they // can throw after the fields above are live. There is no prior session // worth restoring — adoption has already replaced it — so a failed // adoption discards everything rather than leaving entries and keys // resident behind _isUnlocked=false. val adopted = mutateIfCurrentEpoch(expectedEpoch) { derivedKey = key decoyMode = false entryList = payload.entries.toMutableList() authenticatedDuressConfigured = payload.duressConfigured _duressConfigured.value = payload.duressConfigured recoveryWordHash = payload.recoveryWordHash recoveryWordsSet = payload.recoveryWordsSet sessionDataAvailable = true _settings.value = store.loadSettings() ensureRootKey(payload) autoPurgeExpiredTrash() consolidatePasskeyEntries() publish() _isUnlocked.value = true // A successful adoption starts a new generation. A sibling // SlotAUnlock captured in the prior epoch will fail closed at // the next commit instead of replacing this session. sessionEpoch = nextSessionEpoch(sessionEpoch) } if (adopted == null) { VaultCrypto.zero(key) UnlockResult.Error("stale_unlock") } else { UnlockResult.Success } } catch (e: Exception) { // Do not call lock() here: a concurrent newer session may already // own this repository. Only invalidate the failed epoch's fields. mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() } VaultCrypto.zero(key) 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( key: ByteArray, internal val payload: VaultPayload, internal val epoch: Long, ) { private var ownedKey: ByteArray? = key private var consumed = false /** Transfer the key exactly once to the adopter. */ internal fun takeKey(): ByteArray? = synchronized(this) { if (consumed) return@synchronized null consumed = true ownedKey.also { ownedKey = null } } /** Discard an uncommitted token without touching a transferred key. */ internal fun discard() = synchronized(this) { if (consumed) return@synchronized consumed = true ownedKey?.let(VaultCrypto::zero) ownedKey = null } } /** * 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 epoch = currentEpoch() val slotA = store.loadSlotA() ?: return null val key = VaultCrypto.deriveKey(password, unb64(slotA.salt)) try { val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A) val payload = try { VaultCodec.decode(plaintext) } finally { VaultCrypto.zero(plaintext) } return SlotAUnlock(key, payload, epoch) } catch (_: Exception) { VaultCrypto.zero(key) } if (password == Normalizer.normalize(password, Normalizer.Form.NFC)) return null return tryLegacyRawCandidate(password, slotA, epoch) } /** Legacy raw-password fallback; successful candidates heal slot A to NFC. */ private fun tryLegacyRawCandidate(password: String, slotA: StoredVault, epoch: Long): SlotAUnlock? { var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt)) var newKey: ByteArray? = null return try { val plaintext = try { VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A) } catch (_: Exception) { return null } val payload = try { VaultCodec.decode(plaintext) } finally { VaultCrypto.zero(plaintext) } val newSalt = VaultCrypto.generateSalt() newKey = VaultCrypto.deriveKey(password, newSalt) val encoded = VaultCodec.encode(payload) val blob = try { b64(VaultCrypto.encrypt(encoded, newKey!!, VaultCrypto.AAD_SLOT_A)) } finally { VaultCrypto.zero(encoded) } val next = slotA.copy(salt = b64(newSalt), blob = blob) val priorB = store.loadSlotB() beforeDurableCommit() val healed = withEpochCommit(epoch) { if (!store.writeSlotA(next)) false else try { if (!writeMatchingNoiseSlotB(next) || !writeCompanionDuressSlot(next)) { runCatching { store.writeSlotA(slotA) } if (priorB != null) runCatching { store.writeSlotB(priorB) } false } else { store.stampLocalMutation() true } } catch (_: Exception) { runCatching { store.writeSlotA(slotA) } if (priorB != null) runCatching { store.writeSlotB(priorB) } false } } ?: false if (healed) { VaultCrypto.zero(rawKey!!) rawKey = null SlotAUnlock(newKey!!, payload, epoch).also { newKey = null } } else { VaultCrypto.zero(newKey!!) newKey = null SlotAUnlock(rawKey!!, payload, epoch).also { rawKey = null } } } finally { rawKey?.let(VaultCrypto::zero) newKey?.let(VaultCrypto::zero) } } /** Adopt a previously-derived [SlotAUnlock] as the live session. */ suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock { val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed") adoptSlotA(key, unlock.payload, unlock.epoch) } /** Zero a derived [SlotAUnlock] that will not be adopted. */ fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard() // ---- 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( key: ByteArray, internal val payload: VaultPayload, internal val epoch: Long, ) { private var ownedKey: ByteArray? = key private var consumed = false internal fun takeKey(): ByteArray? = synchronized(this) { if (consumed) return@synchronized null consumed = true ownedKey.also { ownedKey = null } } internal fun discard() = synchronized(this) { if (consumed) return@synchronized consumed = true ownedKey?.let(VaultCrypto::zero) ownedKey = null } } /** * 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 epoch = currentEpoch() val slot = store.loadDecoySlot() ?: return null if (slot.version != LEGACY_ANDROID_SLOT_VERSION && slot.version != CURRENT_ANDROID_SLOT_VERSION) return null var key: ByteArray? = VaultCrypto.deriveKey(password, unb64(slot.salt)) return try { val plaintext = try { if (slot.version == CURRENT_ANDROID_SLOT_VERSION) { VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_B) } else { runCatching { VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_A) }.getOrElse { VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_B) } } } catch (_: Exception) { VaultCrypto.zero(key!!) key = null return null } val payload = try { VaultCodec.decode(plaintext) } finally { VaultCrypto.zero(plaintext) } if (slot.version == LEGACY_ANDROID_SLOT_VERSION) { val migratedKey = migrateLegacyDuressSlot(slot, password, payload, key!!, epoch) ?: run { VaultCrypto.zero(key!!) key = null return null } if (migratedKey !== key) { VaultCrypto.zero(key!!) key = migratedKey } } DuressUnlock(key!!, payload, epoch).also { key = null } } catch (_: Exception) { key?.let(VaultCrypto::zero) null } } /** * Re-seal an authenticated legacy decoy under the current Slot-B AAD. * A failed write or verification restores the exact legacy record, while * already-authenticated unlock remains usable for this session. */ private fun migrateLegacyDuressSlot( legacy: StoredVault, password: String, payload: VaultPayload, legacyKey: ByteArray, epoch: Long, ): ByteArray? { var salt: ByteArray? = null var migratedKey: ByteArray? = null var plaintext: ByteArray? = null var restoredLegacy = false var migrated = false try { salt = VaultCrypto.generateSalt() migratedKey = VaultCrypto.deriveKey(password, salt) plaintext = VaultCodec.encode(payload) val next = StoredVault( version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(salt), blob = b64(VaultCrypto.encrypt(plaintext, migratedKey, VaultCrypto.AAD_SLOT_B)), ) var priorA: StoredVault? = null var priorB: String? = null withSessionEpochCommit(epoch) { priorA = store.loadSlotA() priorB = store.loadSlotB() if (store.loadDecoySlot() != legacy) return@withSessionEpochCommit false if (!store.writeDecoySlot(next)) { restoredLegacy = store.loadDecoySlot() == legacy return@withSessionEpochCommit restoredLegacy } val reopened = store.loadDecoySlot() val verified = reopened?.version == CURRENT_ANDROID_SLOT_VERSION && runCatching { val reopenedPlaintext = VaultCrypto.decrypt(unb64(reopened.blob), migratedKey!!, VaultCrypto.AAD_SLOT_B) try { VaultCodec.decode(reopenedPlaintext) == payload } finally { VaultCrypto.zero(reopenedPlaintext) } }.getOrDefault(false) if (verified) { migrated = true return@withSessionEpochCommit true } val restored = priorA != null && store.restoreVaultTriad(priorA!!, priorB, legacy) restoredLegacy = restored && store.loadSlotA() == priorA && store.loadSlotB() == priorB && store.loadDecoySlot() == legacy restoredLegacy } if (migrated) { VaultCrypto.zero(legacyKey) return migratedKey.also { migratedKey = null } } if (restoredLegacy) return legacyKey } catch (_: Exception) { // A failed migration must not claim a legacy key unless the exact // legacy record was durably restored and observed. } if (!restoredLegacy) runCatching { store.loadDecoySlot() == legacy } migratedKey?.let(VaultCrypto::zero) return if (restoredLegacy) legacyKey else null } /** Adopt a derived [DuressUnlock] as a live decoy session (edits persist to slot B only). */ suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock { val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed") if (currentEpoch() != unlock.epoch) { VaultCrypto.zero(key) return@withLock UnlockResult.Error("stale_unlock") } return try { val adopted = mutateIfCurrentEpoch(unlock.epoch) { derivedKey = key decoyMode = true entryList = unlock.payload.entries.toMutableList() authenticatedDuressConfigured = false _duressConfigured.value = false recoveryWordHash = unlock.payload.recoveryWordHash recoveryWordsSet = unlock.payload.recoveryWordsSet sessionDataAvailable = true rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64) else VaultCrypto.generateRootKey() // The decoy session gets its own defaults, never the real vault's // record. Settings are shared, plaintext state loaded at every // session start, so reading it here both told the coercer the real // configuration and let updateSettings below persist changes into // the record the real vault loads next (PB-26-120). _settings.value = VaultSettings() publish() _isUnlocked.value = true sessionEpoch = nextSessionEpoch(sessionEpoch) } if (adopted == null) { VaultCrypto.zero(key) UnlockResult.Error("stale_unlock") } else { UnlockResult.Success } } catch (e: Exception) { // A payload/root/settings failure can occur after the candidate key // has been assigned to derivedKey. Tear down only this epoch so a // newer session is not disturbed, and scrub the token reference on // every error path. mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() } VaultCrypto.zero(key) UnlockResult.Error(e.message ?: "decoy_unlock_failed") } } /** Zero a derived [DuressUnlock] that will not be adopted. */ fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard() /** * Configure the duress vault (MF-6): seal a fresh, empty decoy payload under an * Argon2id key derived from [duressPassphrase] (fresh salt) into slot B. The * passphrase is never stored — only its ability to decrypt slot B proves it. * Requires the real vault to be unlocked and not itself a decoy session. */ suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock { // H-03: read derivedKey/decoyMode atomically with any concurrent lock(). val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null } ?: 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 } var key: ByteArray? = null var basePlaintext: ByteArray? = null return try { val salt = VaultCrypto.generateSalt() key = VaultCrypto.deriveKey(duressPassphrase, salt) // An empty decoy vault with its own fresh sync root key — nothing real leaks. val decoyRoot = VaultCrypto.generateRootKey() val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) } val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64) basePlaintext = VaultCodec.encode(decoyPayload) // H-04: pad the duress plaintext to slot A's ciphertext length so the // duress blob matches slot A's blob on disk. VaultCodec.decode strips // trailing NULs, so the extra padding survives encrypt but never // reaches the JSON parser. // This decoy plaintext length is frozen from here on: re-sealing it // on later growth would need the duress passphrase, which is never // retained. VaultStore compensates by re-padding the two slot // RECORDS to the longer of the two on every write, so the frozen // blob no longer shows up as a length difference at rest. val slotA = store.loadSlotA() val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!! val blob = try { b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_B)) } finally { VaultCrypto.zero(plaintext) // Padding creates a second array; scrub the original encoded // payload too rather than dropping its last reference untouched. if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero) basePlaintext = null key?.let(VaultCrypto::zero) key = null } beforeDurableCommit() val ok = withEpochCommit(expectedEpoch) { store.writeDecoySlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(salt), blob = blob)) } ?: false if (!ok) return false val marked = mutateIfCurrentEpoch(expectedEpoch) { authenticatedDuressConfigured = true _duressConfigured.value = true true } ?: false if (!marked) return false if (!runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }.getOrDefault(false)) { mutateIfCurrentEpoch(expectedEpoch) { authenticatedDuressConfigured = false _duressConfigured.value = false } runCatching { writeFakeDuressSlot(store.loadSlotA()!!) } return false } true } catch (_: Exception) { false } finally { // Cover failures before the encrypt block is entered (for example a // codec/copy exception) as well as any early return after key derivation. key?.let(VaultCrypto::zero) basePlaintext?.let(VaultCrypto::zero) } } /** * Export the decoy slot as browser-shaped JSON for adoption snapshots. * * Returns the sealed slot as `{version, salt, nonce, ciphertext, tag}` all * lowercase hex — identical to the browser's `duress_slot` wire format — so * any receiver (browser or Android) can install it via the existing * passthrough. Returns null when duress is not configured, no decoy exists, * or any conversion failure occurs. Never decrypts. */ suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock { if (!authenticatedDuressConfigured) return null val slot = store.loadDecoySlot() ?: return null return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull() } /** * Convert an Android [StoredVault] decoy slot to browser-shaped JSON. * * Mirrors the inverse of [installAdoptedDuressSlot]: base64 salt/blob are * split into lowercase hex fields matching [BrowserSlotA]. Intermediate * byte arrays are zeroed in finally. */ private fun androidDuressSlotToBrowserJson(slot: StoredVault): String { // Build the wire JSON directly with JsonObject so `version: 1` is ALWAYS // serialized — kotlinx-serialization's encodeDefaults = false would // otherwise omit BrowserSlotA.version (its Kotlin default), and the // browser's hasValidVaultSchema strictly requires `r.version === 1`, // silently dropping the duress slot on Android→browser adoptions. var salt: ByteArray? = null var blob: ByteArray? = null var nonce: ByteArray? = null var ctAndTag: ByteArray? = null var ct: ByteArray? = null var tag: ByteArray? = null try { salt = unb64(slot.salt) blob = unb64(slot.blob) nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE) ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size) ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16) tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size) val obj = JsonObject(mapOf( "version" to JsonPrimitive(1), "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }), "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }), "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }), "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }), )) return obj.toString() } finally { salt?.let(VaultCrypto::zero) blob?.let(VaultCrypto::zero) nonce?.let(VaultCrypto::zero) ctAndTag?.let(VaultCrypto::zero) ct?.let(VaultCrypto::zero) tag?.let(VaultCrypto::zero) } } /** * Install a browser-shaped duress slot received through adoption. */ suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock { val b = BrowserSlot.parse(json) ?: return false if (b.version != 1) return false val expectedEpoch = currentEpoch() var salt: ByteArray? = null var nonce: ByteArray? = null var ct: ByteArray? = null var tag: ByteArray? = null var blob: ByteArray? = null try { salt = unhex(b.salt) nonce = unhex(b.nonce) ct = unhex(b.ciphertext) tag = unhex(b.tag) blob = ByteArray(nonce.size + ct.size + tag.size).also { System.arraycopy(nonce, 0, it, 0, nonce.size) System.arraycopy(ct, 0, it, nonce.size, ct.size) System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size) } val stored = StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = b64(blob)) val priorA = store.loadSlotA() ?: return false val priorB = store.loadSlotB() val priorD = store.loadDecoySlot() ?: return false beforeDurableCommit() val committed = withEpochCommit(expectedEpoch) { if (!store.writeDecoySlot(stored)) return@withEpochCommit false authenticatedDuressConfigured = true _duressConfigured.value = true true } ?: false if (!committed) return false if (runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }.getOrDefault(false)) { true } else { mutateIfCurrentEpoch(expectedEpoch) { authenticatedDuressConfigured = false _duressConfigured.value = false } withEpochCommit(expectedEpoch) { store.restoreVaultTriad(priorA, priorB, priorD) } false } } catch (_: Exception) { false } finally { salt?.let(VaultCrypto::zero) nonce?.let(VaultCrypto::zero) ct?.let(VaultCrypto::zero) tag?.let(VaultCrypto::zero) blob?.let(VaultCrypto::zero) } } /** Tear down the duress vault: drop slot B and the duress flag on the real payload. */ suspend fun clearDuress(): Boolean = mutex.withLock { // H-04: presence-indistinguishable — replace the real decoy with a fake // blob of slot A's size rather than removing the duress record. The // record stays on disk at the same length, so an at-rest observer // cannot tell "duress cleared" from "no duress ever configured". val expectedEpoch = currentEpoch() val replaced = withEpochCommit(expectedEpoch) { store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) } ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false) } ?: false if (!replaced) return@withLock false // H-03: stateLock-fence the derivedKey/decoyMode read AND the // authenticatedDuressConfigured write so a concurrent lock() cannot land between them. if (!isSessionEpochCurrent(expectedEpoch)) return false val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { authenticatedDuressConfigured = false _duressConfigured.value = false } != null) { runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) } } true } /** True for the provider-specific exception used for a valid GCM blob/key mismatch. */ private fun isGcmAuthenticationFailure(error: Throwable): Boolean { var current: Throwable? = error while (current != null) { if (current is javax.crypto.AEADBadTagException) return true // Some Android providers expose a GCM tag failure as the parent // BadPaddingException rather than the AEAD-specific subtype. A // short ciphertext is an IllegalBlockSizeException and therefore // remains an internal/malformed-payload Error below. if (current is javax.crypto.BadPaddingException) return true current = current.cause } return false } /** Unlock with an already-derived AES-256 key (biometric path). */ suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock { val expectedEpoch = currentEpoch() val slotA = try { store.loadSlotA() } catch (_: Exception) { return@withLock UnlockResult.Error("unlock_key_storage_failed") } ?: return@withLock UnlockResult.Error("no_vault") // Keep the caller-owned key separate from the staged session key. The // biometric caller always zeroes its input in a finally block; adoption // must therefore own a copy and use the same all-or-nothing path as a // password unlock rather than publishing a partially decoded session. var stagedKey: ByteArray? = key.copyOf() return try { val encrypted = try { unb64(slotA.blob) } catch (_: Exception) { return@withLock UnlockResult.Error("invalid_vault_blob") } // A GCM record must include nonce + authentication tag. Providers // differ on whether a shorter record is reported as BadPadding or // AEADBadTag; reject it before decrypt so malformed storage cannot be // misreported as a user-entered wrong key. if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) { return@withLock UnlockResult.Error("invalid_vault_blob") } val plaintext = try { VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A) } catch (e: Exception) { if (isGcmAuthenticationFailure(e)) { return@withLock UnlockResult.WrongPassword } return@withLock UnlockResult.Error("unlock_key_decrypt_failed") } val payload = try { VaultCodec.decode(plaintext) } catch (_: Exception) { return@withLock UnlockResult.Error("invalid_vault_payload") } finally { VaultCrypto.zero(plaintext) } val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing") val result = adoptSlotA(candidate, payload, expectedEpoch) if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() } result } finally { stagedKey?.let(VaultCrypto::zero) } } fun lock() { // Never snapshot a mutation that has changed entryList but has not yet // crossed its durable epoch fence. Successful writes refresh the spool // before releasing this mutex; an in-flight writer therefore makes this // lock skip the convenience refresh and fail closed to the prior cache. if (mutex.tryLock()) { try { runCatching { LockedSyncSpoolHooks.refresh(this) } } finally { mutex.unlock() } } // H-03: must observe derivedKey/entryList atomically with any in-flight // suspend op (an addEntry mid-encrypt, an unlock adopting the payload, // etc.). mutex cannot be acquired from this non-suspend caller; stateLock // guards just the brief state reset. Advancing the epoch first invalidates // every operation that captured this session before it reaches a later // durable commit or publication. stateLock.lock() try { sessionEpoch = nextSessionEpoch(sessionEpoch) clearSessionStateLocked() _uiGateLocked.value = false } finally { stateLock.unlock() } } /** * Enter sync-retained background mode: the UI shows the lock screen but the * vault stays fully unlocked in memory so [PeachSyncService] can complete * sync sessions. Called from [PeachApplication.onStop] when background sync * is enabled. A subsequent full [lock] (auto-lock timer, manual lock) * supersedes this and zeroes everything. */ fun lockUiGate() { if (_isUnlocked.value) _uiGateLocked.value = true } /** Dismiss the UI gate after the user authenticates on resume. */ fun clearUiGate() { _uiGateLocked.value = false } @Volatile private var suppressNextBackgroundLockUntil: Long = 0L // Test seam so the expiry branch is reachable from plain JUnit (SystemClock // is a stub off-device). @VisibleForTesting internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() } /** * 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. * * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS] * (M-07); a never-consumed stale flag cannot suppress a later real lock. */ fun expectTransientBackground() { suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS } /** * Consumed once by the next background-lock check; see [expectTransientBackground]. * Returns false (and clears the flag) if the suppression has expired or was * never set — a genuine background event must always lock. */ fun consumeTransientBackgroundSuppression(): Boolean { val until = suppressNextBackgroundLockUntil val active = until != 0L && nowMs() < until suppressNextBackgroundLockUntil = 0L return active } // ---- 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. */ // M-04: the "null when locked" half of the contract was documented but never // enforced — a rootKey outlives _isUnlocked in the recovery half-unlock (see // [unlockWithRecoveryWords], which installs a root key with no password key // yet) and in a create that failed after staging. Sync must fail closed // there, so the authoritative unlocked flag gates it, not just decoyMode. internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray) internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState { if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) } } internal fun isSessionEpochCurrent(epoch: Long): Boolean = mutateState { sessionEpoch == epoch } internal fun unlockedSessionEpoch(): Long? = mutateState { if (derivedKey != null && _isUnlocked.value) sessionEpoch else null } fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key /** * 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? = mutateState { if (!sessionDataAvailable) null else 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? = mutateState { if (decoyMode || !_isUnlocked.value) 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 { val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null } ?: return false installRootKey(generateRecoverableRootKey(), expectedEpoch) } /** * 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") } val expectedEpoch = mutateState { when { derivedKey == null -> null decoyMode -> Long.MIN_VALUE else -> sessionEpoch } } if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked") if (expectedEpoch == Long.MIN_VALUE) 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, expectedEpoch)) { 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, expectedEpoch: Long): Boolean { // H-03: snapshot the live fields under stateLock so the success and the // rollback paths both see consistent prior/new state. Writing the new // rootKey/recovery fields is inside its own mutateState at the end. // H-03 (P1-3 codex): the snapshot must COPY the live key/rootKey arrays // — holding live references would let a zero-on-failure operation also // wipe the session's still-live key arrays. data class Snapshot( val key: ByteArray, val priorRootKey: ByteArray, val priorRecoveryHash: String?, val priorRecoverySet: Boolean, ) val snap = mutateIfCurrentEpoch(expectedEpoch) { val k = derivedKey ?: return@mutateIfCurrentEpoch null if (decoyMode) return@mutateIfCurrentEpoch null val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet) } ?: run { VaultCrypto.zero(nextRootKey) return false } val priorSlot = store.loadSlotA() ?: run { VaultCrypto.zero(snap.key) VaultCrypto.zero(snap.priorRootKey) VaultCrypto.zero(nextRootKey) return false } val nextWords = rootKeyToWords(nextRootKey) return try { beforeDurableCommit() if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed") if (mutateIfCurrentEpoch(expectedEpoch) { rootKey = nextRootKey recoveryWordHash = recoveryWordsHash(nextWords) recoveryWordsSet = false } == null) throw IllegalStateException("stale_root_rotation") val nextRecoveryEscrow = buildRecoveryEscrow(nextWords) ?: throw IllegalStateException("recovery_escrow_failed") val nextRootKeyEscrow = buildRootKeyEscrow(nextWords) ?: throw IllegalStateException("root_escrow_failed") val plaintext = VaultCodec.encode(currentPayload()) val blob = try { b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A)) } finally { VaultCrypto.zero(plaintext) } val nextSlot = priorSlot.copy( blob = blob, recoveryEscrow = nextRecoveryEscrow, rootKeyEscrow = nextRootKeyEscrow, ) val committed = withEpochCommit(expectedEpoch) { if (!store.writeSlotA(nextSlot)) return@withEpochCommit false try { if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed") // H-04: a root-key rotation re-seals slot A, so the duress slot // (real or fake) must be re-padded to the new ciphertext length. if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed") store.stampLocalMutation() } catch (_: Exception) { store.writeSlotA(priorSlot) return@withEpochCommit false } true } ?: false if (!committed) throw IllegalStateException("storage_write_failed") mutateIfCurrentEpoch(expectedEpoch) { publish() } true } catch (_: Exception) { val restored = mutateIfCurrentEpoch(expectedEpoch) { rootKey?.let(VaultCrypto::zero) rootKey = snap.priorRootKey.copyOf() recoveryWordHash = snap.priorRecoveryHash recoveryWordsSet = snap.priorRecoverySet } if (restored != null) { withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) } } false } finally { VaultCrypto.zero(snap.key) VaultCrypto.zero(snap.priorRootKey) // On success nextRootKey is owned by rootKey. On every other path // it is not, including a stale epoch after lock(); scrub it while // the local reference is still available. val stillOwnedBySession = mutateState { sessionEpoch == expectedEpoch && rootKey === nextRootKey } if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey) } } /** * 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? = mutateState { if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf() } suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock { val expectedEpoch = currentEpoch() val slotA = store.loadSlotA() ?: return false var oldKey: ByteArray? = null var newKey: ByteArray? = null var plaintext: ByteArray? = null return try { oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt)) // Keep ownership of the decrypted vault plaintext until decoding has // finished. Passing decrypt() inline made the byte array unreachable // before the finally block, so a successful or failed password change // could leave the full plaintext resident in the heap. val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A) val payload = try { VaultCodec.decode(decrypted) } finally { VaultCrypto.zero(decrypted) } val newSalt = VaultCrypto.generateSalt() newKey = VaultCrypto.deriveKey(newPassword, newSalt) // Re-seal the existing payload under the new key/salt. plaintext = VaultCodec.encode(payload) val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A)) // The escrow seals the slot-A PLAINTEXT, which a password change does not // alter — only the wrapping changes — so carry it forward; rebuilding the // StoredVault from scratch would silently drop it. // Carry BOTH escrows forward unchanged: each seals plaintext/root-key // content that a password change does not alter — only the slot-A // wrapping rotates. The root-key escrow staying stable across password // changes matches the browser (`vault.ts:363-364`). val next = StoredVault( version = slotA.version, salt = b64(newSalt), blob = blob, recoveryEscrow = slotA.recoveryEscrow, rootKeyEscrow = slotA.rootKeyEscrow, ) // H-01: durably commit slot A + size-matched noise + version stamp // before swapping the session key. A partial commit (new A on disk, // old key in memory) would have the next save encrypt under the old // key while matching disk on the new one. val priorB = store.loadSlotB() if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false // H-03 (P1-2 codex): zero the OLD live key and assign the NEW one // inside the same stateLock critical section, so a concurrent lock() // can't observe the in-between (zeroed but not yet reassigned). if (mutateIfCurrentEpoch(expectedEpoch) { oldKey?.let(VaultCrypto::zero); oldKey = null derivedKey?.let(VaultCrypto::zero) derivedKey = newKey; newKey = null } == null) return false VaultKeyRotationHooks.onRotated() true } catch (_: Exception) { false } finally { oldKey?.let(VaultCrypto::zero) newKey?.let(VaultCrypto::zero) plaintext?.let(VaultCrypto::zero) } } /** * Irreversible wipe. Locks the session and clears every vault key. The Pro * license is in a separate store and is preserved by construction. */ suspend fun destroyVault(): Boolean = mutex.withLock { lock() val cleared = store.clearVault() store.clearDecoySlot() cleared } // ---- Entry CRUD ------------------------------------------------------- /** * A Peach entry id is a UUID. Import and restore carry ids from foreign * files (another Peach install, a vendor export, a hand-written backup) and * no reader bounds them, so an id that is not a well-formed Peach id is * replaced here rather than trusted. * * This is the naturalisation seam, and it is deliberately the only place it * happens. Minting costs at most one duplicate if the same vault is later * restored on a device that still holds the old id — visible and fixable. * The sync path must NOT do this: adopting a peer's id verbatim is the only * thing that keeps two devices agreeing on which entry they are discussing, * so reminting there would fork identity on every device, on every round. */ private val entryIdShape = Regex("[A-Za-z0-9_-]{1,64}") private fun String?.normalizedEntryId(): String = if (this != null && entryIdShape.matches(this)) this else UUID.randomUUID().toString() suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock { val epoch = requireUnlocked() val stamp = VaultCrypto.monotonicEntryTimestamp() val now = System.currentTimeMillis() val entry = withDerivedLast4(draft.copy( id = draft.id.normalizedEntryId(), createdAt = if (draft.createdAt > 0) draft.createdAt else now, modifiedAt = stamp, passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt, )) // H-03: capture rollback AND write entryList under stateLock so a // concurrent lock() cannot zero the session between the read and the write. val rollback = mutateCurrentSession(epoch) { val r = entryList.toMutableList() entryList.add(entry) r } ?: throw IllegalStateException("vault_locked") persistOrRollback(rollback, epoch) 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 { val epoch = requireUnlocked() if (drafts.isEmpty()) return 0 val now = System.currentTimeMillis() val ready = drafts.map { draft -> val stamp = VaultCrypto.monotonicEntryTimestamp() withDerivedLast4( draft.copy( id = draft.id.normalizedEntryId(), createdAt = if (draft.createdAt > 0) draft.createdAt else now, modifiedAt = stamp, passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt, ), ) } // H-03: capture rollback AND append every entry under stateLock. val rollback = mutateCurrentSession(epoch) { val r = entryList.toMutableList() entryList.addAll(ready) r } ?: throw IllegalStateException("vault_locked") persistOrRollback(rollback, epoch) return drafts.size } /** * M-01: replace the whole active set with [newEntries] in one write. * * Import's REPLACE mode used to soft-delete every current entry one durable * write at a time (swallowing each failure) and then batch-add, so a failed * add left the old entries stranded in Trash with nothing restored. Staging * the entire next list in memory and committing it through * [persistOrRollback] makes the swap all-or-nothing: on any failure the * pre-import list — active entries included — is restored. * * Existing tombstones are preserved so paired devices still see the * deletions they already know about. */ suspend fun replaceEntries(newEntries: List): Int = mutex.withLock { val epoch = requireUnlocked() val now = System.currentTimeMillis() val ready = newEntries.map { draft -> val stamp = VaultCrypto.monotonicEntryTimestamp() withDerivedLast4( draft.copy( id = draft.id.normalizedEntryId(), createdAt = if (draft.createdAt > 0) draft.createdAt else now, modifiedAt = stamp, passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt, ), ) } // M-03: an incoming entry that shares an id with an existing live entry // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning // the old one and then appending the new one would leave the list with // two entries of the same id — the tombstoned one would be returned by // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be- // superseded entries from the tombstone set instead. val incomingIds = ready.map { it.id }.toHashSet() val rollback = mutateCurrentSession(epoch) { val r = entryList.toMutableList() val replaced = entryList.map { entry -> if (entry.deletedAt != null) return@map entry if (entry.id in incomingIds) return@map null val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt) entry.copy(deletedAt = stamp, modifiedAt = stamp) }.filterNotNull() entryList = (replaced + ready).toMutableList() r } ?: throw IllegalStateException("vault_locked") persistOrRollback(rollback, epoch) return ready.size } suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock { val epoch = requireUnlocked() // H-03: read entryList, compute the patched entry, and write it under a // single stateLock so a concurrent lock() cannot interleave between the // lookup and the assignment (would IndexOutOfBoundsException on a shrunk // list, or clobber the entry onto the wrong id after a relock). val prepared = mutateCurrentSession(epoch) { 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 updated to rollback } ?: throw IllegalStateException("vault_locked") val (updated, rollback) = prepared persistOrRollback(rollback, epoch) updated } /** Soft-delete: writes a tombstone whose timestamp beats any in-flight edit. */ suspend fun deleteEntry(id: String) = mutex.withLock { val epoch = requireUnlocked() // H-03: lookup + tombstone write under stateLock so lock() cannot shrink // the list out from under the indexOfFirst. val rollback = mutateCurrentSession(epoch) { 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 prior = entryList.toMutableList() entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp) prior } ?: throw IllegalStateException("vault_locked") persistOrRollback(rollback, epoch) } suspend fun restoreEntry(id: String) = mutex.withLock { val epoch = requireUnlocked() val rollback = mutateCurrentSession(epoch) { val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null } if (idx == -1) throw IllegalStateException("deleted entry not found") val current = entryList[idx] val prior = entryList.toMutableList() entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)) prior } ?: throw IllegalStateException("vault_locked") persistOrRollback(rollback, epoch) } /** Permanent delete — no tombstone, no recovery. */ suspend fun purgeEntry(id: String) = mutex.withLock { val epoch = requireUnlocked() val rollback = mutateCurrentSession(epoch) { val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null } if (idx == -1) throw IllegalStateException("deleted entry not found") val prior = entryList.toMutableList() entryList.removeAt(idx) prior } ?: throw IllegalStateException("vault_locked") persistOrRollback(rollback, epoch) } /** Permanently empty the trash — hard-deletes every soft-deleted entry. */ suspend fun purgeDeletedEntries(): Int = mutex.withLock { val epoch = requireUnlocked() // H-03: read+remove under stateLock so lock() can't reorder the remove. data class R(val rollback: MutableList, val removed: Int) val r = mutateCurrentSession(epoch) { val rollback = entryList.toMutableList() val before = entryList.size entryList.removeAll { it.deletedAt != null } R(rollback, before - entryList.size) } ?: throw IllegalStateException("vault_locked") if (r.removed > 0) persistOrRollback(r.rollback, epoch) r.removed } /** Browser parity: number of active entries that dedupe would move to trash. */ suspend fun countDuplicateEntries(): Int = mutex.withLock { val epoch = requireUnlocked() // countDuplicateEntriesLocked reads entryList; H-03 fences the read so // lock() can't race the read with state writes. mutateCurrentSession(epoch) { countDuplicateEntriesLocked() } ?: throw IllegalStateException("vault_locked") } /** * 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 { val epoch = requireUnlocked() data class R(val rollback: MutableList, val removed: Int) val ready = mutateCurrentSession(epoch) { 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() R(rollback, removed) } ?: throw IllegalStateException("vault_locked") if (ready.removed > 0) persistOrRollback(ready.rollback, epoch) ready.removed } fun getEntries(): List = mutateState { if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null } } fun getEntry(id: String): VaultEntry? = mutateState { if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null } } fun getDeletedEntries(): List = mutateState { if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt } } /** Active entries, optionally narrowed to those carrying [tagFilter]. */ fun getActiveEntries(tagFilter: String? = null): List { return mutateState { if (!sessionDataAvailable) return@mutateState emptyList() val active = entryList.filter { it.deletedAt == null } if (tagFilter == null) return@mutateState active val needle = tagFilter.trim().lowercase() active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true } } } /** Sorted, de-duplicated tags across all active entries. */ fun getDistinctTags(): List = mutateState { if (!sessionDataAvailable) return@mutateState emptyList() 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 mutateState { if (!sessionDataAvailable) return@mutateState emptyList() 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 = mutateState { if (!sessionDataAvailable) emptyList() else 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 expectedEpoch = currentEpoch() val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false // H-04: snapshots now carry the duress slot too — restore preserves // the at-rest triad (A/B/D), not just the A/B pair. val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot) val priorA = store.loadSlotA() val priorB = store.loadSlotB() val priorD = store.loadDecoySlot() // H-02: capture pre-restore state as the recovery snapshot BEFORE any // slot write — the old code took this snapshot, then stranded it on // every failure, polluting history with a "restore" entry that never // restored anything. The history below is rolled back together with // the slots if the commit fails. val priorHistory = store.loadVaultHistory() // P1-4 (codex): the recovery snapshot must land before we commit the // slot pair, otherwise a failed restore cannot be rolled back to the // pre-restore history. If the history write itself fails, refuse to // proceed — the alternative is a successful restore with no undo path. beforeDurableCommit() if (!takeVaultSnapshot("restore", expectedEpoch)) { return false } // H-04: legacy snapshots without vault_d, or vault_d that fails to // decode, fall back to a fake duress of slot A's size — presence- // indistinguishable from any other freshly-restored vault. val nextD: StoredVault = restoredD ?: run { val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 16 val fakeBlob = ByteArray(target).also(rng::nextBytes) val fakeSalt = VaultCrypto.generateSalt() StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob)) } if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) { withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) } return false } lock() true } /** * H-02: atomic slot-A + slot-B + version stamp for a snapshot restore. * Mirrors [commitVaultRotation] but writes the snapshot's EXACT slot B * (a restore preserves the duress-shaped blob from history), not a fresh * noise record. Any failure rolls back so the at-rest pair never holds a * restored slot A against a pre-restore slot B. Caller is responsible for * restoring the pre-snapshot history on a false return. * * H-04: duress slot is now part of the atomic triad — restored last so * a duress-write failure rolls the A/B pair back too. */ private fun commitRestoreAtomic( nextA: StoredVault, nextB: String, nextD: StoredVault, priorA: StoredVault?, priorB: String?, priorD: StoredVault?, expectedEpoch: Long, ): Boolean { return withEpochCommit(expectedEpoch) { if (!store.writeSlotA(nextA)) return@withEpochCommit false // P1-2 (codex): writeSlotB's boolean AND any thrown exception from it // must trigger rollback; previously a throw escaped without restoring // the prior slot A and history. try { if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed") // H-04: duress slot is part of the restore triad. Failures here // roll A+B back so the at-rest state stays consistent. if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed") store.stampLocalMutation() } catch (_: Exception) { reportRollbackFailures(slotRollback.restoreTriad(priorA, priorB, priorD)) return@withEpochCommit false } true } ?: false } // ---- 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, expectedEpoch: Long? = null, ): Int = mutex.withLock { val epoch = expectedEpoch ?: requireUnlocked() if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 0 // 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.mapNotNull { entry -> // MN-3: drop peer entries that decode cleanly but are semantically // hollow (blank title, credential-less login, husk passkey) and // re-cap passwordHistory before anything merges into the vault. SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy( modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow), deletedAt = entry.deletedAt?.let { SyncLimits.clampSyncTimestamp(it, mergeNow) }, ) } val tombMap = remoteTombstones.associate { it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow) } // H-03: build the new entryList under stateLock so a concurrent lock() // cannot zero derivedKey/entryList between the byId read and the merge. val applied = mutateCurrentSession(epoch) { var n = 0 val byId = entryList.associateBy { it.id }.toMutableMap() for (remote in clamped) { val tomb = tombMap[remote.id] if (tomb != null && tomb >= remote.modifiedAt) continue val local = byId[remote.id] if (local == null) { entryList.add(remote); byId[remote.id] = remote; n++ } else if (remote.modifiedAt > local.modifiedAt) { val i = entryList.indexOfFirst { it.id == local.id } if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ } } } for ((id, deletedAt) in tombMap) { val local = byId[id] ?: continue if (local.deletedAt != null && local.deletedAt >= deletedAt) continue if (deletedAt < local.modifiedAt) continue val i = entryList.indexOfFirst { it.id == id } if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ } } n } ?: return@withLock 0 if (applied > 0) { persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch) mutateIfCurrentEpoch(epoch) { publish() } runCatching { LockedSyncSpoolHooks.refresh(this) } } applied } suspend fun updateSettings(settings: VaultSettings) = mutex.withLock { // A decoy session edits an in-memory copy only — discarded at lock. // The real vault's settings record is not the duress session's to // change, and letting it through weakened capture protection on a plain // in-app surface (PB-26-120). if (mutateState { decoyMode }) { val decoyEpoch = requireUnlocked() mutateCurrentSession(decoyEpoch) { _settings.value = settings } ?: throw IllegalStateException("vault_locked") return@withLock } // Require a fully live session before exposing a settings mutation. The // epoch is captured with the read so a lock/new-session boundary cannot // let stale work publish into the replacement session. val epoch = requireUnlocked() val prior = mutateCurrentSession(epoch) { val old = _settings.value _settings.value = settings old } ?: throw IllegalStateException("vault_locked") val committed = try { beforeDurableCommit() // The durable write is inside the same epoch fence as the in-memory // publication. If lock() won first, this returns null and no stale // settings reach storage or the public StateFlow. withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true } catch (_: Exception) { false } if (!committed) { // A live-session storage failure rolls back the public value. A // stale epoch is already reset by lock()/the new session and must // not be allowed to overwrite that safe state. mutateIfCurrentEpoch(epoch) { _settings.value = prior } throw IllegalStateException("settings_write_failed") } } // ---- Recovery words (vault metadata) ---------------------------------- /** True once the user has confirmed their recovery words for this vault. */ fun isRecoveryWordsSet(): Boolean = mutateState { 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 { val epoch = requireUnlocked() // H-03: hash + escrow-related state fields set under stateLock so a // concurrent lock() cannot zero them in the middle of buildRecoveryEscrow. // M-04: the flags have to be live before the escrows are built — both seal // currentPayload() — so capture the prior pair and put it back if the write // never lands. Otherwise the session reports words set against a slot A // that still holds no escrow, and the recovery screen never offers a retry. val prior = mutateCurrentSession(epoch) { val priorPair = recoveryWordHash to recoveryWordsSet recoveryWordHash = recoveryWordsHash(words) recoveryWordsSet = true priorPair } ?: return@withLock false // 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. val persisted = runCatching { persistSlotAWithEscrows( buildRecoveryEscrow(words), buildRootKeyEscrow(words), expectedEpoch = epoch, ) }.getOrDefault(false) if (!persisted) { mutateIfCurrentEpoch(epoch) { recoveryWordHash = prior.first recoveryWordsSet = prior.second } } persisted } /** Verify entered words against the stored recovery hash. */ fun verifyRecoveryWords(words: List): Boolean { return mutateState { val stored = recoveryWordHash ?: return@mutateState false MessageDigest.isEqual( stored.toByteArray(Charsets.UTF_8), recoveryWordsHash(words).toByteArray(Charsets.UTF_8), ) } } // ---- 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 /** * One-time self-heal: if this vault has a root key but no recovery escrow * on disk (adopted vaults created before the escrow fix), build and persist * it now. Called on every successful unlock; no-ops once the escrow exists. */ private fun ensureRecoveryEscrow() { if (hasRecoveryEscrow()) return val rk = mutateState { rootKey?.copyOf() } ?: return runCatching { val words = rootKeyToWords(rk) val escrow = buildRecoveryEscrow(words) val rkEscrow = buildRootKeyEscrow(words) if (escrow != null || rkEscrow != null) { val prior = store.loadSlotA() ?: return@runCatching store.writeSlotA(prior.copy( recoveryEscrow = escrow ?: prior.recoveryEscrow, rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow, )) mutateState { recoveryWordHash = recoveryWordsHash(words) recoveryWordsSet = true } } }.also { VaultCrypto.zero(rk) } } /** * 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 expectedEpoch = currentEpoch() 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. val fullEscrow = slotA.recoveryEscrow if (fullEscrow != null) { val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() if (material != null) { var keyBytes: ByteArray? = null var plaintext: ByteArray? = null var candidateRoot: ByteArray? = null var stateCommitStarted = false try { keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY) val payload = VaultCodec.decode(plaintext) // Stage every fallible operation before touching live session // fields. In particular, a settings/provider/decode failure // cannot leave entries, flags, or a root key resident while the // public session remains locked. val candidateSettings = store.loadSettings() candidateRoot = if (payload.rootKeyB64.isNotBlank()) { val decodedRoot = unb64(payload.rootKeyB64) if (decodedRoot.size != VaultCrypto.KEY_SIZE) { // Do not let a malformed authenticated payload abandon // this decoded root-key candidate before the outer // finally block owns it. VaultCrypto.zero(decodedRoot) throw IllegalArgumentException("invalid recovery root key") } decodedRoot } else { generateRecoverableRootKey() } stateCommitStarted = true val adopted = mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() entryList = payload.entries.toMutableList() authenticatedDuressConfigured = payload.duressConfigured _duressConfigured.value = payload.duressConfigured recoveryWordHash = payload.recoveryWordHash recoveryWordsSet = payload.recoveryWordsSet rootKey = candidateRoot candidateRoot = null _settings.value = candidateSettings publish() // This is the authenticated recovery half-unlock boundary: // only set it after every candidate field is installed. sessionDataAvailable = true _isUnlocked.value = false true } if (adopted == true) return@withLock true } catch (_: Exception) { // Wrong words, malformed payloads, provider failures, and // settings failures all fall through to the browser-compatible // root-key escrow. If the state commit had started, invalidate // that epoch so no partial candidate survives. if (stateCommitStarted) { mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() } } } finally { plaintext?.let(VaultCrypto::zero) VaultCrypto.zero(material) keyBytes?.let(VaultCrypto::zero) candidateRoot?.let(VaultCrypto::zero) } } } // 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`). val rootEscrow = slotA.rootKeyEscrow if (rootEscrow != null) { var candidateRoot: ByteArray? = null var stateCommitStarted = false try { candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" } val candidateSettings = store.loadSettings() stateCommitStarted = true val adopted = mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() entryList = mutableListOf() authenticatedDuressConfigured = false _duressConfigured.value = false recoveryWordHash = recoveryWordsHash(words) recoveryWordsSet = true rootKey = candidateRoot candidateRoot = null _settings.value = candidateSettings publish() sessionDataAvailable = true _isUnlocked.value = false true } if (adopted == true) return@withLock true } catch (_: Exception) { if (stateCommitStarted) { mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() } } } finally { candidateRoot?.let(VaultCrypto::zero) } } 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 { val expectedEpoch = currentEpoch() // H-03: read rootKey + entryList under stateLock before computing the new // plaintext, then re-publish + swap the derived key under stateLock so a // concurrent lock() cannot straddle the two fences. if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false val priorA = store.loadSlotA() ?: return false var newKey: ByteArray? = null var plaintext: ByteArray? = null return try { val newSalt = VaultCrypto.generateSalt() newKey = VaultCrypto.deriveKey(newPassword, newSalt) plaintext = VaultCodec.encode(currentPayload()) val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A)) // Rebuild both escrows against the recovered session so their salts rotate // with the password reset; fall back to the prior escrows if rebuild fails // (e.g. a checksum-invalid phrase). The same 12 words keep working. val valid = words.size == 12 && findInvalidWords(words).isEmpty() val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow val next = StoredVault( version = priorA.version, salt = b64(newSalt), blob = blob, recoveryEscrow = freshRecovery, rootKeyEscrow = freshRootKey, ) // H-01: see changePassword — both rotations share the same // commit-or-keep-old-key contract so a partial write can't strand // the session on a key that no longer matches disk. val priorB = store.loadSlotB() if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false // H-03 (P1-2 codex): zero the old live key and install the new one // inside the same stateLock critical section so a concurrent lock() // can't observe the in-between. if (mutateIfCurrentEpoch(expectedEpoch) { derivedKey?.let(VaultCrypto::zero) derivedKey = newKey; newKey = null publish() _isUnlocked.value = true } == null) return false VaultKeyRotationHooks.onRotated() true } catch (_: Exception) { false } finally { newKey?.let(VaultCrypto::zero) plaintext?.let(VaultCrypto::zero) } } /** * Seal the current slot-A plaintext under a key derived from the 12 BIP39 * recovery [words] (Android offline-restore escrow). IKM is the browser's * `wordsToRootKeyMaterial` (MF-1), AAD is the string [VaultCrypto.AAD_RECOVERY]. * Returns null for a checksum-invalid phrase. */ private fun buildRecoveryEscrow(words: List): RecoveryEscrow? { if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null var keyBytes: ByteArray? = null var plaintext: ByteArray? = null return try { val salt = VaultCrypto.generateSalt() val recoveryKey = deriveRecoveryKey(material, salt) keyBytes = recoveryKey.encoded plaintext = VaultCodec.encode(currentPayload()) val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY) RecoveryEscrow(salt = b64(salt), blob = b64(sealed)) } finally { VaultCrypto.zero(material) keyBytes?.let(VaultCrypto::zero) plaintext?.let(VaultCrypto::zero) } } /** * 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 material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null val rk = mutateState { rootKey?.copyOf() } ?: run { VaultCrypto.zero(material) return null } var keyBytes: ByteArray? = null var plaintext: ByteArray? = null return try { val salt = VaultCrypto.generateSalt() val recoveryKey = deriveRecoveryKey(material, salt) keyBytes = recoveryKey.encoded plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII) // 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(rk) VaultCrypto.zero(material) keyBytes?.let(VaultCrypto::zero) plaintext?.let(VaultCrypto::zero) } } /** 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 var keyBytes: ByteArray? = null var pt: ByteArray? = null return try { val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt)) keyBytes = recoveryKey.encoded val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag) pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY) hexToRootKey(String(pt, Charsets.US_ASCII)) } catch (_: Exception) { null } finally { // M-05: pt is the lowercase hex of the 32-byte root key — i.e. the // IKM-equivalent of the vault identity. material+keyBytes were // already zeroed; pt was leaking. VaultCrypto.zero(material) keyBytes?.let(VaultCrypto::zero) pt?.let(VaultCrypto::zero) } } /** * 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?, expectedEpoch: Long? = null, ): Boolean { val snapshot = snapshotForPersist(expectedEpoch) ?: throw IllegalStateException("vault_locked_or_stale") if (snapshot.decoy) { VaultCrypto.zero(snapshot.key) return false } try { val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A") val plaintext = VaultCodec.encode(snapshot.payload) val blob = try { b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A)) } finally { // The escrow path used to drop this reference before zeroing it; // keep it live through the fill on both success and failure. VaultCrypto.zero(plaintext) } val next = prior.copy( blob = blob, recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow, rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow, ) beforeDurableCommit() return withEpochCommit(snapshot.epoch) { if (!store.writeSlotA(next)) return@withEpochCommit false try { if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed") // H-04: escrow rebuild changes slot A's plaintext size, so re-pad // the duress slot too. A real decoy is left alone; a fake is // re-sized to slot A's new ciphertext length. if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed") } catch (e: Exception) { store.writeSlotA(prior) throw e } true } ?: false } finally { VaultCrypto.zero(snapshot.key) } } // ---- Internals -------------------------------------------------------- private fun requireUnlocked(): Long { // H-03: read derivedKey atomically with [lock]'s reset; without it, // [lock] from a background lifecycle event can run between this read // and the caller's first state touch. stateLock.lock() try { if (derivedKey == null) throw IllegalStateException("vault_locked") return sessionEpoch } finally { stateLock.unlock() } } /** A locked or half-adopted session must not expose secret-bearing fields. */ private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null private fun countDuplicateEntriesLocked(): Int { val seen = mutableMapOf() var duplicates = 0 for (entry in entryList.filter { it.deletedAt == null }) { val key = entry.duplicateKey() val existing = seen[key] if (existing == null) { seen[key] = entry } else { duplicates++ val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt if (entryStamp > existingStamp) seen[key] = entry } } return duplicates } private fun VaultEntry.duplicateKey(): String { if (type == "note") return "note|${title.lowercase()}" val passkeyData = passkey if (passkeyData != null) { return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}" } return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}" } /** * Capture the current slot pair (and metadata) into the history list. * Returns true when the history write landed, false when it didn't — callers * that depend on the snapshot existing (notably [restoreVaultSnapshot]) * must check the result and roll back if it does not. */ private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean { val epoch = expectedEpoch ?: currentEpoch() if (mutateIfCurrentEpoch(epoch) { true } != true) return false val slotA = store.loadSlotA() ?: return false val vaultA = VaultCodec.encodeSlot(slotA) val vaultB = store.loadSlotB() ?: slotA.blob // H-04: capture the duress slot so a restore preserves the at-rest // triad, not just the A/B pair. The duress slot is always present // (real or fake), so loadDecoySlot() returning null is a logic error // — fall through to a fake duress in that case rather than silently // losing the record from the snapshot. val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run { val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 16 val fakeBlob = ByteArray(target).also(rng::nextBytes) val fakeSalt = VaultCrypto.generateSalt() VaultCodec.encodeSlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob))) } val history = store.loadVaultHistory() val latest = history.snapshots.firstOrNull() if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true val entryCount = mutateIfCurrentEpoch(epoch) { if (derivedKey != null && !decoyMode) { bucketEntryCount(entryList.count { it.deletedAt == null }) } else { null } } ?: return false return try { val snapshot = VaultSnapshot( id = UUID.randomUUID().toString(), savedAt = System.currentTimeMillis(), reason = reason, vault_a = vaultA, vault_b = vaultB, vault_d = vaultD, entryCount = entryCount, ) // P1-4 (codex): return the history-write boolean to callers so a // false return no longer leaves the snapshot out of history while // callers (notably restoreVaultSnapshot) proceed as if it succeeded. withEpochCommit(epoch) { store.writeVaultHistory( history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)), ) } ?: false } catch (_: Exception) { false } } private fun bucketEntryCount(n: Int): Int = when { n <= 0 -> 0 n <= 10 -> 10 n <= 25 -> 25 n <= 50 -> 50 n <= 100 -> 100 n <= 250 -> 250 n <= 500 -> 500 else -> ((n + 499) / 500) * 500 } /** * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3), * so it is stored explicitly and survives the sync wire format rather than being * re-derived at render time. Non-card entries pass through unchanged. * * M-03: never overwrite a stored last4 with null. A card kept only as last4 * (e.g. after a PeachScript import that had no PAN) must keep that value; * re-deriving from an absent cardNumber would silently zero the field. */ private fun withDerivedLast4(entry: VaultEntry): VaultEntry { if (!entry.isCard()) return entry val digits = entry.cardNumber?.filter { it.isDigit() } ?: "" val derived = if (digits.length >= 4) digits.takeLast(4) else null return entry.copy(last4 = derived ?: entry.last4) } /** * 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) } } /** * Browser parity (P1): merge orphaned passkey-only entries into matching * login entries that share the same registrable domain. A passkey-only * entry has passkey fields (rpId + credentialId) but no password. Runs on * unlock so consolidation is idempotent and cheap. */ private fun consolidatePasskeyEntries() { val passkeyOnly = entryList.filter { entry -> entry.deletedAt == null && entry.isLogin() && entry.password.isNullOrBlank() && !entry.rpId.isNullOrBlank() && !entry.credentialId.isNullOrBlank() } if (passkeyOnly.isEmpty()) return var changed = false val now = System.currentTimeMillis() for (pkEntry in passkeyOnly) { val rpId = pkEntry.rpId ?: continue val target = entryList.firstOrNull { candidate -> candidate.deletedAt == null && candidate.id != pkEntry.id && candidate.isLogin() && candidate.passkey == null && candidate.credentialId.isNullOrBlank() && !candidate.password.isNullOrBlank() && listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray()) .any { com.peachpasswords.shared.extractDomain(it) == rpId } } ?: continue val idx = entryList.indexOfFirst { it.id == target.id } if (idx < 0) continue entryList[idx] = target.copy( rpId = pkEntry.rpId, rpName = pkEntry.rpName ?: target.rpName, credentialId = pkEntry.credentialId, publicKeyCose = pkEntry.publicKeyCose, privateKeyJwk = pkEntry.privateKeyJwk, userHandle = pkEntry.userHandle, userName = pkEntry.userName, userDisplayName = pkEntry.userDisplayName, algorithm = pkEntry.algorithm, signCount = pkEntry.signCount, lastUsedAt = pkEntry.lastUsedAt, modifiedAt = now, ) // Soft-delete the orphan so it lands in trash (recoverable). val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id } if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now) changed = true } if (changed) runCatching { persistSlotA(bumpVersion = false) } } /** The slot-A plaintext envelope for the current in-memory session. */ private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() } private fun currentPayloadLocked(): VaultPayload = VaultPayload( entries = entryList.toList(), duressConfigured = authenticatedDuressConfigured, 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 { var plaintext = VaultCodec.encode(currentPayload()) return try { val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A)) StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = blob) } finally { // M-05: full vault plaintext is allocated here once per createVault. // Zeroing in finally keeps the transient copy off the heap after the // caller either succeeds or rolls back via lock(). VaultCrypto.zero(plaintext) } } 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)) } /** H-04: the ciphertext length slot A is using right now, so the duress * slot's blob can match it on disk. AES-GCM is a stream cipher — plaintext * length equals ciphertext length, so this also matches slot A's plaintext. */ private fun matchingCiphertextLength(slotA: StoredVault): Int = (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0) /** H-04: a decoy-shaped StoredVault with a random blob of [matchingCiphertextLength] * bytes. Replaces a real decoy when the user clears duress, and is the * initial duress slot when no decoy is configured — so the record's * PRESENCE on disk is independent of authenticatedDuressConfigured, and its SIZE * matches slot A's blob length. */ private fun writeFakeDuressSlot(slotA: StoredVault): Boolean { val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 16 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes) val fakeSalt = VaultCrypto.generateSalt() return store.writeDecoySlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob))) } /** H-04: keep the duress record present on every persist. A real decoy is * left alone (it was padded at setDuressPassphrase time, and we don't have * the duress key here to re-seal it) — VaultStore re-pads both slot * records to the longer of the two on every write, which is what keeps the * pair the same length after slot A crosses a padding bucket * (PAI-2026-004). No real decoy → a fresh fake of slot A's size. */ private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean { val isConfigured = mutateState { authenticatedDuressConfigured } return if (isConfigured) true else writeFakeDuressSlot(slotA) } /** * H-04: convert a browser-shaped duress slot to Android StoredVault * without decrypting. The browser seals slot "b" under the duress * passphrase; decrypting with the main password silently drops it. * Concatenates nonce‖ciphertext‖tag into Android's blob and re-encodes * the salt from hex to base64. */ private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault { val salt = unhex(b.salt) val nonce = unhex(b.nonce) val ct = unhex(b.ciphertext) val tag = unhex(b.tag) val blob = ByteArray(nonce.size + ct.size + tag.size).also { System.arraycopy(nonce, 0, it, 0, nonce.size) System.arraycopy(ct, 0, it, nonce.size, ct.size) System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size) } return StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = b64(blob)) } /** Re-seal slot A from the current in-memory state under the live key. */ private fun persistSlotA( bumpVersion: Boolean, snapshotReason: String? = "local_save", expectedEpoch: Long? = null, ): Boolean { val snapshot = snapshotForPersist(expectedEpoch) ?: throw IllegalStateException("vault_locked_or_stale") return try { // Decoy sessions persist to the decoy slot only — never touch the real slot A. if (snapshot.decoy) { val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot") val plaintext = VaultCodec.encode(snapshot.payload) val blob = try { b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_B)) } finally { // Keep the last reference alive until the zero, including the // successful encrypt path (M-05). VaultCrypto.zero(plaintext) } beforeDurableCommit() return withEpochCommit(snapshot.epoch) { store.writeDecoySlot(prior.copy(version = CURRENT_ANDROID_SLOT_VERSION, blob = blob)) } ?: false } val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A") // H-01: captured BEFORE the noise write so a stamp failure can restore // the prior slot B alongside the prior slot A. Slot B is always // overwritten by writeMatchingNoiseSlotB below, so a stamp failure // leaves (new A, new B, old version) on disk — partial commit. val priorB = store.loadSlotB() beforeDurableCommit() if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false val plaintext = VaultCodec.encode(snapshot.payload) val blob = try { b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A)) } finally { // Do not drop [plaintext] before fill(0): the serialized vault // contains every entry password and is the primary M-05 target. VaultCrypto.zero(plaintext) } val next = prior.copy(blob = blob) // The epoch check and the entire A/B/D/version commit share one brief // stateLock critical section. lock() either wins before this block or // waits until the already-committed write is complete; it can never // clear the session between the check and the durable write. withEpochCommit(snapshot.epoch) { if (!store.writeSlotA(next)) return@withEpochCommit false try { if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed") // H-04: duress slot stays present (real one untouched, fake one // re-padded to slot A's current size on every persist). if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed") if (bumpVersion) store.stampLocalMutation() } catch (e: Exception) { store.writeSlotA(prior) if (priorB != null) runCatching { store.writeSlotB(priorB) } throw e } true } ?: false } finally { // Snapshot keys are copies, so lock() may zero the live key while this // operation is still deriving/encrypting; always scrub our copy too. VaultCrypto.zero(snapshot.key) } } /** * H-01: durably commit a slot-A rotation by writing slot A, its * size-matched noise slot B, and the local-mutation stamp in order. Any * failure rolls the previously-committed steps back so the at-rest pair * matches what was there before the call. Returns true only when every * step succeeded — callers must NOT swap their session key on false. */ private fun commitVaultRotation( priorA: StoredVault, priorB: String?, nextA: StoredVault, expectedEpoch: Long, ): Boolean { beforeDurableCommit() return withEpochCommit(expectedEpoch) { if (!store.writeSlotA(nextA)) return@withEpochCommit false // P1-1 (codex): the noise write's boolean AND any thrown exception from // it must trigger rollback; previously a throw from writeMatchingNoiseSlotB // escaped the helper and left slot A committed. try { if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed") // H-04: a password rotation changes slot A's plaintext size (new // salt → re-encrypted blob), so the duress slot must be re-padded // alongside. writeCompanionDuressSlot leaves a real decoy alone // and re-emits a fake of slot A's size otherwise. if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed") store.stampLocalMutation() } catch (_: Exception) { reportRotationRollback(slotRollback.restorePair(priorA, priorB)) return@withEpochCommit false } true } ?: false } /** * Best-effort undo of the writes [commitVaultRotation] may have committed. * Each step runs independently — a failed rollback must not mask the * original commit failure. Log is a throwing stub off-device. */ private fun reportRotationRollback(failures: List) { if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") } } /** Persist a local mutation, rolling the in-memory list back on failure. */ private fun persistOrRollback(rollback: MutableList, expectedEpoch: Long) { try { if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) { mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback } throw IllegalStateException("storage_write_failed") } } catch (e: Exception) { mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback } throw e } // A lock can win in the small interval after the durable commit and // before publication. The epoch check prevents pre-lock entries from // being republished into a newly locked (or newly unlocked) session. mutateIfCurrentEpoch(expectedEpoch) { publish() } runCatching { LockedSyncSpoolHooks.refresh(this) } runCatching { VaultMutationHooks.onCommitted() } } }