LockViewModel.kt

9.8 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.sync.PeachSyncService10import com.peachpasswords.ui.PeachNav11import com.peachpasswords.ui.vault.entryTapDestination12import com.peachpasswords.vault.CreateVaultResult13import com.peachpasswords.ui.security.scorePassword14import com.peachpasswords.vault.DuressAction15import com.peachpasswords.vault.DuressConfigStore16import com.peachpasswords.vault.LastViewedEntryStore17import com.peachpasswords.vault.UnlockResult18import com.peachpasswords.vault.VaultCrypto19import com.peachpasswords.vault.VaultEntry20import com.peachpasswords.vault.VaultLockTimer21import com.peachpasswords.vault.VaultRepository22import dagger.hilt.android.lifecycle.HiltViewModel23import dagger.hilt.android.qualifiers.ApplicationContext24import kotlinx.coroutines.Dispatchers25import kotlinx.coroutines.async26import kotlinx.coroutines.flow.MutableStateFlow27import kotlinx.coroutines.flow.StateFlow28import kotlinx.coroutines.flow.asStateFlow29import kotlinx.coroutines.flow.update30import kotlinx.coroutines.launch31import kotlinx.coroutines.withContext32import javax.inject.Inject3334data class LockUiState(35    val hasVault: Boolean = false,36    val loading: Boolean = false,37    val error: String? = null,38    val unlocked: Boolean = false,39    /** True when this session just created a new vault (routes through recovery-word setup). */40    val created: Boolean = false,41    /** Increments on each failed unlock so the screen can fire its shake animation. */42    val shakeNonce: Int = 0,43    /** Real vault was wiped by a destroy-duress unlock → screen drops to CreateVault. */44    val duressDestroyed: Boolean = false,45)4647const val MIN_PASSWORD_LENGTH = 124849/** CreateVault is blocked below "fair" (zxcvbn-style score 2). */50const val MIN_CREATE_STRENGTH = 25152/**53 * Validate a create-vault attempt; returns the user-facing error, or null when the54 * inputs are acceptable. Pure so the create rules (length floor, strength gate,55 * confirm match — in that order) are unit-testable without the ViewModel.56 */57internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {58    password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"59    score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"60    password != confirm -> "passwords do not match"61    else -> null62}6364/** Browser parity: create stays disabled until every create-vault rule passes. */65internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =66    createValidationError(password, confirm, score) == null6768/**69 * Where to land after a successful unlock (brief Fix 3): the last-viewed entry if70 * it still exists, else the first active entry, else the vault list when the vault71 * is empty. [resolve] re-validates the stored id against the live vault so a stale72 * pointer (deleted entry) falls back rather than opening a missing detail screen.73 * NOTE entries route to the notes editor via [entryTapDestination], same as a list tap.74 */75internal fun postUnlockDestination(76    lastViewedId: String?,77    resolve: (String) -> VaultEntry?,78    firstActive: VaultEntry?,79): PeachNav {80    val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList81    return entryTapDestination(target)82}8384@HiltViewModel85class LockViewModel @Inject constructor(86    private val vault: VaultRepository,87    private val duress: DuressConfigStore,88    private val lockTimer: VaultLockTimer,89    private val lastViewed: LastViewedEntryStore,90    private val foregroundSync: ForegroundSyncCoordinator,91    @ApplicationContext private val appContext: Context,92) : ViewModel() {93    private val _state = MutableStateFlow(LockUiState(hasVault = vault.hasVault()))94    val state: StateFlow<LockUiState> = _state.asStateFlow()9596    /** Resolve where a just-unlocked session should land (last-viewed entry → first → list). */97    fun postUnlockDestination(): PeachNav =98        postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())99100    fun unlock(password: String) {101        if (password.isBlank() || _state.value.loading) return102        _state.update { it.copy(loading = true, error = null) }103        viewModelScope.launch {104            // Derive slot A and the duress slot (slot B) CONCURRENTLY so the wall-clock105            // time to reject/accept never reveals whether the entered password was the106            // real password, the duress passphrase, or simply wrong (MF-5/MF-6, mirrors107            // browser DUR-002). Both Argon2id derivations run to completion; the branch108            // happens only after both finish. Each derivation is non-committing (no109            // mutex, no session mutation) so the loser is simply discarded.110            val (duressUnlock, slotA) = withContext(Dispatchers.Default) {111                val duressDeferred = async { if (duress.isConfigured()) vault.tryUnlockDuress(password) else null }112                val slotADeferred = async { vault.tryUnlockSlotA(password) }113                duressDeferred.await() to slotADeferred.await()114            }115            when {116                // Duress takes precedence: the duress passphrase never reaches the real vault.117                duressUnlock != null -> {118                    slotA?.let(vault::discardSlotAUnlock)119                    handleDuress(duressUnlock)120                }121                slotA != null -> {122                    vault.commitSlotAUnlock(slotA)123                    lockTimer.start()124                    PeachSyncService.start(appContext)125                    foregroundSync.triggerForegroundSync()126                    _state.update { it.copy(loading = false, unlocked = true) }127                }128                else -> _state.update {129                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)130                }131            }132        }133    }134135    /**136     * Carry out the configured duress action against an already-derived slot-B unlock.137     * Both paths look like an ordinary successful (decoy) or fresh-install (destroy)138     * unlock to a coercer — no error, no hint that a duress passphrase was used.139     */140    private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {141        when (duress.action()) {142            DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {143                is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true) } }144                else -> _state.update {145                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)146                }147            }148            DuressAction.DESTROY -> {149                vault.discardDuressUnlock(duressUnlock)150                vault.destroyVault()151                duress.clear()152                _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }153            }154        }155    }156157    fun create(password: String, confirm: String) {158        if (_state.value.loading) return159        createValidationError(password, confirm, scorePassword(password))?.let { msg ->160            return _state.update { it.copy(error = msg) }161        }162        _state.update { it.copy(loading = true, error = null) }163        viewModelScope.launch {164            when (val r = vault.createVault(password)) {165                is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }166                is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }167            }168        }169    }170171    fun clearError() = _state.update { it.copy(error = null) }172173    /**174     * True when an existing vault can be unlocked by biometrics: strong biometrics175     * available AND a key was escrowed on enable. (A wrapped key existing implies176     * the user enabled biometrics — no need to also read the locked settings blob.)177     */178    fun canTryBiometric(): Boolean =179        vault.hasVault() &&180            Biometrics.strongAvailable(appContext) &&181            BiometricKeyStore.hasWrappedKey(appContext)182183    /**184     * Run the biometric unlock: prompt with the DECRYPT cipher, unwrap the escrowed185     * vault key, and unlock with it. Failure/cancel is silent so the password input186     * remains usable.187     */188    fun biometricUnlock(activity: FragmentActivity) {189        if (_state.value.loading) return190        val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return191        Biometrics.authenticateCrypto(192            activity = activity,193            title = "Unlock Peach",194            subtitle = "Use your biometric to unlock your vault",195            crypto = BiometricPrompt.CryptoObject(cipher),196            onSuccess = { result ->197                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto198                val key = BiometricKeyStore.unwrap(appContext, used) ?: return@authenticateCrypto199                viewModelScope.launch {200                    val r = vault.unlockWithKey(key)201                    VaultCrypto.zero(key)202                    when (r) {203                        is UnlockResult.Success -> {204                            lockTimer.start()205                            PeachSyncService.start(appContext)206                            foregroundSync.triggerForegroundSync()207                            _state.update { it.copy(unlocked = true) }208                        }209                        else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }210                    }211                }212            },213        )214    }215}