LockViewModel.kt

9.6 KB · raw

1package com.peachpasswords.ui.lock23import android.content.Context4import androidx.biometric.BiometricPrompt5import androidx.fragment.app.FragmentActivity6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.sync.ForegroundSyncCoordinator9import com.peachpasswords.ui.PeachNav10import com.peachpasswords.ui.vault.entryTapDestination11import com.peachpasswords.vault.CreateVaultResult12import com.peachpasswords.ui.security.scorePassword13import com.peachpasswords.vault.DuressAction14import com.peachpasswords.vault.DuressConfigStore15import com.peachpasswords.vault.LastViewedEntryStore16import com.peachpasswords.vault.UnlockResult17import com.peachpasswords.vault.VaultCrypto18import com.peachpasswords.vault.VaultEntry19import com.peachpasswords.vault.VaultLockTimer20import com.peachpasswords.vault.VaultRepository21import dagger.hilt.android.lifecycle.HiltViewModel22import dagger.hilt.android.qualifiers.ApplicationContext23import kotlinx.coroutines.Dispatchers24import kotlinx.coroutines.async25import kotlinx.coroutines.flow.MutableStateFlow26import kotlinx.coroutines.flow.StateFlow27import kotlinx.coroutines.flow.asStateFlow28import kotlinx.coroutines.flow.update29import kotlinx.coroutines.launch30import kotlinx.coroutines.withContext31import javax.inject.Inject3233data class LockUiState(34    val hasVault: Boolean = false,35    val loading: Boolean = false,36    val error: String? = null,37    val unlocked: Boolean = false,38    /** True when this session just created a new vault (routes through recovery-word setup). */39    val created: Boolean = false,40    /** Increments on each failed unlock so the screen can fire its shake animation. */41    val shakeNonce: Int = 0,42    /** Real vault was wiped by a destroy-duress unlock → screen drops to CreateVault. */43    val duressDestroyed: Boolean = false,44)4546const val MIN_PASSWORD_LENGTH = 124748/** CreateVault is blocked below "fair" (zxcvbn-style score 2). */49const val MIN_CREATE_STRENGTH = 25051/**52 * Validate a create-vault attempt; returns the user-facing error, or null when the53 * inputs are acceptable. Pure so the create rules (length floor, strength gate,54 * confirm match — in that order) are unit-testable without the ViewModel.55 */56internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {57    password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"58    score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"59    password != confirm -> "passwords do not match"60    else -> null61}6263/** Browser parity: create stays disabled until every create-vault rule passes. */64internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =65    createValidationError(password, confirm, score) == null6667/**68 * Where to land after a successful unlock (brief Fix 3): the last-viewed entry if69 * it still exists, else the first active entry, else the vault list when the vault70 * is empty. [resolve] re-validates the stored id against the live vault so a stale71 * pointer (deleted entry) falls back rather than opening a missing detail screen.72 * NOTE entries route to the notes editor via [entryTapDestination], same as a list tap.73 */74internal fun postUnlockDestination(75    lastViewedId: String?,76    resolve: (String) -> VaultEntry?,77    firstActive: VaultEntry?,78): PeachNav {79    val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList80    return entryTapDestination(target)81}8283@HiltViewModel84class LockViewModel @Inject constructor(85    private val vault: VaultRepository,86    private val duress: DuressConfigStore,87    private val lockTimer: VaultLockTimer,88    private val lastViewed: LastViewedEntryStore,89    private val foregroundSync: ForegroundSyncCoordinator,90    @ApplicationContext private val appContext: Context,91) : ViewModel() {92    private val _state = MutableStateFlow(LockUiState(hasVault = vault.hasVault()))93    val state: StateFlow<LockUiState> = _state.asStateFlow()9495    /** Resolve where a just-unlocked session should land (last-viewed entry → first → list). */96    fun postUnlockDestination(): PeachNav =97        postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())9899    fun unlock(password: String) {100        if (password.isBlank() || _state.value.loading) return101        _state.update { it.copy(loading = true, error = null) }102        viewModelScope.launch {103            // Derive slot A and the duress slot (slot B) CONCURRENTLY so the wall-clock104            // time to reject/accept never reveals whether the entered password was the105            // real password, the duress passphrase, or simply wrong (MF-5/MF-6, mirrors106            // browser DUR-002). Both Argon2id derivations run to completion; the branch107            // happens only after both finish. Each derivation is non-committing (no108            // mutex, no session mutation) so the loser is simply discarded.109            val (duressUnlock, slotA) = withContext(Dispatchers.Default) {110                val duressDeferred = async { if (duress.isConfigured()) vault.tryUnlockDuress(password) else null }111                val slotADeferred = async { vault.tryUnlockSlotA(password) }112                duressDeferred.await() to slotADeferred.await()113            }114            when {115                // Duress takes precedence: the duress passphrase never reaches the real vault.116                duressUnlock != null -> {117                    slotA?.let(vault::discardSlotAUnlock)118                    handleDuress(duressUnlock)119                }120                slotA != null -> {121                    vault.commitSlotAUnlock(slotA)122                    lockTimer.start()123                    foregroundSync.triggerForegroundSync()124                    _state.update { it.copy(loading = false, unlocked = true) }125                }126                else -> _state.update {127                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)128                }129            }130        }131    }132133    /**134     * Carry out the configured duress action against an already-derived slot-B unlock.135     * Both paths look like an ordinary successful (decoy) or fresh-install (destroy)136     * unlock to a coercer — no error, no hint that a duress passphrase was used.137     */138    private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {139        when (duress.action()) {140            DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {141                is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true) } }142                else -> _state.update {143                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)144                }145            }146            DuressAction.DESTROY -> {147                vault.discardDuressUnlock(duressUnlock)148                vault.destroyVault()149                duress.clear()150                _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }151            }152        }153    }154155    fun create(password: String, confirm: String) {156        if (_state.value.loading) return157        createValidationError(password, confirm, scorePassword(password))?.let { msg ->158            return _state.update { it.copy(error = msg) }159        }160        _state.update { it.copy(loading = true, error = null) }161        viewModelScope.launch {162            when (val r = vault.createVault(password)) {163                is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }164                is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }165            }166        }167    }168169    fun clearError() = _state.update { it.copy(error = null) }170171    /**172     * True when an existing vault can be unlocked by biometrics: strong biometrics173     * available AND a key was escrowed on enable. (A wrapped key existing implies174     * the user enabled biometrics — no need to also read the locked settings blob.)175     */176    fun canTryBiometric(): Boolean =177        vault.hasVault() &&178            Biometrics.strongAvailable(appContext) &&179            BiometricKeyStore.hasWrappedKey(appContext)180181    /**182     * Run the biometric unlock: prompt with the DECRYPT cipher, unwrap the escrowed183     * vault key, and unlock with it. Failure/cancel is silent so the password input184     * remains usable.185     */186    fun biometricUnlock(activity: FragmentActivity) {187        if (_state.value.loading) return188        val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return189        Biometrics.authenticateCrypto(190            activity = activity,191            title = "Unlock Peach",192            subtitle = "Use your biometric to unlock your vault",193            crypto = BiometricPrompt.CryptoObject(cipher),194            onSuccess = { result ->195                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto196                val key = BiometricKeyStore.unwrap(appContext, used) ?: return@authenticateCrypto197                viewModelScope.launch {198                    val r = vault.unlockWithKey(key)199                    VaultCrypto.zero(key)200                    when (r) {201                        is UnlockResult.Success -> {202                            lockTimer.start()203                            foregroundSync.triggerForegroundSync()204                            _state.update { it.copy(unlocked = true) }205                        }206                        else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }207                    }208                }209            },210        )211    }212}