diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index 44831e623..fbc5031f6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -31,168 +31,42 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog -import androidx.credentials.GetCredentialResponse -import androidx.credentials.exceptions.GetCredentialUnknownException -import androidx.credentials.provider.PendingIntentHandler -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity import com.kunzisoft.keepass.activities.GroupActivity import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addTypeMode -import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivityResult import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.credentialprovider.TypeMode import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp -import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters -import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAppOrigin import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addNodeId import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addSearchInfo -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.checkSecurity -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.getVerifiedGETClientDataResponse -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removeAppOrigin -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveAppOrigin -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveNodeId -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveSearchInfo -import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists -import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.saveCustomPrivilegedApps import com.kunzisoft.keepass.credentialprovider.viewmodel.PasskeyLauncherViewModel import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.database.element.node.NodeIdUUID -import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.model.AppOrigin -import com.kunzisoft.keepass.model.Passkey -import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.SearchInfo -import com.kunzisoft.keepass.model.SignatureNotFoundException -import com.kunzisoft.keepass.settings.PreferencesUtil -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch -import java.io.IOException -import java.io.InvalidObjectException import java.util.UUID @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) class PasskeyLauncherActivity : DatabaseModeActivity() { private val passkeyLauncherViewModel: PasskeyLauncherViewModel by viewModels() - private var mUsageParameters: PublicKeyCredentialUsageParameters? = null - private var mCreationParameters: PublicKeyCredentialCreationParameters? = null - private var mPasskey: Passkey? = null - - private var mBackupEligibility: Boolean = true - private var mBackupState: Boolean = false private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher? = this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - val intent = it.data - val resultCode = it.resultCode - // Build a new formatted response from the selection response - val responseIntent = Intent() - when (resultCode) { - RESULT_OK -> { - try { - Log.d(TAG, "Passkey selection result") - if (intent == null) - throw IOException("Intent is null") - val passkey = intent.retrievePasskey() - ?: throw IOException("Passkey is null") - val appOrigin = intent.retrieveAppOrigin() - ?: throw IOException("App origin is null") - intent.removePasskey() - intent.removeAppOrigin() - mUsageParameters?.let { usageParameters -> - // Check verified origin - PendingIntentHandler.setGetCredentialResponse( - responseIntent, - GetCredentialResponse( - buildPasskeyPublicKeyCredential( - requestOptions = usageParameters.publicKeyCredentialRequestOptions, - clientDataResponse = getVerifiedGETClientDataResponse( - usageParameters = usageParameters, - appOrigin = appOrigin - ), - passkey = passkey, - backupEligibility = mBackupEligibility, - backupState = mBackupState - ) - ) - ) - } ?: run { - throw IOException("Usage parameters is null") - } - setActivityResult( - lockDatabase = passkeyLauncherViewModel.lockDatabase, - resultCode = RESULT_OK, - data = responseIntent - ) - } catch (e: SignatureNotFoundException) { - passkeyLauncherViewModel.showAppSignatureDialog(e.temptingApp) - } catch (e: Exception) { - Log.e(TAG, "Unable to create selection response for passkey", e) - showError(e) - setActivityResult( - lockDatabase = passkeyLauncherViewModel.lockDatabase, - resultCode = RESULT_CANCELED - ) - } - } - RESULT_CANCELED -> { - setActivityResult( - lockDatabase = passkeyLauncherViewModel.lockDatabase, - resultCode = RESULT_CANCELED - ) - } - } - // Remove the launcher register - passkeyLauncherViewModel.isResultLauncherRegistered = false + passkeyLauncherViewModel.manageSelectionResult(it) } private var mPasskeyRegistrationActivityResultLauncher: ActivityResultLauncher? = - this.buildActivityResultLauncher( - lockDatabase = true, - dataTransformation = { intent -> - // Build a new formatted response from the creation response - val responseIntent = Intent() - try { - Log.d(TAG, "Passkey registration result") - val passkey = intent?.retrievePasskey() - intent?.removePasskey() - intent?.removeAppOrigin() - // If registered passkey is the same as the one we want to validate, - if (mPasskey == passkey) { - mCreationParameters?.let { - PendingIntentHandler.setCreateCredentialResponse( - intent = responseIntent, - response = buildCreatePublicKeyCredentialResponse( - publicKeyCredentialCreationParameters = it, - backupEligibility = mBackupEligibility, - backupState = mBackupState - ) - ) - } - } else { - throw SecurityException("Passkey was modified before registration") - } - } catch (e: Exception) { - Log.e(TAG, "Unable to create registration response for passkey", e) - showError(e) - } - responseIntent - } - ) + this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + passkeyLauncherViewModel.manageRegistrationResult(it) + } override fun applyCustomStyle(): Boolean { return false @@ -204,75 +78,95 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - mBackupEligibility = PreferencesUtil.isPasskeyBackupEligibilityEnable(applicationContext) - mBackupState = PreferencesUtil.isPasskeyBackupStateEnable(applicationContext) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - passkeyLauncherViewModel.uiState.collect { uiState -> - when (uiState) { - is PasskeyLauncherViewModel.UIState.Loading -> { - // Nothing to do - } - is PasskeyLauncherViewModel.UIState.ShowAppPrivilegedDialog -> { - showAppPrivilegedDialog(uiState.temptingApp) - } - is PasskeyLauncherViewModel.UIState.ShowAppSignatureDialog -> { - showAppSignatureDialog( uiState.temptingApp) - } + // Initialize the parameters + passkeyLauncherViewModel.initialize() + // Retrieve the UI + passkeyLauncherViewModel.uiState.collect { uiState -> + when (uiState) { + is PasskeyLauncherViewModel.UIState.Loading -> { + // Nothing to do + } + is PasskeyLauncherViewModel.UIState.ShowAppPrivilegedDialog -> { + showAppPrivilegedDialog( + database = uiState.database, + temptingApp = uiState.temptingApp + ) + } + is PasskeyLauncherViewModel.UIState.ShowAppSignatureDialog -> { + showAppSignatureDialog( + database = uiState.database, + temptingApp = uiState.temptingApp + ) + } + is PasskeyLauncherViewModel.UIState.SetActivityResult -> { + setActivityResult( + lockDatabase = uiState.lockDatabase, + resultCode = uiState.resultCode, + data = uiState.data + ) + } + is PasskeyLauncherViewModel.UIState.ShowError -> { + showError(uiState.error) + } + is PasskeyLauncherViewModel.UIState.LaunchGroupActivityForSelection -> { + GroupActivity.launchForPasskeySelectionResult( + context = this@PasskeyLauncherActivity, + database = uiState.database, + activityResultLauncher = mPasskeySelectionActivityResultLauncher, + searchInfo = null, + autoSearch = false + ) + } + is PasskeyLauncherViewModel.UIState.LaunchGroupActivityForRegistration -> { + GroupActivity.launchForRegistration( + context = this@PasskeyLauncherActivity, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + database = uiState.database, + registerInfo = uiState.registerInfo, + typeMode = uiState.typeMode + ) + } + is PasskeyLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForSelection -> { + FileDatabaseSelectActivity.launchForPasskeySelectionResult( + activity = this@PasskeyLauncherActivity, + activityResultLauncher = mPasskeySelectionActivityResultLauncher, + searchInfo = uiState.searchInfo, + ) + } + is PasskeyLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForRegistration -> { + FileDatabaseSelectActivity.launchForRegistration( + context = this@PasskeyLauncherActivity, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + registerInfo = uiState.registerInfo, + typeMode = uiState.typeMode + ) } } } } } - private fun cancelRequest() { - setResult(RESULT_CANCELED) - finish() - } - - private fun cancelRequest(e: Throwable) { - Log.e(TAG, "Passkey launch error", e) - showError(e) - cancelRequest() - } - - /** - * Launch the main action to manage Passkey - */ - private suspend fun launchPasskeyAction(database: ContextualDatabase?) { - passkeyLauncherViewModel.isResultLauncherRegistered = true - val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo() - val appOrigin = intent.retrieveAppOrigin() ?: AppOrigin(verified = false) - val nodeId = intent.retrieveNodeId() - checkSecurity(intent, nodeId) - when (mSpecialMode) { - SpecialMode.SELECTION -> { - launchSelection(database, nodeId, searchInfo, appOrigin) - } - SpecialMode.REGISTRATION -> { - // TODO Registration in predefined group - // launchRegistration(database, nodeId, mSearchInfo) - launchRegistration(database, null, searchInfo) - } - else -> { - throw InvalidObjectException("Passkey launch mode not supported") - } - } + override fun onDatabaseRetrieved(database: ContextualDatabase?) { + super.onDatabaseRetrieved(database) + passkeyLauncherViewModel.onDatabaseRetrieved(intent, mSpecialMode, database) } /** * Display a dialog that asks the user to add an app to the list of privileged apps. */ - private fun showAppPrivilegedDialog(temptingApp: AndroidPrivilegedApp) { + private fun showAppPrivilegedDialog( + database: ContextualDatabase, + temptingApp: AndroidPrivilegedApp + ) { Log.w(javaClass.simpleName, "No privileged apps file found") AlertDialog.Builder(this@PasskeyLauncherActivity).apply { setTitle(getString(R.string.passkeys_privileged_apps_ask_title)) setMessage(StringBuilder() .append( getString( - R.string.passkeys_privileged_apps_ask_message, - temptingApp.toString() + R.string.passkeys_privileged_apps_ask_message, + temptingApp.toString() ) ) .append("\n\n") @@ -280,21 +174,18 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { .toString() ) setPositiveButton(android.R.string.ok) { _, _ -> - lifecycleScope.launch(CoroutineExceptionHandler { _, e -> - cancelRequest(e) - }) { - saveCustomPrivilegedApps( - context = application, - privilegedApps = listOf(temptingApp) - ) - launchPasskeyAction(mDatabase) - } + passkeyLauncherViewModel.saveCustomPrivilegedApp( + intent = intent, + specialMode = mSpecialMode, + database = database, + temptingApp = temptingApp + ) } setNegativeButton(android.R.string.cancel) { _, _ -> - cancelRequest() + passkeyLauncherViewModel.cancelResult() } setOnCancelListener { - cancelRequest() + passkeyLauncherViewModel.cancelResult() } }.create().show() } @@ -302,14 +193,17 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { /** * Display a dialog that asks the user to add an app signature in an existing passkey */ - private fun showAppSignatureDialog(appOrigin: AppOrigin) { + private fun showAppSignatureDialog( + database: ContextualDatabase, + temptingApp: AppOrigin + ) { AlertDialog.Builder(this@PasskeyLauncherActivity).apply { setTitle(getString(R.string.passkeys_missing_signature_app_ask_title)) setMessage(StringBuilder() .append( getString( R.string.passkeys_missing_signature_app_ask_message, - appOrigin.toName() + temptingApp.toName() ) ) .append("\n\n") @@ -317,230 +211,24 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { .toString() ) setPositiveButton(android.R.string.ok) { _, _ -> - lifecycleScope.launch(CoroutineExceptionHandler { _, e -> - cancelRequest(e) - }) { - // TODO - setActivityResult( - lockDatabase = passkeyLauncherViewModel.lockDatabase, - resultCode = RESULT_OK - ) - } + passkeyLauncherViewModel.saveAppSignature( + intent = intent, + specialMode = mSpecialMode, + database = database, + temptingApp = temptingApp + ) } setNegativeButton(android.R.string.cancel) { _, _ -> - setActivityResult( - lockDatabase = passkeyLauncherViewModel.lockDatabase, - resultCode = RESULT_CANCELED - ) + passkeyLauncherViewModel.cancelResult() } setOnCancelListener { - setActivityResult( - lockDatabase = passkeyLauncherViewModel.lockDatabase, - resultCode = RESULT_CANCELED - ) + passkeyLauncherViewModel.cancelResult() } }.create().show() } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - - if (passkeyLauncherViewModel.isResultLauncherRegistered.not()) { - lifecycleScope.launch(CoroutineExceptionHandler { _, e -> - when (e) { - is PrivilegedAllowLists.PrivilegedException -> { - passkeyLauncherViewModel.showAppPrivilegedDialog(e.temptingApp) - } - else -> cancelRequest(e) - } - }) { - launchPasskeyAction(database) - } - } - } - - private fun autoSelectPasskeyAndSetResult( - database: ContextualDatabase?, - nodeId: UUID, - appOrigin: AppOrigin - ) { - mUsageParameters?.let { usageParameters -> - // To get the passkey from the database - val passkey = database - ?.getEntryById(NodeIdUUID(nodeId)) - ?.getEntryInfo(database) - ?.passkey - ?: throw GetCredentialUnknownException("No passkey with nodeId $nodeId found") - - val result = Intent() - try { - PendingIntentHandler.setGetCredentialResponse( - result, - GetCredentialResponse( - buildPasskeyPublicKeyCredential( - requestOptions = usageParameters.publicKeyCredentialRequestOptions, - clientDataResponse = getVerifiedGETClientDataResponse( - usageParameters = usageParameters, - appOrigin = appOrigin - ), - passkey = passkey, - backupEligibility = mBackupEligibility, - backupState = mBackupState - ) - ) - ) - setActivityResult( - lockDatabase = passkeyLauncherViewModel.lockDatabase, - resultCode = RESULT_OK, - data = result - ) - } catch (e: SignatureNotFoundException) { - passkeyLauncherViewModel.showAppSignatureDialog(e.temptingApp) - } - } ?: run { - Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") - setActivityResult( - lockDatabase = passkeyLauncherViewModel.lockDatabase, - resultCode = RESULT_CANCELED - ) - } - } - - private suspend fun launchSelection( - database: ContextualDatabase?, - nodeId: UUID?, - searchInfo: SearchInfo, - appOrigin: AppOrigin - ) { - Log.d(TAG, "Launch passkey selection") - retrievePasskeyUsageRequestParameters( - intent = intent, - context = applicationContext - ) { usageParameters -> - // Save the requested parameters - mUsageParameters = usageParameters - // Manage the passkey to use - nodeId?.let { nodeId -> - autoSelectPasskeyAndSetResult(database, nodeId, appOrigin) - } ?: run { - SearchHelper.checkAutoSearchInfo( - context = this, - database = database, - searchInfo = searchInfo, - onItemsFound = { _, _ -> - Log.w( - TAG, "Passkey found for auto selection, should not append," + - "use PasskeyProviderService instead" - ) - finish() - }, - onItemNotFound = { openedDatabase -> - Log.d( - TAG, "No Passkey found for selection," + - "launch manual selection in opened database" - ) - GroupActivity.launchForPasskeySelectionResult( - context = this, - database = openedDatabase, - activityResultLauncher = mPasskeySelectionActivityResultLauncher, - searchInfo = null, - autoSearch = false - ) - }, - onDatabaseClosed = { - Log.d(TAG, "Manual passkey selection in closed database") - FileDatabaseSelectActivity.launchForPasskeySelectionResult( - activity = this, - activityResultLauncher = mPasskeySelectionActivityResultLauncher, - searchInfo = searchInfo, - ) - } - ) - } - } - } - - private fun autoRegisterPasskeyAndSetResult( - database: ContextualDatabase?, - nodeId: UUID, - passkey: Passkey - ) { - // TODO Overwrite and Register in a predefined group - mCreationParameters?.let { creationParameters -> - // To set the passkey to the database - setResult(RESULT_OK) - finish() - } ?: run { - Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") - setResult(RESULT_CANCELED) - finish() - } - } - - private suspend fun launchRegistration( - database: ContextualDatabase?, - nodeId: UUID?, - searchInfo: SearchInfo - ) { - Log.d(TAG, "Launch passkey registration") - retrievePasskeyCreationRequestParameters( - intent = intent, - context = applicationContext, - passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters -> - // Save the requested parameters - mPasskey = passkey - mCreationParameters = publicKeyCredentialParameters - // Manage the passkey and create a register info - val registerInfo = RegisterInfo( - searchInfo = searchInfo, - passkey = passkey, - appOrigin = appInfoToStore - ) - // If nodeId already provided - nodeId?.let { nodeId -> - autoRegisterPasskeyAndSetResult(database, nodeId, passkey) - } ?: run { - SearchHelper.checkAutoSearchInfo( - context = this, - database = database, - searchInfo = searchInfo, - onItemsFound = { openedDatabase, _ -> - Log.w(TAG, "Passkey found for registration, " + - "but launch manual registration for a new entry") - GroupActivity.launchForRegistration( - context = this, - activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, - database = openedDatabase, - registerInfo = registerInfo, - typeMode = TypeMode.PASSKEY - ) - }, - onItemNotFound = { openedDatabase -> - Log.d(TAG, "Launch new manual registration in opened database") - GroupActivity.launchForRegistration( - context = this, - activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, - database = openedDatabase, - registerInfo = registerInfo, - typeMode = TypeMode.PASSKEY - ) - }, - onDatabaseClosed = { - Log.d(TAG, "Manual passkey registration in closed database") - FileDatabaseSelectActivity.launchForRegistration( - context = this, - activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, - registerInfo = registerInfo, - typeMode = TypeMode.PASSKEY - ) - } - ) - } - } - ) - } - private fun showError(e: Throwable) { + Log.e(TAG, "Passkey launch error", e) Toast.makeText(this, e.localizedMessage, Toast.LENGTH_LONG).show() } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt index 60d43af88..9ea28a923 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt @@ -1,34 +1,512 @@ package com.kunzisoft.keepass.credentialprovider.viewmodel -import androidx.lifecycle.ViewModel +import android.app.Activity.RESULT_CANCELED +import android.app.Activity.RESULT_OK +import android.app.Application +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.activity.result.ActivityResult +import androidx.annotation.RequiresApi +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.PendingIntentHandler +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.checkSecurity +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.getVerifiedGETClientDataResponse +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removeAppOrigin +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveAppOrigin +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveNodeId +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveSearchInfo +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.saveCustomPrivilegedApps +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.element.node.NodeIdUUID +import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.model.AppOrigin +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.model.RegisterInfo +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.model.SignatureNotFoundException +import com.kunzisoft.keepass.settings.PreferencesUtil +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException +import java.io.InvalidObjectException +import java.util.UUID -class PasskeyLauncherViewModel: ViewModel() { +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeyLauncherViewModel(application: Application): AndroidViewModel(application) { - var isResultLauncherRegistered: Boolean = false - val lockDatabase = true + private var mUsageParameters: PublicKeyCredentialUsageParameters? = null + private var mCreationParameters: PublicKeyCredentialCreationParameters? = null + private var mPasskey: Passkey? = null + + private var mBackupEligibility: Boolean = true + private var mBackupState: Boolean = false + private var mLockDatabase: Boolean = true + + private var isResultLauncherRegistered: Boolean = false + private var currentDatabase: ContextualDatabase? = null private val _uiState = MutableStateFlow(UIState.Loading) val uiState: StateFlow = _uiState - fun showAppPrivilegedDialog(temptingApp: AndroidPrivilegedApp) { - _uiState.value = UIState.ShowAppPrivilegedDialog(temptingApp) + fun initialize() { + mBackupEligibility = PreferencesUtil.isPasskeyBackupEligibilityEnable(getApplication()) + mBackupState = PreferencesUtil.isPasskeyBackupStateEnable(getApplication()) } - fun showAppSignatureDialog(temptingApp: AppOrigin) { - _uiState.value = UIState.ShowAppSignatureDialog(temptingApp) + fun showAppPrivilegedDialog( + database: ContextualDatabase, + temptingApp: AndroidPrivilegedApp + ) { + _uiState.value = UIState.ShowAppPrivilegedDialog(database, temptingApp) + } + + fun showAppSignatureDialog( + database: ContextualDatabase, + temptingApp: AppOrigin + ) { + _uiState.value = UIState.ShowAppSignatureDialog(database, temptingApp) + } + + fun showError(error: Throwable) { + _uiState.value = UIState.ShowError(error) + } + + fun saveCustomPrivilegedApp( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase, + temptingApp: AndroidPrivilegedApp + ) { + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + cancelResult() + }) { + saveCustomPrivilegedApps( + context = getApplication(), + privilegedApps = listOf(temptingApp) + ) + launchPasskeyAction( + intent = intent, + specialMode = specialMode, + database = database + ) + } + } + + fun saveAppSignature( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase, + temptingApp: AppOrigin + ) { + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + cancelResult() + }) { + // TODO Save app signature + } + } + + fun setResult(intent: Intent) { + currentDatabase = null + _uiState.value = UIState.SetActivityResult( + lockDatabase = mLockDatabase, + resultCode = RESULT_OK, + data = intent + ) + } + + fun cancelResult() { + currentDatabase = null + _uiState.value = UIState.SetActivityResult( + lockDatabase = mLockDatabase, + resultCode = RESULT_CANCELED + ) + } + + fun onDatabaseRetrieved( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase? + ) { + currentDatabase = database + if (isResultLauncherRegistered.not()) { + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + if (e is PrivilegedAllowLists.PrivilegedException && database != null) { + showAppPrivilegedDialog(database, e.temptingApp) + } else { + showError(e) + cancelResult() + } + }) { + launchPasskeyAction(intent, specialMode, database) + } + } + } + + /** + * Launch the main action to manage Passkey + */ + private suspend fun launchPasskeyAction( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase? + ) { + isResultLauncherRegistered = true + val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo() + val appOrigin = intent.retrieveAppOrigin() ?: AppOrigin(verified = false) + val nodeId = intent.retrieveNodeId() + checkSecurity(intent, nodeId) + when (specialMode) { + SpecialMode.SELECTION -> { + launchSelection( + intent = intent, + database = database, + nodeId = nodeId, + searchInfo = searchInfo, + appOrigin = appOrigin + ) + } + SpecialMode.REGISTRATION -> { + // TODO Registration in predefined group + // launchRegistration(database, nodeId, mSearchInfo) + launchRegistration( + intent = intent, + database = database, + nodeId = null, + searchInfo = searchInfo + ) + } + else -> { + throw InvalidObjectException("Passkey launch mode not supported") + } + } + } + + // ------------- + // Selection + // ------------- + + private suspend fun launchSelection( + intent: Intent, + database: ContextualDatabase?, + nodeId: UUID?, + searchInfo: SearchInfo, + appOrigin: AppOrigin + ) { + withContext(Dispatchers.IO) { + Log.d(TAG, "Launch passkey selection") + retrievePasskeyUsageRequestParameters( + intent = intent, + context = getApplication() + ) { usageParameters -> + // Save the requested parameters + mUsageParameters = usageParameters + // Manage the passkey to use + nodeId?.let { nodeId -> + autoSelectPasskeyAndSetResult(database, nodeId, appOrigin) + } ?: run { + SearchHelper.checkAutoSearchInfo( + context = getApplication(), + database = database, + searchInfo = searchInfo, + onItemsFound = { _, _ -> + Log.w( + TAG, "Passkey found for auto selection, should not append," + + "use PasskeyProviderService instead" + ) + cancelResult() + }, + onItemNotFound = { openedDatabase -> + Log.d( + TAG, "No Passkey found for selection," + + "launch manual selection in opened database" + ) + _uiState.value = UIState.LaunchGroupActivityForSelection( + database = openedDatabase + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey selection in closed database") + _uiState.value = + UIState.LaunchFileDatabaseSelectActivityForSelection( + searchInfo = searchInfo + ) + } + ) + } + } + } + } + + private fun autoSelectPasskeyAndSetResult( + database: ContextualDatabase?, + nodeId: UUID, + appOrigin: AppOrigin + ) { + mUsageParameters?.let { usageParameters -> + // To get the passkey from the database + val passkey = database + ?.getEntryById(NodeIdUUID(nodeId)) + ?.getEntryInfo(database) + ?.passkey + ?: throw GetCredentialUnknownException( + "No passkey with nodeId $nodeId found" + ) + // Build the response + val result = Intent() + try { + PendingIntentHandler.setGetCredentialResponse( + result, + GetCredentialResponse( + buildPasskeyPublicKeyCredential( + requestOptions = usageParameters.publicKeyCredentialRequestOptions, + clientDataResponse = getVerifiedGETClientDataResponse( + usageParameters = usageParameters, + appOrigin = appOrigin + ), + passkey = passkey, + backupEligibility = mBackupEligibility, + backupState = mBackupState + ) + ) + ) + setResult(result) + } catch (e: SignatureNotFoundException) { + // Request the dialog if signature exception + showAppSignatureDialog(database, e.temptingApp) + } + } ?: throw IOException("Usage parameters is null") + } + + fun manageSelectionResult( + activityResult: ActivityResult + ) { + val intent = activityResult.data + // Build a new formatted response from the selection response + val responseIntent = Intent() + when (activityResult.resultCode) { + RESULT_OK -> { + try { + Log.d(TAG, "Passkey selection result") + if (intent == null) + throw IOException("Intent is null") + val passkey = intent.retrievePasskey() + ?: throw IOException("Passkey is null") + val appOrigin = intent.retrieveAppOrigin() + ?: throw IOException("App origin is null") + intent.removePasskey() + intent.removeAppOrigin() + mUsageParameters?.let { usageParameters -> + // Check verified origin + PendingIntentHandler.setGetCredentialResponse( + responseIntent, + GetCredentialResponse( + buildPasskeyPublicKeyCredential( + requestOptions = usageParameters.publicKeyCredentialRequestOptions, + clientDataResponse = getVerifiedGETClientDataResponse( + usageParameters = usageParameters, + appOrigin = appOrigin + ), + passkey = passkey, + backupEligibility = mBackupEligibility, + backupState = mBackupState + ) + ) + ) + } ?: run { + throw IOException("Usage parameters is null") + } + setResult(responseIntent) + } catch (e: SignatureNotFoundException) { + currentDatabase?.let { + showAppSignatureDialog(it, e.temptingApp) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to create selection response for passkey", e) + showError(e) + cancelResult() + } + } + RESULT_CANCELED -> { + cancelResult() + } + } + // Remove the launcher register + isResultLauncherRegistered = false + } + + // ------------- + // Registation + // ------------- + + private suspend fun launchRegistration( + intent: Intent, + database: ContextualDatabase?, + nodeId: UUID?, + searchInfo: SearchInfo + ) { + withContext(Dispatchers.IO) { + Log.d(TAG, "Launch passkey registration") + retrievePasskeyCreationRequestParameters( + intent = intent, + context = getApplication(), + passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters -> + // Save the requested parameters + mPasskey = passkey + mCreationParameters = publicKeyCredentialParameters + // Manage the passkey and create a register info + val registerInfo = RegisterInfo( + searchInfo = searchInfo, + passkey = passkey, + appOrigin = appInfoToStore + ) + // If nodeId already provided + nodeId?.let { nodeId -> + autoRegisterPasskeyAndSetResult(database, nodeId, passkey) + } ?: run { + SearchHelper.checkAutoSearchInfo( + context = getApplication(), + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, _ -> + Log.w( + TAG, "Passkey found for registration, " + + "but launch manual registration for a new entry" + ) + _uiState.value = UIState.LaunchGroupActivityForRegistration( + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onItemNotFound = { openedDatabase -> + Log.d(TAG, "Launch new manual registration in opened database") + _uiState.value = UIState.LaunchGroupActivityForRegistration( + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey registration in closed database") + _uiState.value = + UIState.LaunchFileDatabaseSelectActivityForRegistration( + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + } + ) + } + } + ) + } + } + + private fun autoRegisterPasskeyAndSetResult( + database: ContextualDatabase?, + nodeId: UUID, + passkey: Passkey + ) { + // TODO Overwrite and Register in a predefined group + mCreationParameters?.let { creationParameters -> + // To set the passkey to the database + setResult(Intent()) + } ?: run { + Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") + cancelResult() + } + } + + fun manageRegistrationResult(activityResult: ActivityResult) { + val intent = activityResult.data + // Build a new formatted response from the creation response + val responseIntent = Intent() + try { + Log.d(TAG, "Passkey registration result") + val passkey = intent?.retrievePasskey() + intent?.removePasskey() + intent?.removeAppOrigin() + // If registered passkey is the same as the one we want to validate, + if (mPasskey == passkey) { + mCreationParameters?.let { + PendingIntentHandler.setCreateCredentialResponse( + intent = responseIntent, + response = buildCreatePublicKeyCredentialResponse( + publicKeyCredentialCreationParameters = it, + backupEligibility = mBackupEligibility, + backupState = mBackupState + ) + ) + } + } else { + throw SecurityException("Passkey was modified before registration") + } + } catch (e: Exception) { + Log.e(TAG, "Unable to create registration response for passkey", e) + _uiState.value = UIState.ShowError(e) + } + _uiState.value = UIState.SetActivityResult( + lockDatabase = mLockDatabase, + resultCode = activityResult.resultCode, + data = responseIntent + ) } sealed class UIState { object Loading : UIState() data class ShowAppPrivilegedDialog( + val database: ContextualDatabase, val temptingApp: AndroidPrivilegedApp ): UIState() data class ShowAppSignatureDialog( + val database: ContextualDatabase, val temptingApp: AppOrigin ): UIState() + data class LaunchGroupActivityForSelection( + val database: ContextualDatabase + ): UIState() + data class LaunchGroupActivityForRegistration( + val database: ContextualDatabase, + val registerInfo: RegisterInfo, + val typeMode: TypeMode + ): UIState() + data class LaunchFileDatabaseSelectActivityForSelection( + val searchInfo: SearchInfo + ): UIState() + data class LaunchFileDatabaseSelectActivityForRegistration( + val registerInfo: RegisterInfo, + val typeMode: TypeMode + ): UIState() + data class SetActivityResult( + val lockDatabase: Boolean, + val resultCode: Int, + val data: Intent? = null + ): UIState() + data class ShowError( + val error: Throwable + ): UIState() + } + + companion object { + private val TAG = PasskeyLauncherViewModel::class.java.name } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e50e65c1..03758f18a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -429,7 +429,7 @@ %1$s attempts to perform a Passkey action.\n\nAdd it to the list of privileged apps? Signature missing WARNING: The passkey was created from another client or the signature has been deleted. Ensure the app you want to authenticate is part of the same service and is legitimate to avoid security issues. - %1$s with an unknown signature attempts to authenticate with an existing passkey.\n\nAdd app signature to passkey entry? + %1$s is unrecognised and attempts to authenticate with an existing passkey.\n\nAdd app signature to passkey entry? Backup Eligibility Determine at creation time whether the public key credential source is allowed to be backed up Backup State diff --git a/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt b/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt index d42b3fbc5..435cee70b 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt @@ -44,12 +44,19 @@ data class AppOrigin( this.webOrigins.add(webOrigin) } + /** + * Determine whether at least one signature is present in the Android origins + */ + fun containsAndroidOriginSignature(): Boolean { + return androidOrigins.any { !it.fingerprint.isNullOrEmpty() } + } + /** * Verify the app origin by comparing it to the list of android origins, * return the first verified origin or throw an exception if none is found */ fun checkAppOrigin(compare: AppOrigin): String { - if (compare.androidOrigins.isNotEmpty()) { + if (compare.containsAndroidOriginSignature().not()) { throw SignatureNotFoundException(this, "Android origin not found") } return androidOrigins.firstOrNull { androidOrigin ->