feat: Add User Verification #2283

This commit is contained in:
J-Jamet
2025-11-25 17:38:00 +01:00
parent ed095ad0a7
commit c17fba8ef7
10 changed files with 249 additions and 55 deletions

View File

@@ -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

View File

@@ -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
)

View File

@@ -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,26 +157,39 @@ 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 ->
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
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 {
icon = passkeyEntry.buildIcon(
this@PasskeyProviderService,
database
)?.apply {
setTintBlendMode(BlendMode.DST)
} ?: defaultIcon,
pendingIntent = usagePendingIntent,
@@ -179,16 +200,24 @@ class PasskeyProviderService : CredentialProviderService() {
)
}
}
}
callback(passkeyEntries)
},
onItemNotFound = { _ ->
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
searchInfo = searchInfo,
userVerificationRequired = userVerificationRequired
)?.let { pendingIntent ->
passkeyEntries.add(
PublicKeyCredentialEntry(
@@ -213,6 +242,7 @@ class PasskeyProviderService : CredentialProviderService() {
)
)
}
}
},
onDatabaseClosed = {
Log.d(TAG, "Add pending intent for passkey selection in closed database")
@@ -240,6 +270,41 @@ class PasskeyProviderService : CredentialProviderService() {
)
}
/**
* To easily manage user verification condition
*/
private fun manageUserVerification(
passkeyEntries: MutableList<CredentialEntry>,
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,

View File

@@ -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
*/

View File

@@ -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>(CredentialState.Loading)
val credentialUiState: StateFlow<CredentialState> = 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)

View File

@@ -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,16 +150,29 @@ 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?
) {
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)
}
}
}
override suspend fun launchAction(
intent: Intent,

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportHeight="960"
android:viewportWidth="960">
<path
android:fillColor="#E50808"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM253,707L480,480L480,160Q346,160 253,253Q160,346 160,480Q160,544 184,603Q208,662 253,707Z"/>
</vector>

View File

@@ -767,6 +767,7 @@
<string name="passkey_selection_description">Select an existing passkey</string>
<string name="passkey_database_username">KeePassDX Database</string>
<string name="passkey_locked_database_description">Select to unlock</string>
<string name="passkey_relaunch_database_description">Reauthenticate (No Device Auth)</string>
<string name="passkey_username">Passkey Username</string>
<string name="passkey_private_key">Passkey Private Key</string>
<string name="passkey_credential_id">Passkey Credential Id</string>
@@ -776,4 +777,5 @@
<string name="passkey_backup_state">Passkey Backup State</string>
<string name="error_passkey_result">Unable to return the passkey</string>
<string name="error_passkey_credential_id">No passkey found with relying party %1$s and credentialIds %2$s</string>
<string name="user_verification_required">User verification required</string>
</resources>

View File

@@ -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

View File

@@ -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