From c17fba8ef79139e33aab8c1aa98cccba70184d04 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Tue, 25 Nov 2025 17:38:00 +0100 Subject: [PATCH] feat: Add User Verification #2283 --- CHANGELOG | 1 + .../activity/PasskeyLauncherActivity.kt | 65 ++++++- .../passkey/PasskeyProviderService.kt | 163 ++++++++++++------ .../passkey/util/PasskeyHelper.kt | 39 +++++ .../viewmodel/CredentialLauncherViewModel.kt | 3 +- .../viewmodel/PasskeyLauncherViewModel.kt | 20 ++- .../res/drawable/ic_clock_loader_red_24dp.xml | 9 + app/src/main/res/values/strings.xml | 2 + .../metadata/android/en-US/changelogs/150.txt | 1 + .../metadata/android/fr-FR/changelogs/150.txt | 1 + 10 files changed, 249 insertions(+), 55 deletions(-) create mode 100644 app/src/main/res/drawable/ic_clock_loader_red_24dp.xml diff --git a/CHANGELOG b/CHANGELOG index 72b09cafb..538de1c9f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ KeePassDX(4.3.0) * Manual change of app language #1884 #1990 + * Add Passkey User Verification #2283 * Fix autofill username detection #2276 * Fix Passkey in passwordless mode #2282 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 077034060..4cc44281f 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,6 +31,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity @@ -44,8 +46,13 @@ import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivity 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.util.PasskeyHelper.ALLOWED_AUTHENTICATORS 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.addUserVerificationRequired +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.isAuthenticatorsAllowed +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.isUserVerificationRequired +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removeUserVerificationRequired import com.kunzisoft.keepass.credentialprovider.viewmodel.CredentialLauncherViewModel import com.kunzisoft.keepass.credentialprovider.viewmodel.PasskeyLauncherViewModel import com.kunzisoft.keepass.database.ContextualDatabase @@ -54,6 +61,7 @@ import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.utils.AppUtil.randomRequestCode +import com.kunzisoft.keepass.utils.LOCK_ACTION import com.kunzisoft.keepass.view.toastError import kotlinx.coroutines.launch import java.util.UUID @@ -82,7 +90,60 @@ class PasskeyLauncherActivity : DatabaseLockActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // To manage https://github.com/Kunzisoft/KeePassDX/issues/2283 + if (intent.isUserVerificationRequired()) { + if (isAuthenticatorsAllowed().not()) { + intent.removeUserVerificationRequired() + sendBroadcast(Intent(LOCK_ACTION)) + } + } + // super.onCreate must be after UserVerification to allow database lock super.onCreate(savedInstanceState) + // Biometric must be after super.onCreate + if (intent.isUserVerificationRequired()) { + if (isAuthenticatorsAllowed()) { + BiometricPrompt( + this, ContextCompat.getMainExecutor(this), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence + ) { + super.onAuthenticationError(errorCode, errString) + when (errorCode) { + BiometricPrompt.ERROR_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_USER_CANCELED -> { + // No operation + Log.i(TAG, "$errString") + } + else -> { + toastError(SecurityException("Authentication error: $errString")) + } + } + passkeyLauncherViewModel.cancelResult() + } + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + passkeyLauncherViewModel.launchAction(intent, mSpecialMode) + } + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + toastError(SecurityException(getString(R.string.device_unlock_not_recognized))) + passkeyLauncherViewModel.cancelResult() + } + }).authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.user_verification_required)) + .setAllowedAuthenticators(ALLOWED_AUTHENTICATORS) + .setConfirmationRequired(false) + .build() + ) + } + } + lifecycleScope.launch { // Initialize the parameters passkeyLauncherViewModel.initialize() @@ -278,7 +339,8 @@ class PasskeyLauncherActivity : DatabaseLockActivity() { specialMode: SpecialMode, searchInfo: SearchInfo? = null, appOrigin: AppOrigin? = null, - nodeId: UUID? = null + nodeId: UUID? = null, + userVerificationRequired: Boolean = false ): PendingIntent? { return PendingIntent.getActivity( context, @@ -290,6 +352,7 @@ class PasskeyLauncherActivity : DatabaseLockActivity() { addAppOrigin(appOrigin) addNodeId(nodeId) addAuthCode(nodeId) + addUserVerificationRequired(userVerificationRequired) }, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index 2f1d11fc9..22cff0fbb 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -49,6 +49,8 @@ import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.credentialprovider.activity.PasskeyLauncherActivity import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions +import com.kunzisoft.keepass.credentialprovider.passkey.data.UserVerificationRequirement +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.isAuthenticatorsAllowed import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.exception.RegisterInReadOnlyDatabaseException @@ -65,6 +67,7 @@ class PasskeyProviderService : CredentialProviderService() { private var mDatabaseTaskProvider: DatabaseTaskProvider? = null private var mDatabase: ContextualDatabase? = null private lateinit var defaultIcon: Icon + private lateinit var relaunchIcon: Icon private var isAutoSelectAllowed: Boolean = false override fun onCreate() { @@ -83,6 +86,11 @@ class PasskeyProviderService : CredentialProviderService() { setTintBlendMode(BlendMode.DST) } + relaunchIcon = Icon.createWithResource( + this@PasskeyProviderService, + R.drawable.ic_clock_loader_red_24dp + ) + isAutoSelectAllowed = isPasskeyAutoSelectEnable(this) } @@ -149,69 +157,91 @@ class PasskeyProviderService : CredentialProviderService() { val credentialIdList = publicKeyCredentialRequestOptions.allowCredentials .map { b64Encode(it.id) } val searchInfo = buildPasskeySearchInfo(relyingPartyId, credentialIdList) - Log.d(TAG, "Build passkey search for relying party $relyingPartyId, credentialIds $credentialIdList") + val userVerificationRequired = publicKeyCredentialRequestOptions + .userVerification == UserVerificationRequirement.REQUIRED + Log.d(TAG, "Build passkey search for UV $userVerificationRequired, " + + "RP $relyingPartyId and Credential IDs $credentialIdList") SearchHelper.checkAutoSearchInfo( context = this, database = mDatabase, searchInfo = searchInfo, onItemsFound = { database, items -> - Log.d(TAG, "Add pending intent for passkey selection with found items") - for (passkeyEntry in items) { - PasskeyLauncherActivity.getPendingIntent( - context = applicationContext, - specialMode = SpecialMode.SELECTION, - nodeId = passkeyEntry.id, - appOrigin = passkeyEntry.appOrigin - )?.let { usagePendingIntent -> - val passkey = passkeyEntry.passkey - passkeyEntries.add( - PublicKeyCredentialEntry( - context = applicationContext, - username = passkey?.username ?: "Unknown", - icon = passkeyEntry.buildIcon(this@PasskeyProviderService, database)?.apply { - setTintBlendMode(BlendMode.DST) - } ?: defaultIcon, - pendingIntent = usagePendingIntent, - beginGetPublicKeyCredentialOption = option, - displayName = passkeyEntry.getVisualTitle(), - isAutoSelectAllowed = isAutoSelectAllowed + manageUserVerification( + passkeyEntries = passkeyEntries, + searchInfo = searchInfo, + option = option, + userVerificationRequired = userVerificationRequired + ) { + Log.d(TAG, "Add pending intent for passkey selection with found items") + for (passkeyEntry in items) { + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + nodeId = passkeyEntry.id, + appOrigin = passkeyEntry.appOrigin, + userVerificationRequired = userVerificationRequired + )?.let { usagePendingIntent -> + val passkey = passkeyEntry.passkey + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = passkey?.username ?: "Unknown", + icon = passkeyEntry.buildIcon( + this@PasskeyProviderService, + database + )?.apply { + setTintBlendMode(BlendMode.DST) + } ?: defaultIcon, + pendingIntent = usagePendingIntent, + beginGetPublicKeyCredentialOption = option, + displayName = passkeyEntry.getVisualTitle(), + isAutoSelectAllowed = isAutoSelectAllowed + ) ) - ) + } } } callback(passkeyEntries) }, onItemNotFound = { _ -> - Log.w(TAG, "No passkey found in the database with this relying party : $relyingPartyId") - if (credentialIdList.isEmpty()) { - Log.d(TAG, "Add pending intent for passkey selection in opened database") - PasskeyLauncherActivity.getPendingIntent( - context = applicationContext, - specialMode = SpecialMode.SELECTION, - searchInfo = searchInfo - )?.let { pendingIntent -> - passkeyEntries.add( - PublicKeyCredentialEntry( - context = applicationContext, - username = getString(R.string.passkey_database_username), - displayName = getString(R.string.passkey_selection_description), - icon = defaultIcon, - pendingIntent = pendingIntent, - beginGetPublicKeyCredentialOption = option, - lastUsedTime = Instant.now(), - isAutoSelectAllowed = isAutoSelectAllowed + manageUserVerification( + passkeyEntries = passkeyEntries, + searchInfo = searchInfo, + option = option, + userVerificationRequired = userVerificationRequired + ) { + Log.w(TAG, "No passkey found in the database with this relying party : $relyingPartyId") + if (credentialIdList.isEmpty()) { + Log.d(TAG, "Add pending intent for passkey selection in opened database") + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + searchInfo = searchInfo, + userVerificationRequired = userVerificationRequired + )?.let { pendingIntent -> + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = getString(R.string.passkey_database_username), + displayName = getString(R.string.passkey_selection_description), + icon = defaultIcon, + pendingIntent = pendingIntent, + beginGetPublicKeyCredentialOption = option, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = isAutoSelectAllowed + ) + ) + } + callback(passkeyEntries) + } else { + throw IOException( + getString( + R.string.error_passkey_credential_id, + relyingPartyId, + credentialIdList ) ) } - callback(passkeyEntries) - } else { - throw IOException( - getString( - R.string.error_passkey_credential_id, - relyingPartyId, - credentialIdList - ) - ) } }, onDatabaseClosed = { @@ -240,6 +270,41 @@ class PasskeyProviderService : CredentialProviderService() { ) } + /** + * To easily manage user verification condition + */ + private fun manageUserVerification( + passkeyEntries: MutableList, + searchInfo: SearchInfo, + option: BeginGetPublicKeyCredentialOption, + userVerificationRequired: Boolean, + standardAction: () -> Unit + ) { + if (userVerificationRequired && isAuthenticatorsAllowed().not()) { + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + searchInfo = searchInfo, + userVerificationRequired = true + )?.let { pendingIntent -> + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = getString(R.string.passkey_database_username), + displayName = getString(R.string.passkey_relaunch_database_description), + icon = relaunchIcon, + pendingIntent = pendingIntent, + beginGetPublicKeyCredentialOption = option, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = isAutoSelectAllowed + ) + ) + } + } else { + standardAction() + } + } + override fun onBeginCreateCredentialRequest( request: BeginCreateCredentialRequest, cancellationSignal: CancellationSignal, diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt index 0598d40e5..b31d0bcb0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -30,6 +30,10 @@ import android.security.keystore.KeyProperties import android.util.Log import android.widget.Toast import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CreatePublicKeyCredentialResponse import androidx.credentials.GetPublicKeyCredentialOption @@ -91,6 +95,7 @@ object PasskeyHelper { private const val EXTRA_APP_ORIGIN = "com.kunzisoft.keepass.extra.appOrigin" private const val EXTRA_TIMESTAMP = "com.kunzisoft.keepass.extra.timestamp" private const val EXTRA_AUTHENTICATION_CODE = "com.kunzisoft.keepass.extra.authenticationCode" + private const val EXTRA_UV_REQUIRED = "com.kunzisoft.keepass.extra.userVerification" private const val SEPARATOR = "_" @@ -107,6 +112,40 @@ object PasskeyHelper { private val internalSecureRandom: SecureRandom = SecureRandom() + /** + * Add the user verification to the intent + */ + fun Intent.addUserVerificationRequired(userVerification: Boolean) { + putExtra(EXTRA_UV_REQUIRED, userVerification) + } + + /** + * Check if the user verification is required + */ + fun Intent.isUserVerificationRequired(): Boolean { + return getBooleanExtra(EXTRA_UV_REQUIRED, false) + } + + /** + * Remove the user verification from the intent + */ + fun Intent.removeUserVerificationRequired() { + removeExtra(EXTRA_UV_REQUIRED) + } + + /** + * Allowed authenticators for the User Verification + */ + const val ALLOWED_AUTHENTICATORS = BIOMETRIC_WEAK or DEVICE_CREDENTIAL + + /** + * Check if the device supports the biometric prompt for User Verification + */ + fun Context.isAuthenticatorsAllowed(): Boolean { + return BiometricManager.from(this) + .canAuthenticate(ALLOWED_AUTHENTICATORS) == BIOMETRIC_SUCCESS + } + /** * Add an authentication code generated by an entry to the intent */ diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/CredentialLauncherViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/CredentialLauncherViewModel.kt index 01d441831..2ce56a98a 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/CredentialLauncherViewModel.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/CredentialLauncherViewModel.kt @@ -24,7 +24,6 @@ abstract class CredentialLauncherViewModel(application: Application): AndroidVie protected var isResultLauncherRegistered: Boolean = false private var mSelectionResult: ActivityResult? = null - protected val mCredentialUiState = MutableStateFlow(CredentialState.Loading) val credentialUiState: StateFlow = mCredentialUiState @@ -56,7 +55,7 @@ abstract class CredentialLauncherViewModel(application: Application): AndroidVie ) } - private fun onDatabaseRetrieved(database: ContextualDatabase) { + fun onDatabaseRetrieved(database: ContextualDatabase) { mDatabase = database mSelectionResult?.let { selectionResult -> manageSelectionResult(database, selectionResult) 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 2e49c6843..fd8eee838 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 @@ -25,6 +25,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.build 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.isUserVerificationRequired 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 @@ -149,14 +150,27 @@ class PasskeyLauncherViewModel(application: Application): CredentialLauncherView } } + fun launchAction( + intent: Intent, + specialMode: SpecialMode, + ) { + super.launchActionIfNeeded(intent, specialMode, mDatabase) + } + override fun launchActionIfNeeded( intent: Intent, specialMode: SpecialMode, database: ContextualDatabase? ) { - // Launch with database when a nodeId is present - if ((database != null && database.loaded) || intent.retrieveNodeId() == null) { - super.launchActionIfNeeded(intent, specialMode, database) + if (intent.isUserVerificationRequired()) { + if (database != null) { + onDatabaseRetrieved(database) + } + } else { + // Launch with database when a nodeId is present + if ((database != null && database.loaded) || intent.retrieveNodeId() == null) { + super.launchActionIfNeeded(intent, specialMode, database) + } } } diff --git a/app/src/main/res/drawable/ic_clock_loader_red_24dp.xml b/app/src/main/res/drawable/ic_clock_loader_red_24dp.xml new file mode 100644 index 000000000..feba31601 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_loader_red_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c13825b11..a2c91408a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -767,6 +767,7 @@ Select an existing passkey KeePassDX Database Select to unlock + Reauthenticate (No Device Auth) Passkey Username Passkey Private Key Passkey Credential Id @@ -776,4 +777,5 @@ Passkey Backup State Unable to return the passkey No passkey found with relying party %1$s and credentialIds %2$s + User verification required \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/150.txt b/fastlane/metadata/android/en-US/changelogs/150.txt index 4c2d129b6..d3f21421a 100644 --- a/fastlane/metadata/android/en-US/changelogs/150.txt +++ b/fastlane/metadata/android/en-US/changelogs/150.txt @@ -1,3 +1,4 @@ * Manual change of app language #1884 #1990 + * Add Passkey User Verification #2283 * Fix autofill username detection #2276 * Fix Passkey in passwordless mode #2282 \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/changelogs/150.txt b/fastlane/metadata/android/fr-FR/changelogs/150.txt index 04b7887d9..0063a8035 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/150.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/150.txt @@ -1,3 +1,4 @@ * Changement manuel de la langue de l'appli #1884 #1990 + * Ajout de la Verification Utilisateur pour Passkey #2283 * Correction de la détection du nom d'utilisateur pour le remplissage auto #2276 * Correction de Passkey en mode passwordless #2282 \ No newline at end of file