LockViewModel.kt

17.5 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.license.LicenseRepository9import com.peachpasswords.sync.ForegroundSyncCoordinator10import com.peachpasswords.sync.SyncFreshStartResetter11import com.peachpasswords.ui.PeachNav12import com.peachpasswords.ui.vault.entryTapDestination13import com.peachpasswords.vault.CreateVaultResult14import com.peachpasswords.ui.security.scorePassword15import com.peachpasswords.vault.DuressAction16import com.peachpasswords.vault.DuressConfigStore17import com.peachpasswords.vault.LastViewedEntryStore18import com.peachpasswords.vault.UnlockResult19import com.peachpasswords.vault.VaultCrypto20import com.peachpasswords.vault.VaultEntry21import com.peachpasswords.vault.VaultLockTimer22import com.peachpasswords.vault.VaultRepository23import dagger.hilt.android.lifecycle.HiltViewModel24import dagger.hilt.android.qualifiers.ApplicationContext25import kotlinx.coroutines.Dispatchers26import kotlinx.coroutines.async27import kotlinx.coroutines.flow.MutableStateFlow28import kotlinx.coroutines.flow.StateFlow29import kotlinx.coroutines.flow.asStateFlow30import kotlinx.coroutines.flow.update31import kotlinx.coroutines.launch32import kotlinx.coroutines.withContext33import javax.inject.Inject3435data class LockUiState(36    val hasVault: Boolean = false,37    val pinEnabled: Boolean = false,38    val pinPreferred: Boolean = false,39    val isPro: Boolean = false,40    val loading: Boolean = false,41    val error: String? = null,42    val unlocked: Boolean = false,43    /** True when this session just created a new vault (routes through recovery-word setup). */44    val created: Boolean = false,45    /** Increments on each failed unlock so the screen can fire its shake animation. */46    val shakeNonce: Int = 0,47    /** Real vault was wiped by a destroy-duress unlock → screen drops to CreateVault. */48    val duressDestroyed: Boolean = false,49)5051const val MIN_PASSWORD_LENGTH = 125253/** CreateVault is blocked below "fair" (zxcvbn-style score 2). */54const val MIN_CREATE_STRENGTH = 25556/**57 * Validate a create-vault attempt; returns the user-facing error, or null when the58 * inputs are acceptable. Pure so the create rules (length floor, strength gate,59 * confirm match — in that order) are unit-testable without the ViewModel.60 */61internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {62    password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"63    score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"64    password != confirm -> "passwords do not match"65    else -> null66}6768/** Browser parity: create stays disabled until every create-vault rule passes. */69internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =70    createValidationError(password, confirm, score) == null7172/**73 * Where to land after a successful unlock (brief Fix 3): the last-viewed entry if74 * it still exists, else the first active entry, else the vault list when the vault75 * is empty. [resolve] re-validates the stored id against the live vault so a stale76 * pointer (deleted entry) falls back rather than opening a missing detail screen.77 * NOTE entries route to the notes editor via [entryTapDestination], same as a list tap.78 */79internal fun postUnlockDestination(80    lastViewedId: String?,81    resolve: (String) -> VaultEntry?,82    firstActive: VaultEntry?,83): PeachNav {84    val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList85    return entryTapDestination(target)86}8788@HiltViewModel89class LockViewModel @Inject constructor(90    private val vault: VaultRepository,91    license: LicenseRepository,92    private val duress: DuressConfigStore,93    private val lockTimer: VaultLockTimer,94    private val lastViewed: LastViewedEntryStore,95    private val foregroundSync: ForegroundSyncCoordinator,96    private val syncFreshStart: SyncFreshStartResetter,97    private val pinUnlock: com.peachpasswords.vault.PinUnlockStore,98    @ApplicationContext private val appContext: Context,99) : ViewModel() {100    private val _state = MutableStateFlow(101        LockUiState(102            hasVault = vault.hasVault(),103            pinEnabled = pinUnlock.isConfigured(),104            pinPreferred = pinUnlock.isPinPreferred(),105            isPro = license.isProActive(),106        ),107    )108    val state: StateFlow<LockUiState> = _state.asStateFlow()109110    /** Resolve where a just-unlocked session should land (last-viewed entry → first → list). */111    fun postUnlockDestination(): PeachNav =112        postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())113114    /**115     * Owns candidate unlock tokens for the whole concurrent-derivation scope.116     *117     * Argon2id is deliberately not cancellable.  A token can therefore be118     * produced after the caller has been cancelled unless ownership is claimed119     * by the child immediately, before that child completes.  The scope closes120     * only after [coroutineScope] has joined both children, so every registered121     * loser is discarded even when cancellation happens between either await.122     */123    private class UnlockCandidates(private val vault: VaultRepository) : AutoCloseable {124        private val lock = Any()125        private var closed = false126        private var slotA: VaultRepository.SlotAUnlock? = null127        private var duress: VaultRepository.DuressUnlock? = null128129        fun registerSlotA(candidate: VaultRepository.SlotAUnlock) {130            val discard = synchronized(lock) {131                if (closed) true else {132                    slotA?.let(vault::discardSlotAUnlock)133                    slotA = candidate134                    false135                }136            }137            if (discard) vault.discardSlotAUnlock(candidate)138        }139140        fun registerDuress(candidate: VaultRepository.DuressUnlock) {141            val discard = synchronized(lock) {142                if (closed) true else {143                    duress?.let(vault::discardDuressUnlock)144                    duress = candidate145                    false146                }147            }148            if (discard) vault.discardDuressUnlock(candidate)149        }150151        fun takeSlotA(): VaultRepository.SlotAUnlock? = synchronized(lock) {152            slotA.also { slotA = null }153        }154155        fun takeDuress(): VaultRepository.DuressUnlock? = synchronized(lock) {156            duress.also { duress = null }157        }158159        override fun close() {160            val candidates = synchronized(lock) {161                if (closed) return162                closed = true163                val result = slotA to duress164                slotA = null165                duress = null166                result167            }168            candidates.first?.let(vault::discardSlotAUnlock)169            candidates.second?.let(vault::discardDuressUnlock)170        }171    }172173    fun unlock(password: String) {174        if (password.isBlank() || _state.value.loading) return175        _state.update { it.copy(loading = true, error = null) }176        viewModelScope.launch {177            val candidates = UnlockCandidates(vault)178            try {179                // Derive slot A and the duress slot (slot B) CONCURRENTLY so the180                // wall-clock time to reject/accept never reveals which password was181                // entered.  Each child registers its token before it completes.182                withContext(Dispatchers.Default) {183                    kotlinx.coroutines.coroutineScope {184                        val duressDeferred = async {185                            vault.tryUnlockDuress(password)?.also(candidates::registerDuress)186                        }187                        val slotADeferred = async {188                            vault.tryUnlockSlotA(password)?.also(candidates::registerSlotA)189                        }190                        // Await both children before choosing a winner.  The191                        // candidates object, not a post-await local, owns tokens.192                        duressDeferred.await()193                        slotADeferred.await()194                    }195                }196197                when (val duressUnlock = candidates.takeDuress()) {198                    // Duress takes precedence: the duress passphrase never reaches199                    // the real vault.  Taking it transfers ownership to this branch.200                    null -> when (val slotAUnlock = candidates.takeSlotA()) {201                        null -> _state.update {202                            it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)203                        }204                        else -> try {205                            when (val result = vault.commitSlotAUnlock(slotAUnlock)) {206                                is UnlockResult.Success -> {207                                    lockTimer.start()208                                    vault.clearUiGate()209                                    foregroundSync.triggerForegroundSync()210                                    pinUnlock.markPasswordUsed()211                                    pinUnlock.replenishAttemptGates()212                                    _state.update { it.copy(loading = false, unlocked = true, pinPreferred = false) }213                                }214                                is UnlockResult.Error -> _state.update {215                                    it.copy(loading = false, error = result.reason, shakeNonce = it.shakeNonce + 1)216                                }217                                is UnlockResult.WrongPassword -> _state.update {218                                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)219                                }220                            }221                        } finally {222                            // If cancellation happens while commit waits on the223                            // repository mutex, this transferred token still owns224                            // its key until this finally discards it.225                            vault.discardSlotAUnlock(slotAUnlock)226                        }227                    }228                    else -> {229                        candidates.takeSlotA()?.let(vault::discardSlotAUnlock)230                        try {231                            handleDuress(duressUnlock)232                        } finally {233                            vault.discardDuressUnlock(duressUnlock)234                        }235                    }236                }237            } finally {238                // coroutineScope has joined all children before this closes the239                // owner, covering cancellation at either await and cancellation240                // while the winner waits on the repository mutex.241                candidates.close()242            }243        }244    }245246    fun unlockWithPin(pin: String) {247        if (pin.isBlank() || _state.value.loading) return248        _state.update { it.copy(loading = true, error = null) }249        viewModelScope.launch {250            val key = withContext(Dispatchers.Default) {251                pinUnlock.unlockVaultKey(pin)252            }253            if (key == null) {254                _state.update {255                    it.copy(256                        loading = false,257                        error = if (pinUnlock.areAttemptsExhausted()) {258                            "PIN locked — use master password"259                        } else {260                            "incorrect PIN"261                        },262                        shakeNonce = it.shakeNonce + 1,263                    )264                }265                return@launch266            }267            try {268                when (val result = vault.unlockWithKey(key)) {269                    is UnlockResult.Success -> {270                        lockTimer.start()271                        vault.clearUiGate()272                        foregroundSync.triggerForegroundSync()273                        pinUnlock.markPinUsed()274                        _state.update { it.copy(loading = false, unlocked = true, pinPreferred = true) }275                    }276                    else -> _state.update {277                        it.copy(loading = false, error = "incorrect PIN", shakeNonce = it.shakeNonce + 1)278                    }279                }280            } finally {281                VaultCrypto.zero(key)282            }283        }284    }285286    /**287     * Carry out the configured duress action against an already-derived slot-B unlock.288     * Both paths look like an ordinary successful (decoy) or fresh-install (destroy)289     * unlock to a coercer — no error, no hint that a duress passphrase was used.290     */291    private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {292        when (duress.action()) {293            DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {294                is UnlockResult.Success -> {295                    // Stop an already-running real sync listener/service before296                    // exposing the decoy session.297                    foregroundSync.stopForDecoySession()298                    lockTimer.start()299                    vault.clearUiGate()300                    _state.update { it.copy(loading = false, unlocked = true) }301                }302                else -> _state.update {303                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)304                }305            }306            DuressAction.DESTROY -> {307                vault.discardDuressUnlock(duressUnlock)308                val destroyed = vault.destroyVault()309                if (!destroyed) {310                    _state.update { it.copy(loading = false, error = "destroy_failed") }311                    return@handleDuress312                }313                syncFreshStart.reset()314                duress.clear()315                _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }316            }317        }318    }319320    fun create(password: String, confirm: String) {321        if (_state.value.loading) return322        createValidationError(password, confirm, scorePassword(password))?.let { msg ->323            return _state.update { it.copy(error = msg) }324        }325        _state.update { it.copy(loading = true, error = null) }326        viewModelScope.launch {327            when (val r = vault.createVault(password)) {328                is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }329                is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }330            }331        }332    }333334    fun clearError() = _state.update { it.copy(error = null) }335336    /**337     * True when an existing vault can be unlocked by biometrics: strong biometrics338     * available AND a key was escrowed on enable. (A wrapped key existing implies339     * the user enabled biometrics — no need to also read the locked settings blob.)340     */341    fun canTryBiometric(): Boolean =342        vault.hasVault() &&343            Biometrics.strongAvailable(appContext) &&344            BiometricKeyStore.hasWrappedKey(appContext)345346    /**347     * Run the biometric unlock: prompt with the DECRYPT cipher, unwrap the escrowed348     * vault key, and unlock with it. Failure/cancel is silent so the password input349     * remains usable.350     */351    fun biometricUnlock(activity: FragmentActivity) {352        if (_state.value.loading) return353        // Sync-retained mode: vault is warm, just confirm identity.354        if (vault.isUnlocked.value) {355            Biometrics.authenticate(356                activity = activity,357                title = "Unlock Peach",358                subtitle = "Confirm your identity to access your vault",359                onSuccess = {360                    vault.clearUiGate()361                    lockTimer.poke()362                    foregroundSync.triggerForegroundSync()363                    _state.update { it.copy(unlocked = true) }364                },365            )366            return367        }368        val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return369        Biometrics.authenticateCrypto(370            activity = activity,371            title = "Unlock Peach",372            subtitle = "Use your biometric to unlock your vault",373            crypto = BiometricPrompt.CryptoObject(cipher),374            onSuccess = { result ->375                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto376                viewModelScope.launch {377                    // Allocate/unseal only inside the launched ownership scope:378                    // if viewModelScope is cancelled before execution, no key is379                    // created that could be stranded before finally runs.380                    var key: ByteArray? = null381                    val r = try {382                        key = BiometricKeyStore.unwrap(appContext, used)383                        if (key == null) return@launch384                        vault.unlockWithKey(key!!)385                    } finally {386                        key?.let(VaultCrypto::zero)387                    }388                    when (r) {389                        is UnlockResult.Success -> {390                            lockTimer.start()391                            vault.clearUiGate()392                            foregroundSync.triggerForegroundSync()393                            _state.update { it.copy(unlocked = true) }394                        }395                        else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }396                    }397                }398            },399        )400    }401}