LockViewModel.kt

15.2 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 isPro: Boolean = false,38    val loading: Boolean = false,39    val error: String? = null,40    val unlocked: Boolean = false,41    /** True when this session just created a new vault (routes through recovery-word setup). */42    val created: Boolean = false,43    /** Increments on each failed unlock so the screen can fire its shake animation. */44    val shakeNonce: Int = 0,45    /** Real vault was wiped by a destroy-duress unlock → screen drops to CreateVault. */46    val duressDestroyed: Boolean = false,47)4849const val MIN_PASSWORD_LENGTH = 125051/** CreateVault is blocked below "fair" (zxcvbn-style score 2). */52const val MIN_CREATE_STRENGTH = 25354/**55 * Validate a create-vault attempt; returns the user-facing error, or null when the56 * inputs are acceptable. Pure so the create rules (length floor, strength gate,57 * confirm match — in that order) are unit-testable without the ViewModel.58 */59internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {60    password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"61    score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"62    password != confirm -> "passwords do not match"63    else -> null64}6566/** Browser parity: create stays disabled until every create-vault rule passes. */67internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =68    createValidationError(password, confirm, score) == null6970/**71 * Where to land after a successful unlock (brief Fix 3): the last-viewed entry if72 * it still exists, else the first active entry, else the vault list when the vault73 * is empty. [resolve] re-validates the stored id against the live vault so a stale74 * pointer (deleted entry) falls back rather than opening a missing detail screen.75 * NOTE entries route to the notes editor via [entryTapDestination], same as a list tap.76 */77internal fun postUnlockDestination(78    lastViewedId: String?,79    resolve: (String) -> VaultEntry?,80    firstActive: VaultEntry?,81): PeachNav {82    val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList83    return entryTapDestination(target)84}8586@HiltViewModel87class LockViewModel @Inject constructor(88    private val vault: VaultRepository,89    license: LicenseRepository,90    private val duress: DuressConfigStore,91    private val lockTimer: VaultLockTimer,92    private val lastViewed: LastViewedEntryStore,93    private val foregroundSync: ForegroundSyncCoordinator,94    private val syncFreshStart: SyncFreshStartResetter,95    @ApplicationContext private val appContext: Context,96) : ViewModel() {97    private val _state = MutableStateFlow(98        LockUiState(hasVault = vault.hasVault(), isPro = license.isProActive()),99    )100    val state: StateFlow<LockUiState> = _state.asStateFlow()101102    /** Resolve where a just-unlocked session should land (last-viewed entry → first → list). */103    fun postUnlockDestination(): PeachNav =104        postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())105106    /**107     * Owns candidate unlock tokens for the whole concurrent-derivation scope.108     *109     * Argon2id is deliberately not cancellable.  A token can therefore be110     * produced after the caller has been cancelled unless ownership is claimed111     * by the child immediately, before that child completes.  The scope closes112     * only after [coroutineScope] has joined both children, so every registered113     * loser is discarded even when cancellation happens between either await.114     */115    private class UnlockCandidates(private val vault: VaultRepository) : AutoCloseable {116        private val lock = Any()117        private var closed = false118        private var slotA: VaultRepository.SlotAUnlock? = null119        private var duress: VaultRepository.DuressUnlock? = null120121        fun registerSlotA(candidate: VaultRepository.SlotAUnlock) {122            val discard = synchronized(lock) {123                if (closed) true else {124                    slotA?.let(vault::discardSlotAUnlock)125                    slotA = candidate126                    false127                }128            }129            if (discard) vault.discardSlotAUnlock(candidate)130        }131132        fun registerDuress(candidate: VaultRepository.DuressUnlock) {133            val discard = synchronized(lock) {134                if (closed) true else {135                    duress?.let(vault::discardDuressUnlock)136                    duress = candidate137                    false138                }139            }140            if (discard) vault.discardDuressUnlock(candidate)141        }142143        fun takeSlotA(): VaultRepository.SlotAUnlock? = synchronized(lock) {144            slotA.also { slotA = null }145        }146147        fun takeDuress(): VaultRepository.DuressUnlock? = synchronized(lock) {148            duress.also { duress = null }149        }150151        override fun close() {152            val candidates = synchronized(lock) {153                if (closed) return154                closed = true155                val result = slotA to duress156                slotA = null157                duress = null158                result159            }160            candidates.first?.let(vault::discardSlotAUnlock)161            candidates.second?.let(vault::discardDuressUnlock)162        }163    }164165    fun unlock(password: String) {166        if (password.isBlank() || _state.value.loading) return167        _state.update { it.copy(loading = true, error = null) }168        viewModelScope.launch {169            val candidates = UnlockCandidates(vault)170            try {171                // Derive slot A and the duress slot (slot B) CONCURRENTLY so the172                // wall-clock time to reject/accept never reveals which password was173                // entered.  Each child registers its token before it completes.174                withContext(Dispatchers.Default) {175                    kotlinx.coroutines.coroutineScope {176                        val duressDeferred = async {177                            if (duress.isConfigured()) {178                                vault.tryUnlockDuress(password)?.also(candidates::registerDuress)179                            } else null180                        }181                        val slotADeferred = async {182                            vault.tryUnlockSlotA(password)?.also(candidates::registerSlotA)183                        }184                        // Await both children before choosing a winner.  The185                        // candidates object, not a post-await local, owns tokens.186                        duressDeferred.await()187                        slotADeferred.await()188                    }189                }190191                when (val duressUnlock = candidates.takeDuress()) {192                    // Duress takes precedence: the duress passphrase never reaches193                    // the real vault.  Taking it transfers ownership to this branch.194                    null -> when (val slotAUnlock = candidates.takeSlotA()) {195                        null -> _state.update {196                            it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)197                        }198                        else -> try {199                            when (val result = vault.commitSlotAUnlock(slotAUnlock)) {200                                is UnlockResult.Success -> {201                                    lockTimer.start()202                                    vault.clearUiGate()203                                    foregroundSync.triggerForegroundSync()204                                    _state.update { it.copy(loading = false, unlocked = true) }205                                }206                                is UnlockResult.Error -> _state.update {207                                    it.copy(loading = false, error = result.reason, shakeNonce = it.shakeNonce + 1)208                                }209                                is UnlockResult.WrongPassword -> _state.update {210                                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)211                                }212                            }213                        } finally {214                            // If cancellation happens while commit waits on the215                            // repository mutex, this transferred token still owns216                            // its key until this finally discards it.217                            vault.discardSlotAUnlock(slotAUnlock)218                        }219                    }220                    else -> {221                        candidates.takeSlotA()?.let(vault::discardSlotAUnlock)222                        try {223                            handleDuress(duressUnlock)224                        } finally {225                            vault.discardDuressUnlock(duressUnlock)226                        }227                    }228                }229            } finally {230                // coroutineScope has joined all children before this closes the231                // owner, covering cancellation at either await and cancellation232                // while the winner waits on the repository mutex.233                candidates.close()234            }235        }236    }237238    /**239     * Carry out the configured duress action against an already-derived slot-B unlock.240     * Both paths look like an ordinary successful (decoy) or fresh-install (destroy)241     * unlock to a coercer — no error, no hint that a duress passphrase was used.242     */243    private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {244        when (duress.action()) {245            DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {246                is UnlockResult.Success -> { lockTimer.start(); vault.clearUiGate(); _state.update { it.copy(loading = false, unlocked = true) } }247                else -> _state.update {248                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)249                }250            }251            DuressAction.DESTROY -> {252                vault.discardDuressUnlock(duressUnlock)253                vault.destroyVault()254                syncFreshStart.reset()255                duress.clear()256                _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }257            }258        }259    }260261    fun create(password: String, confirm: String) {262        if (_state.value.loading) return263        createValidationError(password, confirm, scorePassword(password))?.let { msg ->264            return _state.update { it.copy(error = msg) }265        }266        _state.update { it.copy(loading = true, error = null) }267        viewModelScope.launch {268            when (val r = vault.createVault(password)) {269                is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }270                is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }271            }272        }273    }274275    fun clearError() = _state.update { it.copy(error = null) }276277    /**278     * True when an existing vault can be unlocked by biometrics: strong biometrics279     * available AND a key was escrowed on enable. (A wrapped key existing implies280     * the user enabled biometrics — no need to also read the locked settings blob.)281     */282    fun canTryBiometric(): Boolean =283        vault.hasVault() &&284            Biometrics.strongAvailable(appContext) &&285            BiometricKeyStore.hasWrappedKey(appContext)286287    /**288     * Run the biometric unlock: prompt with the DECRYPT cipher, unwrap the escrowed289     * vault key, and unlock with it. Failure/cancel is silent so the password input290     * remains usable.291     */292    fun biometricUnlock(activity: FragmentActivity) {293        if (_state.value.loading) return294        // Sync-retained mode: vault is warm, just confirm identity.295        if (vault.isUnlocked.value) {296            Biometrics.authenticate(297                activity = activity,298                title = "Unlock Peach",299                subtitle = "Confirm your identity to access your vault",300                onSuccess = {301                    vault.clearUiGate()302                    lockTimer.poke()303                    foregroundSync.triggerForegroundSync()304                    _state.update { it.copy(unlocked = true) }305                },306            )307            return308        }309        val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return310        Biometrics.authenticateCrypto(311            activity = activity,312            title = "Unlock Peach",313            subtitle = "Use your biometric to unlock your vault",314            crypto = BiometricPrompt.CryptoObject(cipher),315            onSuccess = { result ->316                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto317                viewModelScope.launch {318                    // Allocate/unseal only inside the launched ownership scope:319                    // if viewModelScope is cancelled before execution, no key is320                    // created that could be stranded before finally runs.321                    var key: ByteArray? = null322                    val r = try {323                        key = BiometricKeyStore.unwrap(appContext, used)324                        if (key == null) return@launch325                        vault.unlockWithKey(key!!)326                    } finally {327                        key?.let(VaultCrypto::zero)328                    }329                    when (r) {330                        is UnlockResult.Success -> {331                            lockTimer.start()332                            vault.clearUiGate()333                            foregroundSync.triggerForegroundSync()334                            _state.update { it.copy(unlocked = true) }335                        }336                        else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }337                    }338                }339            },340        )341    }342}