package com.peachpasswords.ui.lock import android.content.Context import androidx.biometric.BiometricPrompt import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.license.LicenseRepository import com.peachpasswords.sync.ForegroundSyncCoordinator import com.peachpasswords.sync.SyncFreshStartResetter import com.peachpasswords.ui.PeachNav import com.peachpasswords.ui.vault.entryTapDestination import com.peachpasswords.vault.CreateVaultResult import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.vault.DuressAction import com.peachpasswords.vault.DuressConfigStore import com.peachpasswords.vault.LastViewedEntryStore import com.peachpasswords.vault.UnlockResult import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultLockTimer import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject data class LockUiState( val hasVault: Boolean = false, val pinEnabled: Boolean = false, val pinPreferred: Boolean = false, val isPro: Boolean = false, val loading: Boolean = false, val error: String? = null, val unlocked: Boolean = false, /** True when this session just created a new vault (routes through recovery-word setup). */ val created: Boolean = false, /** Increments on each failed unlock so the screen can fire its shake animation. */ val shakeNonce: Int = 0, /** Real vault was wiped by a destroy-duress unlock → screen drops to CreateVault. */ val duressDestroyed: Boolean = false, ) const val MIN_PASSWORD_LENGTH = 12 /** CreateVault is blocked below "fair" (zxcvbn-style score 2). */ const val MIN_CREATE_STRENGTH = 2 /** * Validate a create-vault attempt; returns the user-facing error, or null when the * inputs are acceptable. Pure so the create rules (length floor, strength gate, * confirm match — in that order) are unit-testable without the ViewModel. */ internal fun createValidationError(password: String, confirm: String, score: Int): String? = when { password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters" score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password" password != confirm -> "passwords do not match" else -> null } /** Browser parity: create stays disabled until every create-vault rule passes. */ internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean = createValidationError(password, confirm, score) == null /** * Where to land after a successful unlock (brief Fix 3): the last-viewed entry if * it still exists, else the first active entry, else the vault list when the vault * is empty. [resolve] re-validates the stored id against the live vault so a stale * pointer (deleted entry) falls back rather than opening a missing detail screen. * NOTE entries route to the notes editor via [entryTapDestination], same as a list tap. */ internal fun postUnlockDestination( lastViewedId: String?, resolve: (String) -> VaultEntry?, firstActive: VaultEntry?, ): PeachNav { val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList return entryTapDestination(target) } @HiltViewModel class LockViewModel @Inject constructor( private val vault: VaultRepository, license: LicenseRepository, private val duress: DuressConfigStore, private val lockTimer: VaultLockTimer, private val lastViewed: LastViewedEntryStore, private val foregroundSync: ForegroundSyncCoordinator, private val syncFreshStart: SyncFreshStartResetter, private val pinUnlock: com.peachpasswords.vault.PinUnlockStore, @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _state = MutableStateFlow( LockUiState( hasVault = vault.hasVault(), pinEnabled = pinUnlock.isConfigured(), pinPreferred = pinUnlock.isPinPreferred(), isPro = license.isProActive(), ), ) val state: StateFlow = _state.asStateFlow() /** Resolve where a just-unlocked session should land (last-viewed entry → first → list). */ fun postUnlockDestination(): PeachNav = postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull()) /** * Owns candidate unlock tokens for the whole concurrent-derivation scope. * * Argon2id is deliberately not cancellable. A token can therefore be * produced after the caller has been cancelled unless ownership is claimed * by the child immediately, before that child completes. The scope closes * only after [coroutineScope] has joined both children, so every registered * loser is discarded even when cancellation happens between either await. */ private class UnlockCandidates(private val vault: VaultRepository) : AutoCloseable { private val lock = Any() private var closed = false private var slotA: VaultRepository.SlotAUnlock? = null private var duress: VaultRepository.DuressUnlock? = null fun registerSlotA(candidate: VaultRepository.SlotAUnlock) { val discard = synchronized(lock) { if (closed) true else { slotA?.let(vault::discardSlotAUnlock) slotA = candidate false } } if (discard) vault.discardSlotAUnlock(candidate) } fun registerDuress(candidate: VaultRepository.DuressUnlock) { val discard = synchronized(lock) { if (closed) true else { duress?.let(vault::discardDuressUnlock) duress = candidate false } } if (discard) vault.discardDuressUnlock(candidate) } fun takeSlotA(): VaultRepository.SlotAUnlock? = synchronized(lock) { slotA.also { slotA = null } } fun takeDuress(): VaultRepository.DuressUnlock? = synchronized(lock) { duress.also { duress = null } } override fun close() { val candidates = synchronized(lock) { if (closed) return closed = true val result = slotA to duress slotA = null duress = null result } candidates.first?.let(vault::discardSlotAUnlock) candidates.second?.let(vault::discardDuressUnlock) } } fun unlock(password: String) { if (password.isBlank() || _state.value.loading) return _state.update { it.copy(loading = true, error = null) } viewModelScope.launch { val candidates = UnlockCandidates(vault) try { // Derive slot A and the duress slot (slot B) CONCURRENTLY so the // wall-clock time to reject/accept never reveals which password was // entered. Each child registers its token before it completes. withContext(Dispatchers.Default) { kotlinx.coroutines.coroutineScope { val duressDeferred = async { vault.tryUnlockDuress(password)?.also(candidates::registerDuress) } val slotADeferred = async { vault.tryUnlockSlotA(password)?.also(candidates::registerSlotA) } // Await both children before choosing a winner. The // candidates object, not a post-await local, owns tokens. duressDeferred.await() slotADeferred.await() } } when (val duressUnlock = candidates.takeDuress()) { // Duress takes precedence: the duress passphrase never reaches // the real vault. Taking it transfers ownership to this branch. null -> when (val slotAUnlock = candidates.takeSlotA()) { null -> _state.update { it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1) } else -> try { when (val result = vault.commitSlotAUnlock(slotAUnlock)) { is UnlockResult.Success -> { lockTimer.start() vault.clearUiGate() foregroundSync.triggerForegroundSync() pinUnlock.markPasswordUsed() pinUnlock.replenishAttemptGates() _state.update { it.copy(loading = false, unlocked = true, pinPreferred = false) } } is UnlockResult.Error -> _state.update { it.copy(loading = false, error = result.reason, shakeNonce = it.shakeNonce + 1) } is UnlockResult.WrongPassword -> _state.update { it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1) } } } finally { // If cancellation happens while commit waits on the // repository mutex, this transferred token still owns // its key until this finally discards it. vault.discardSlotAUnlock(slotAUnlock) } } else -> { candidates.takeSlotA()?.let(vault::discardSlotAUnlock) try { handleDuress(duressUnlock) } finally { vault.discardDuressUnlock(duressUnlock) } } } } finally { // coroutineScope has joined all children before this closes the // owner, covering cancellation at either await and cancellation // while the winner waits on the repository mutex. candidates.close() } } } fun unlockWithPin(pin: String) { if (pin.isBlank() || _state.value.loading) return _state.update { it.copy(loading = true, error = null) } viewModelScope.launch { val key = withContext(Dispatchers.Default) { pinUnlock.unlockVaultKey(pin) } if (key == null) { _state.update { it.copy( loading = false, error = if (pinUnlock.areAttemptsExhausted()) { "PIN locked — use master password" } else { "incorrect PIN" }, shakeNonce = it.shakeNonce + 1, ) } return@launch } try { when (val result = vault.unlockWithKey(key)) { is UnlockResult.Success -> { lockTimer.start() vault.clearUiGate() foregroundSync.triggerForegroundSync() pinUnlock.markPinUsed() _state.update { it.copy(loading = false, unlocked = true, pinPreferred = true) } } else -> _state.update { it.copy(loading = false, error = "incorrect PIN", shakeNonce = it.shakeNonce + 1) } } } finally { VaultCrypto.zero(key) } } } /** * Carry out the configured duress action against an already-derived slot-B unlock. * Both paths look like an ordinary successful (decoy) or fresh-install (destroy) * unlock to a coercer — no error, no hint that a duress passphrase was used. */ private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) { when (duress.action()) { DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) { is UnlockResult.Success -> { // Stop an already-running real sync listener/service before // exposing the decoy session. foregroundSync.stopForDecoySession() lockTimer.start() vault.clearUiGate() _state.update { it.copy(loading = false, unlocked = true) } } else -> _state.update { it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1) } } DuressAction.DESTROY -> { vault.discardDuressUnlock(duressUnlock) val destroyed = vault.destroyVault() if (!destroyed) { _state.update { it.copy(loading = false, error = "destroy_failed") } return@handleDuress } syncFreshStart.reset() duress.clear() _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) } } } } fun create(password: String, confirm: String) { if (_state.value.loading) return createValidationError(password, confirm, scorePassword(password))?.let { msg -> return _state.update { it.copy(error = msg) } } _state.update { it.copy(loading = true, error = null) } viewModelScope.launch { when (val r = vault.createVault(password)) { is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } } is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) } } } } fun clearError() = _state.update { it.copy(error = null) } /** * True when an existing vault can be unlocked by biometrics: strong biometrics * available AND a key was escrowed on enable. (A wrapped key existing implies * the user enabled biometrics — no need to also read the locked settings blob.) */ fun canTryBiometric(): Boolean = vault.hasVault() && Biometrics.strongAvailable(appContext) && BiometricKeyStore.hasWrappedKey(appContext) /** * Run the biometric unlock: prompt with the DECRYPT cipher, unwrap the escrowed * vault key, and unlock with it. Failure/cancel is silent so the password input * remains usable. */ fun biometricUnlock(activity: FragmentActivity) { if (_state.value.loading) return // Sync-retained mode: vault is warm, just confirm identity. if (vault.isUnlocked.value) { Biometrics.authenticate( activity = activity, title = "Unlock Peach", subtitle = "Confirm your identity to access your vault", onSuccess = { vault.clearUiGate() lockTimer.poke() foregroundSync.triggerForegroundSync() _state.update { it.copy(unlocked = true) } }, ) return } val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return Biometrics.authenticateCrypto( activity = activity, title = "Unlock Peach", subtitle = "Use your biometric to unlock your vault", crypto = BiometricPrompt.CryptoObject(cipher), onSuccess = { result -> val used = result.cryptoObject?.cipher ?: return@authenticateCrypto viewModelScope.launch { // Allocate/unseal only inside the launched ownership scope: // if viewModelScope is cancelled before execution, no key is // created that could be stranded before finally runs. var key: ByteArray? = null val r = try { key = BiometricKeyStore.unwrap(appContext, used) if (key == null) return@launch vault.unlockWithKey(key!!) } finally { key?.let(VaultCrypto::zero) } when (r) { is UnlockResult.Success -> { lockTimer.start() vault.clearUiGate() foregroundSync.triggerForegroundSync() _state.update { it.copy(unlocked = true) } } else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) } } } }, ) } }