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 40 val created: Boolean = false,41 42 val shakeNonce: Int = 0,43 44 val duressDestroyed: Boolean = false,45)4647const val MIN_PASSWORD_LENGTH = 12484950const val MIN_CREATE_STRENGTH = 251525354555657internal 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}636465internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =66 createValidationError(password, confirm, score) == null676869707172737475internal 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 98 fun postUnlockDestination(): PeachNav =99 postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())100101 102103104105106107108109110 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 167 168 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 180 181 duressDeferred.await()182 slotADeferred.await()183 }184 }185186 when (val duressUnlock = candidates.takeDuress()) {187 188 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 210 211 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 226 227 228 candidates.close()229 }230 }231 }232233 234235236237238 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 273274275276277 fun canTryBiometric(): Boolean =278 vault.hasVault() &&279 Biometrics.strongAvailable(appContext) &&280 BiometricKeyStore.hasWrappedKey(appContext)281282 283284285286287 fun biometricUnlock(activity: FragmentActivity) {288 if (_state.value.loading) return289 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 314 315 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}