LockViewModel.kt

15.1 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.SyncFreshStartResetter10import 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    private val syncFreshStart: SyncFreshStartResetter,92    @ApplicationContext private val appContext: Context,93) : ViewModel() {94    private val _state = MutableStateFlow(LockUiState(hasVault = vault.hasVault()))95    val state: StateFlow<LockUiState> = _state.asStateFlow()9697    /** Resolve where a just-unlocked session should land (last-viewed entry → first → list). */98    fun postUnlockDestination(): PeachNav =99        postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())100101    /**102     * Owns candidate unlock tokens for the whole concurrent-derivation scope.103     *104     * Argon2id is deliberately not cancellable.  A token can therefore be105     * produced after the caller has been cancelled unless ownership is claimed106     * by the child immediately, before that child completes.  The scope closes107     * only after [coroutineScope] has joined both children, so every registered108     * loser is discarded even when cancellation happens between either await.109     */110    private class UnlockCandidates(private val vault: VaultRepository) : AutoCloseable {111        private val lock = Any()112        private var closed = false113        private var slotA: VaultRepository.SlotAUnlock? = null114        private var duress: VaultRepository.DuressUnlock? = null115116        fun registerSlotA(candidate: VaultRepository.SlotAUnlock) {117            val discard = synchronized(lock) {118                if (closed) true else {119                    slotA?.let(vault::discardSlotAUnlock)120                    slotA = candidate121                    false122                }123            }124            if (discard) vault.discardSlotAUnlock(candidate)125        }126127        fun registerDuress(candidate: VaultRepository.DuressUnlock) {128            val discard = synchronized(lock) {129                if (closed) true else {130                    duress?.let(vault::discardDuressUnlock)131                    duress = candidate132                    false133                }134            }135            if (discard) vault.discardDuressUnlock(candidate)136        }137138        fun takeSlotA(): VaultRepository.SlotAUnlock? = synchronized(lock) {139            slotA.also { slotA = null }140        }141142        fun takeDuress(): VaultRepository.DuressUnlock? = synchronized(lock) {143            duress.also { duress = null }144        }145146        override fun close() {147            val candidates = synchronized(lock) {148                if (closed) return149                closed = true150                val result = slotA to duress151                slotA = null152                duress = null153                result154            }155            candidates.first?.let(vault::discardSlotAUnlock)156            candidates.second?.let(vault::discardDuressUnlock)157        }158    }159160    fun unlock(password: String) {161        if (password.isBlank() || _state.value.loading) return162        _state.update { it.copy(loading = true, error = null) }163        viewModelScope.launch {164            val candidates = UnlockCandidates(vault)165            try {166                // Derive slot A and the duress slot (slot B) CONCURRENTLY so the167                // wall-clock time to reject/accept never reveals which password was168                // entered.  Each child registers its token before it completes.169                withContext(Dispatchers.Default) {170                    kotlinx.coroutines.coroutineScope {171                        val duressDeferred = async {172                            if (duress.isConfigured()) {173                                vault.tryUnlockDuress(password)?.also(candidates::registerDuress)174                            } else null175                        }176                        val slotADeferred = async {177                            vault.tryUnlockSlotA(password)?.also(candidates::registerSlotA)178                        }179                        // Await both children before choosing a winner.  The180                        // candidates object, not a post-await local, owns tokens.181                        duressDeferred.await()182                        slotADeferred.await()183                    }184                }185186                when (val duressUnlock = candidates.takeDuress()) {187                    // Duress takes precedence: the duress passphrase never reaches188                    // the real vault.  Taking it transfers ownership to this branch.189                    null -> when (val slotAUnlock = candidates.takeSlotA()) {190                        null -> _state.update {191                            it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)192                        }193                        else -> try {194                            when (val result = vault.commitSlotAUnlock(slotAUnlock)) {195                                is UnlockResult.Success -> {196                                    lockTimer.start()197                                    vault.clearUiGate()198                                    foregroundSync.triggerForegroundSync()199                                    _state.update { it.copy(loading = false, unlocked = true) }200                                }201                                is UnlockResult.Error -> _state.update {202                                    it.copy(loading = false, error = result.reason, shakeNonce = it.shakeNonce + 1)203                                }204                                is UnlockResult.WrongPassword -> _state.update {205                                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)206                                }207                            }208                        } finally {209                            // If cancellation happens while commit waits on the210                            // repository mutex, this transferred token still owns211                            // its key until this finally discards it.212                            vault.discardSlotAUnlock(slotAUnlock)213                        }214                    }215                    else -> {216                        candidates.takeSlotA()?.let(vault::discardSlotAUnlock)217                        try {218                            handleDuress(duressUnlock)219                        } finally {220                            vault.discardDuressUnlock(duressUnlock)221                        }222                    }223                }224            } finally {225                // coroutineScope has joined all children before this closes the226                // owner, covering cancellation at either await and cancellation227                // while the winner waits on the repository mutex.228                candidates.close()229            }230        }231    }232233    /**234     * Carry out the configured duress action against an already-derived slot-B unlock.235     * Both paths look like an ordinary successful (decoy) or fresh-install (destroy)236     * unlock to a coercer — no error, no hint that a duress passphrase was used.237     */238    private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {239        when (duress.action()) {240            DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {241                is UnlockResult.Success -> { lockTimer.start(); vault.clearUiGate(); _state.update { it.copy(loading = false, unlocked = true) } }242                else -> _state.update {243                    it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)244                }245            }246            DuressAction.DESTROY -> {247                vault.discardDuressUnlock(duressUnlock)248                vault.destroyVault()249                syncFreshStart.reset()250                duress.clear()251                _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }252            }253        }254    }255256    fun create(password: String, confirm: String) {257        if (_state.value.loading) return258        createValidationError(password, confirm, scorePassword(password))?.let { msg ->259            return _state.update { it.copy(error = msg) }260        }261        _state.update { it.copy(loading = true, error = null) }262        viewModelScope.launch {263            when (val r = vault.createVault(password)) {264                is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }265                is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }266            }267        }268    }269270    fun clearError() = _state.update { it.copy(error = null) }271272    /**273     * True when an existing vault can be unlocked by biometrics: strong biometrics274     * available AND a key was escrowed on enable. (A wrapped key existing implies275     * the user enabled biometrics — no need to also read the locked settings blob.)276     */277    fun canTryBiometric(): Boolean =278        vault.hasVault() &&279            Biometrics.strongAvailable(appContext) &&280            BiometricKeyStore.hasWrappedKey(appContext)281282    /**283     * Run the biometric unlock: prompt with the DECRYPT cipher, unwrap the escrowed284     * vault key, and unlock with it. Failure/cancel is silent so the password input285     * remains usable.286     */287    fun biometricUnlock(activity: FragmentActivity) {288        if (_state.value.loading) return289        // Sync-retained mode: vault is warm, just confirm identity.290        if (vault.isUnlocked.value) {291            Biometrics.authenticate(292                activity = activity,293                title = "Unlock Peach",294                subtitle = "Confirm your identity to access your vault",295                onSuccess = {296                    vault.clearUiGate()297                    lockTimer.poke()298                    foregroundSync.triggerForegroundSync()299                    _state.update { it.copy(unlocked = true) }300                },301            )302            return303        }304        val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return305        Biometrics.authenticateCrypto(306            activity = activity,307            title = "Unlock Peach",308            subtitle = "Use your biometric to unlock your vault",309            crypto = BiometricPrompt.CryptoObject(cipher),310            onSuccess = { result ->311                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto312                viewModelScope.launch {313                    // Allocate/unseal only inside the launched ownership scope:314                    // if viewModelScope is cancelled before execution, no key is315                    // created that could be stranded before finally runs.316                    var key: ByteArray? = null317                    val r = try {318                        key = BiometricKeyStore.unwrap(appContext, used)319                        if (key == null) return@launch320                        vault.unlockWithKey(key!!)321                    } finally {322                        key?.let(VaultCrypto::zero)323                    }324                    when (r) {325                        is UnlockResult.Success -> {326                            lockTimer.start()327                            vault.clearUiGate()328                            foregroundSync.triggerForegroundSync()329                            _state.update { it.copy(unlocked = true) }330                        }331                        else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }332                    }333                }334            },335        )336    }337}