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.sync.ForegroundSyncCoordinator import com.peachpasswords.sync.PeachSyncService 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 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, private val duress: DuressConfigStore, private val lockTimer: VaultLockTimer, private val lastViewed: LastViewedEntryStore, private val foregroundSync: ForegroundSyncCoordinator, @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _state = MutableStateFlow(LockUiState(hasVault = vault.hasVault())) 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()) fun unlock(password: String) { if (password.isBlank() || _state.value.loading) return _state.update { it.copy(loading = true, error = null) } viewModelScope.launch { // Derive slot A and the duress slot (slot B) CONCURRENTLY so the wall-clock // time to reject/accept never reveals whether the entered password was the // real password, the duress passphrase, or simply wrong (MF-5/MF-6, mirrors // browser DUR-002). Both Argon2id derivations run to completion; the branch // happens only after both finish. Each derivation is non-committing (no // mutex, no session mutation) so the loser is simply discarded. val (duressUnlock, slotA) = withContext(Dispatchers.Default) { val duressDeferred = async { if (duress.isConfigured()) vault.tryUnlockDuress(password) else null } val slotADeferred = async { vault.tryUnlockSlotA(password) } duressDeferred.await() to slotADeferred.await() } when { // Duress takes precedence: the duress passphrase never reaches the real vault. duressUnlock != null -> { slotA?.let(vault::discardSlotAUnlock) handleDuress(duressUnlock) } slotA != null -> { vault.commitSlotAUnlock(slotA) lockTimer.start() PeachSyncService.start(appContext) foregroundSync.triggerForegroundSync() _state.update { it.copy(loading = false, unlocked = true) } } else -> _state.update { it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1) } } } } /** * 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 -> { lockTimer.start(); _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) vault.destroyVault() 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 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 val key = BiometricKeyStore.unwrap(appContext, used) ?: return@authenticateCrypto viewModelScope.launch { val r = vault.unlockWithKey(key) VaultCrypto.zero(key) when (r) { is UnlockResult.Success -> { lockTimer.start() PeachSyncService.start(appContext) foregroundSync.triggerForegroundSync() _state.update { it.copy(unlocked = true) } } else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) } } } }, ) } }