From 1e7e464e65a5d6310835c48aa2003000ad8b5f7f Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 17 Sep 2025 13:58:12 +0200 Subject: [PATCH] feat: Add dialog --- .../EntrySelectionHelper.kt | 40 ++- .../activity/PasskeyLauncherActivity.kt | 230 +++++++++++++----- .../viewmodel/PasskeyLauncherViewModel.kt | 34 +++ app/src/main/res/values/strings.xml | 7 +- .../com/kunzisoft/keepass/model/AppOrigin.kt | 11 + 5 files changed, 244 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt index 6625420f4..0ba708f83 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt @@ -52,6 +52,28 @@ object EntrySelectionHelper { private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" private const val KEY_REGISTER_INFO = "com.kunzisoft.keepass.extra.REGISTER_INFO" + /** + * Finish the activity by passing the result code and by locking the database if necessary + */ + fun Activity.setActivityResult( + lockDatabase: Boolean = false, + resultCode: Int, + data: Intent? = null, + ) { + when (resultCode) { + Activity.RESULT_OK -> + this.setResult(resultCode, data) + Activity.RESULT_CANCELED -> + this.setResult(resultCode) + } + this.finish() + + if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(this)) { + // Close the database + this.sendBroadcast(Intent(LOCK_ACTION)) + } + } + /** * Utility method to build a registerForActivityResult, * Used recursively, close each activity with return data @@ -63,19 +85,11 @@ object EntrySelectionHelper { return this.registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { - val resultCode = it.resultCode - if (resultCode == Activity.RESULT_OK) { - this.setResult(resultCode, dataTransformation(it.data)) - } - if (resultCode == Activity.RESULT_CANCELED) { - this.setResult(Activity.RESULT_CANCELED) - } - this.finish() - - if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(this)) { - // Close the database - this.sendBroadcast(Intent(LOCK_ACTION)) - } + setActivityResult( + lockDatabase, + it.resultCode, + dataTransformation(it.data) + ) } } 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 f708c4aad..44831e623 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 @@ -27,12 +27,16 @@ import android.os.Bundle import android.util.Log import android.widget.Toast import androidx.activity.result.ActivityResultLauncher +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 @@ -40,8 +44,10 @@ 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 @@ -62,6 +68,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retri 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 @@ -69,6 +76,7 @@ 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 @@ -79,6 +87,7 @@ 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 @@ -87,49 +96,69 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { private var mBackupState: Boolean = false private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher? = - this.buildActivityResultLauncher( - lockDatabase = true, - dataTransformation = { intent -> - // Build a new formatted response from the selection response - val responseIntent = Intent() - 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 + 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 ) - } ?: run { - throw IOException("Usage parameters is null") } - } catch (e: Exception) { - Log.e(TAG, "Unable to create selection response for passkey", e) - showError(e) } - // Return the response - responseIntent + RESULT_CANCELED -> { + setActivityResult( + lockDatabase = passkeyLauncherViewModel.lockDatabase, + resultCode = RESULT_CANCELED + ) + } } - ) + // Remove the launcher register + passkeyLauncherViewModel.isResultLauncherRegistered = false + } private var mPasskeyRegistrationActivityResultLauncher: ActivityResultLauncher? = this.buildActivityResultLauncher( @@ -177,6 +206,24 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { 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) + } + } + } + } + } } private fun cancelRequest() { @@ -194,6 +241,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { * 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() @@ -216,7 +264,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { /** * Display a dialog that asks the user to add an app to the list of privileged apps. */ - private fun showAppPrivilegedDialog(e: PrivilegedAllowLists.PrivilegedException) { + private fun showAppPrivilegedDialog(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)) @@ -224,7 +272,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { .append( getString( R.string.passkeys_privileged_apps_ask_message, - e.temptingApp.toString() + temptingApp.toString() ) ) .append("\n\n") @@ -237,7 +285,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { }) { saveCustomPrivilegedApps( context = application, - privilegedApps = listOf(e.temptingApp) + privilegedApps = listOf(temptingApp) ) launchPasskeyAction(mDatabase) } @@ -251,16 +299,63 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { }.create().show() } + /** + * Display a dialog that asks the user to add an app signature in an existing passkey + */ + private fun showAppSignatureDialog(appOrigin: 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() + ) + ) + .append("\n\n") + .append(getString(R.string.passkeys_missing_signature_app_ask_explanation)) + .toString() + ) + setPositiveButton(android.R.string.ok) { _, _ -> + lifecycleScope.launch(CoroutineExceptionHandler { _, e -> + cancelRequest(e) + }) { + // TODO + setActivityResult( + lockDatabase = passkeyLauncherViewModel.lockDatabase, + resultCode = RESULT_OK + ) + } + } + setNegativeButton(android.R.string.cancel) { _, _ -> + setActivityResult( + lockDatabase = passkeyLauncherViewModel.lockDatabase, + resultCode = RESULT_CANCELED + ) + } + setOnCancelListener { + setActivityResult( + lockDatabase = passkeyLauncherViewModel.lockDatabase, + resultCode = RESULT_CANCELED + ) + } + }.create().show() + } + override fun onDatabaseRetrieved(database: ContextualDatabase?) { super.onDatabaseRetrieved(database) - lifecycleScope.launch(CoroutineExceptionHandler { _, e -> - when (e) { - is PrivilegedAllowLists.PrivilegedException -> showAppPrivilegedDialog(e) - else -> cancelRequest(e) + if (passkeyLauncherViewModel.isResultLauncherRegistered.not()) { + lifecycleScope.launch(CoroutineExceptionHandler { _, e -> + when (e) { + is PrivilegedAllowLists.PrivilegedException -> { + passkeyLauncherViewModel.showAppPrivilegedDialog(e.temptingApp) + } + else -> cancelRequest(e) + } + }) { + launchPasskeyAction(database) } - }) { - launchPasskeyAction(database) } } @@ -278,27 +373,36 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { ?: throw GetCredentialUnknownException("No passkey with nodeId $nodeId found") val result = Intent() - PendingIntentHandler.setGetCredentialResponse( - result, - GetCredentialResponse( - buildPasskeyPublicKeyCredential( - requestOptions = usageParameters.publicKeyCredentialRequestOptions, - clientDataResponse = getVerifiedGETClientDataResponse( - usageParameters = usageParameters, - appOrigin = appOrigin - ), - passkey = passkey, - backupEligibility = mBackupEligibility, - backupState = mBackupState + try { + PendingIntentHandler.setGetCredentialResponse( + result, + GetCredentialResponse( + buildPasskeyPublicKeyCredential( + requestOptions = usageParameters.publicKeyCredentialRequestOptions, + clientDataResponse = getVerifiedGETClientDataResponse( + usageParameters = usageParameters, + appOrigin = appOrigin + ), + passkey = passkey, + backupEligibility = mBackupEligibility, + backupState = mBackupState + ) ) ) - ) - setResult(RESULT_OK, result) - finish() + 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") - setResult(RESULT_CANCELED) - finish() + setActivityResult( + lockDatabase = passkeyLauncherViewModel.lockDatabase, + resultCode = RESULT_CANCELED + ) } } 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 new file mode 100644 index 000000000..60d43af88 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt @@ -0,0 +1,34 @@ +package com.kunzisoft.keepass.credentialprovider.viewmodel + +import androidx.lifecycle.ViewModel +import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp +import com.kunzisoft.keepass.model.AppOrigin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class PasskeyLauncherViewModel: ViewModel() { + + var isResultLauncherRegistered: Boolean = false + val lockDatabase = true + + private val _uiState = MutableStateFlow(UIState.Loading) + val uiState: StateFlow = _uiState + + fun showAppPrivilegedDialog(temptingApp: AndroidPrivilegedApp) { + _uiState.value = UIState.ShowAppPrivilegedDialog(temptingApp) + } + + fun showAppSignatureDialog(temptingApp: AppOrigin) { + _uiState.value = UIState.ShowAppSignatureDialog(temptingApp) + } + + sealed class UIState { + object Loading : UIState() + data class ShowAppPrivilegedDialog( + val temptingApp: AndroidPrivilegedApp + ): UIState() + data class ShowAppSignatureDialog( + val temptingApp: AppOrigin + ): UIState() + } +} \ 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 ab9eb6cc8..9e50e65c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -424,9 +424,12 @@ Passkeys settings Privileged apps Manage browsers in the custom list of privileged apps - WARNING : A privileged app acts as a gateway to retrieve the origin of an authentication. Ensure its legitimacy to avoid security issues. + WARNING: A privileged app acts as a gateway to retrieve the origin of an authentication. Ensure its legitimacy to avoid security issues. App not recognized - %1$s attempts to perform a Passkey action.\n\nWould you like to add it to the list of privileged apps? + %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? 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 4dce325a4..d42b3fbc5 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt @@ -49,6 +49,9 @@ data class AppOrigin( * return the first verified origin or throw an exception if none is found */ fun checkAppOrigin(compare: AppOrigin): String { + if (compare.androidOrigins.isNotEmpty()) { + throw SignatureNotFoundException(this, "Android origin not found") + } return androidOrigins.firstOrNull { androidOrigin -> compare.androidOrigins.any { it.packageName == androidOrigin.packageName @@ -100,6 +103,14 @@ data class AppOrigin( } } +/** + * Exception indicating that no signature is present for the Android origin + */ +class SignatureNotFoundException( + val temptingApp: AppOrigin, + message: String +) : Exception(message) + /** * Represents an Android app origin, the [packageName] is the applicationId of the app * and the [fingerprint] is the