LockViewModel.kt

9.3 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.ui.PeachNav9import com.peachpasswords.ui.vault.entryTapDestination10import com.peachpasswords.vault.CreateVaultResult11import com.peachpasswords.ui.security.scorePassword12import com.peachpasswords.vault.DuressAction13import com.peachpasswords.vault.DuressConfigStore14import com.peachpasswords.vault.LastViewedEntryStore15import com.peachpasswords.vault.UnlockResult16import com.peachpasswords.vault.VaultCrypto17import com.peachpasswords.vault.VaultEntry18import com.peachpasswords.vault.VaultLockTimer19import com.peachpasswords.vault.VaultRepository20import dagger.hilt.android.lifecycle.HiltViewModel21import dagger.hilt.android.qualifiers.ApplicationContext22import kotlinx.coroutines.Dispatchers23import kotlinx.coroutines.async24import kotlinx.coroutines.flow.MutableStateFlow25import kotlinx.coroutines.flow.StateFlow26import kotlinx.coroutines.flow.asStateFlow27import kotlinx.coroutines.flow.update28import kotlinx.coroutines.launch29import kotlinx.coroutines.withContext30import javax.inject.Inject3132data class LockUiState(33    val hasVault: Boolean = false,34    val loading: Boolean = false,35    val error: String? = null,36    val unlocked: Boolean = false,37    /** True when this session just created a new vault (routes through recovery-word setup). */38    val created: Boolean = false,39    /** Increments on each failed unlock so the screen can fire its shake animation. */40    val shakeNonce: Int = 0,41    /** Real vault was wiped by a destroy-duress unlock → screen drops to CreateVault. */42    val duressDestroyed: Boolean = false,43)4445const val MIN_PASSWORD_LENGTH = 124647/** CreateVault is blocked below "fair" (zxcvbn-style score 2). */48const val MIN_CREATE_STRENGTH = 24950/**51 * Validate a create-vault attempt; returns the user-facing error, or null when the52 * inputs are acceptable. Pure so the create rules (length floor, strength gate,53 * confirm match — in that order) are unit-testable without the ViewModel.54 */55internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {56    password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"57    score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"58    password != confirm -> "passwords do not match"59    else -> null60}6162/** Browser parity: create stays disabled until every create-vault rule passes. */63internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =64    createValidationError(password, confirm, score) == null6566/**67 * Where to land after a successful unlock (brief Fix 3): the last-viewed entry if68 * it still exists, else the first active entry, else the vault list when the vault69 * is empty. [resolve] re-validates the stored id against the live vault so a stale70 * pointer (deleted entry) falls back rather than opening a missing detail screen.71 * NOTE entries route to the notes editor via [entryTapDestination], same as a list tap.72 */73internal fun postUnlockDestination(74    lastViewedId: String?,75    resolve: (String) -> VaultEntry?,76    firstActive: VaultEntry?,77): PeachNav {78    val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList79    return entryTapDestination(target)80}8182@HiltViewModel83class LockViewModel @Inject constructor(84    private val vault: VaultRepository,85    private val duress: DuressConfigStore,86    private val lockTimer: VaultLockTimer,87    private val lastViewed: LastViewedEntryStore,88    @ApplicationContext private val appContext: Context,89) : ViewModel() {90    private val _state = MutableStateFlow(LockUiState(hasVault = vault.hasVault()))91    val state: StateFlow<LockUiState> = _state.asStateFlow()9293    /** Resolve where a just-unlocked session should land (last-viewed entry → first → list). */94    fun postUnlockDestination(): PeachNav =95        postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())9697    fun unlock(password: String) {98        if (password.isBlank() || _state.value.loading) return99        _state.update { it.copy(loading = true, error = null) }100        viewModelScope.launch {101            // Derive slot A and the duress slot (slot B) CONCURRENTLY so the wall-clock102            // time to reject/accept never reveals whether the entered password was the103            // real password, the duress passphrase, or simply wrong (MF-5/MF-6, mirrors104            // browser DUR-002). Both Argon2id derivations run to completion; the branch105            // happens only after both finish. Each derivation is non-committing (no106            // mutex, no session mutation) so the loser is simply discarded.107            val (duressUnlock, slotA) = withContext(Dispatchers.Default) {108                val duressDeferred = async { if (duress.isConfigured()) vault.tryUnlockDuress(password) else null }109                val slotADeferred = async { vault.tryUnlockSlotA(password) }110                duressDeferred.await() to slotADeferred.await()111            }112            when {113                // Duress takes precedence: the duress passphrase never reaches the real vault.114                duressUnlock != null -> {115                    slotA?.let(vault::discardSlotAUnlock)116                    handleDuress(duressUnlock)117                }118                slotA != null -> {119                    vault.commitSlotAUnlock(slotA)120                    lockTimer.start()121                    _state.update { it.copy(loading = false, unlocked = true) }122                }123                else -> _state.update {124                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)125                }126            }127        }128    }129130    /**131     * Carry out the configured duress action against an already-derived slot-B unlock.132     * Both paths look like an ordinary successful (decoy) or fresh-install (destroy)133     * unlock to a coercer — no error, no hint that a duress passphrase was used.134     */135    private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {136        when (duress.action()) {137            DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {138                is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true) } }139                else -> _state.update {140                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)141                }142            }143            DuressAction.DESTROY -> {144                vault.discardDuressUnlock(duressUnlock)145                vault.destroyVault()146                duress.clear()147                _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }148            }149        }150    }151152    fun create(password: String, confirm: String) {153        if (_state.value.loading) return154        createValidationError(password, confirm, scorePassword(password))?.let { msg ->155            return _state.update { it.copy(error = msg) }156        }157        _state.update { it.copy(loading = true, error = null) }158        viewModelScope.launch {159            when (val r = vault.createVault(password)) {160                is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }161                is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }162            }163        }164    }165166    fun clearError() = _state.update { it.copy(error = null) }167168    /**169     * True when an existing vault can be unlocked by biometrics: strong biometrics170     * available AND a key was escrowed on enable. (A wrapped key existing implies171     * the user enabled biometrics — no need to also read the locked settings blob.)172     */173    fun canTryBiometric(): Boolean =174        vault.hasVault() &&175            Biometrics.strongAvailable(appContext) &&176            BiometricKeyStore.hasWrappedKey(appContext)177178    /**179     * Run the biometric unlock: prompt with the DECRYPT cipher, unwrap the escrowed180     * vault key, and unlock with it. Failure/cancel is silent so the password input181     * remains usable.182     */183    fun biometricUnlock(activity: FragmentActivity) {184        if (_state.value.loading) return185        val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return186        Biometrics.authenticateCrypto(187            activity = activity,188            title = "Unlock Peach",189            subtitle = "Use your biometric to unlock your vault",190            crypto = BiometricPrompt.CryptoObject(cipher),191            onSuccess = { result ->192                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto193                val key = BiometricKeyStore.unwrap(appContext, used) ?: return@authenticateCrypto194                viewModelScope.launch {195                    val r = vault.unlockWithKey(key)196                    VaultCrypto.zero(key)197                    when (r) {198                        is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(unlocked = true) } }199                        else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }200                    }201                }202            },203        )204    }205}