Compare commits

...

15 Commits

Author SHA1 Message Date
J-Jamet
5fd25c6150 fix: Passkey subdomain #2291 2025-11-26 12:31:53 +01:00
J-Jamet
c1cfddddbe Merge branch 'develop' into feature/UserVerification 2025-11-26 11:23:31 +01:00
J-Jamet
9146315001 fix: Remove Passkey error to be able to select search elements #2282 2025-11-26 11:22:47 +01:00
J-Jamet
609b536898 fix: User Verified flag during registration #2283 2025-11-26 10:16:51 +01:00
J-Jamet
f9051ce787 fix: Remove test line #2283 2025-11-25 19:22:40 +01:00
J-Jamet
d90d175bd8 fix: User Verified response flag 2025-11-25 19:15:37 +01:00
J-Jamet
c17fba8ef7 feat: Add User Verification #2283 2025-11-25 17:38:00 +01:00
J-Jamet
ed095ad0a7 fix: Passwordless for multiple CredentialIds 2025-11-25 13:06:19 +01:00
J-Jamet
82a8776911 fix: Update Credential API code #2141 #2283 2025-11-25 12:24:59 +01:00
J-Jamet
753e9c4721 fix: Update Changelog #2282 2025-11-24 20:42:43 +01:00
J-Jamet
b64094ed20 fix: Show toast error in passwordless mode 2025-11-24 20:35:54 +01:00
J-Jamet
bc854c63f7 fix: Select passkey in passwordless mode #2282 2025-11-24 15:25:37 +01:00
J-Jamet
3b793a72b8 fix: Autofill username detection #2276 2025-11-17 17:28:08 +01:00
J-Jamet
f19afbdb2e Manual change of app language #1884 #1990 2025-11-17 12:08:26 +01:00
J-Jamet
622e9cefdd Merge tag '4.2.4' into develop
4.2.4
2025-11-14 11:53:15 +01:00
21 changed files with 619 additions and 117 deletions

View File

@@ -1,3 +1,9 @@
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
KeePassDX(4.2.4)
* Fix remembering database location #2262

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.kunzisoft.keepass"
minSdkVersion 19
targetSdkVersion 35
versionCode = 149
versionName = "4.2.4"
versionCode = 150
versionName = "4.3.0"
multiDexEnabled true
testApplicationId = "com.kunzisoft.keepass.tests"
@@ -110,6 +110,10 @@ android {
// Bouncy castle bug https://github.com/bcgit/bc-java/issues/1685
resources.pickFirsts.add('META-INF/versions/9/OSGI-INF/MANIFEST.MF')
}
androidResources {
generateLocaleConfig true
}
}
def room_version = "2.5.1"

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,15 @@ 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.data.UserVerificationRequirement
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.addUserVerification
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.getUserVerificationCondition
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.getUserVerifiedWithAuth
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.isAuthenticatorsAllowed
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removeUserVerification
import com.kunzisoft.keepass.credentialprovider.viewmodel.CredentialLauncherViewModel
import com.kunzisoft.keepass.credentialprovider.viewmodel.PasskeyLauncherViewModel
import com.kunzisoft.keepass.database.ContextualDatabase
@@ -54,6 +63,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,10 +92,64 @@ class PasskeyLauncherActivity : DatabaseLockActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
// To manage https://github.com/Kunzisoft/KeePassDX/issues/2283
val userVerificationCondition = intent.getUserVerificationCondition()
if (userVerificationCondition) {
if (isAuthenticatorsAllowed().not()) {
intent.removeUserVerification()
sendBroadcast(Intent(LOCK_ACTION))
}
}
// super.onCreate must be after UserVerification to allow database lock
super.onCreate(savedInstanceState)
// Biometric must be after super.onCreate
if (userVerificationCondition) {
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(userVerified = true, 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()
passkeyLauncherViewModel.initialize(userVerified = intent.getUserVerifiedWithAuth())
// Retrieve the UI
passkeyLauncherViewModel.uiState.collect { uiState ->
when (uiState) {
@@ -278,7 +342,9 @@ class PasskeyLauncherActivity : DatabaseLockActivity() {
specialMode: SpecialMode,
searchInfo: SearchInfo? = null,
appOrigin: AppOrigin? = null,
nodeId: UUID? = null
nodeId: UUID? = null,
userVerification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED,
userVerifiedWithAuth: Boolean = true
): PendingIntent? {
return PendingIntent.getActivity(
context,
@@ -290,6 +356,7 @@ class PasskeyLauncherActivity : DatabaseLockActivity() {
addAppOrigin(appOrigin)
addNodeId(nodeId)
addAuthCode(nodeId)
addUserVerification(userVerification, userVerifiedWithAuth)
},
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)

View File

@@ -154,7 +154,7 @@ class StructureParser(private val structure: AssistStructure) {
it.contains(View.AUTOFILL_HINT_PASSWORD, true) -> {
// Password Id changed if it's the second times we are here,
// So the last username candidate is most appropriate
if (result?.passwordId != null) {
if (result?.passwordId != null && usernameIdCandidate != null) {
result?.usernameId = usernameIdCandidate
result?.usernameValue = usernameValueCandidate
}

View File

@@ -42,12 +42,15 @@ import androidx.credentials.provider.CredentialEntry
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.kunzisoft.encrypt.Base64Helper.Companion.b64Encode
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon
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
@@ -64,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() {
@@ -82,6 +86,11 @@ class PasskeyProviderService : CredentialProviderService() {
setTintBlendMode(BlendMode.DST)
}
relaunchIcon = Icon.createWithResource(
this@PasskeyProviderService,
R.drawable.ic_clock_loader_red_24dp
)
isAutoSelectAllowed = isPasskeyAutoSelectEnable(this)
}
@@ -90,9 +99,13 @@ class PasskeyProviderService : CredentialProviderService() {
super.onDestroy()
}
private fun buildPasskeySearchInfo(relyingParty: String): SearchInfo {
private fun buildPasskeySearchInfo(
relyingParty: String,
credentialIds: List<String> = listOf()
): SearchInfo {
return SearchInfo().apply {
this.relyingParty = relyingParty
this.credentialIds = credentialIds
}
}
@@ -108,6 +121,7 @@ class PasskeyProviderService : CredentialProviderService() {
}
} catch (e: Exception) {
Log.e(javaClass.simpleName, "onBeginGetCredentialRequest error", e)
toastError(e)
callback.onError(GetCredentialUnknownException())
}
}
@@ -136,31 +150,46 @@ class PasskeyProviderService : CredentialProviderService() {
option: BeginGetPublicKeyCredentialOption,
callback: (List<CredentialEntry>) -> Unit
) {
val passkeyEntries: MutableList<CredentialEntry> = mutableListOf()
val relyingPartyId = PublicKeyCredentialRequestOptions(option.requestJson).rpId
val searchInfo = buildPasskeySearchInfo(relyingPartyId)
Log.d(TAG, "Build passkey search for relying party $relyingPartyId")
val publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions(option.requestJson)
val relyingPartyId = publicKeyCredentialRequestOptions.rpId
val credentialIdList = publicKeyCredentialRequestOptions.allowCredentials
.map { b64Encode(it.id) }
val searchInfo = buildPasskeySearchInfo(relyingPartyId, credentialIdList)
val userVerification = publicKeyCredentialRequestOptions.userVerification
Log.d(TAG, "Build passkey search for UV $userVerification, " +
"RP $relyingPartyId and Credential IDs $credentialIdList")
SearchHelper.checkAutoSearchInfo(
context = this,
database = mDatabase,
searchInfo = searchInfo,
onItemsFound = { database, items ->
manageUserVerification(
passkeyEntries = passkeyEntries,
searchInfo = searchInfo,
option = option,
userVerification = userVerification
) {
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,
userVerification = userVerification,
userVerifiedWithAuth = false
)?.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,
@@ -171,15 +200,25 @@ class PasskeyProviderService : CredentialProviderService() {
)
}
}
}
callback(passkeyEntries)
},
onItemNotFound = { _ ->
manageUserVerification(
passkeyEntries = passkeyEntries,
searchInfo = searchInfo,
option = option,
userVerification = userVerification,
) {
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,
userVerification = userVerification,
userVerifiedWithAuth = false
)?.let { pendingIntent ->
passkeyEntries.add(
PublicKeyCredentialEntry(
@@ -195,6 +234,16 @@ class PasskeyProviderService : CredentialProviderService() {
)
}
callback(passkeyEntries)
} else {
throw IOException(
getString(
R.string.error_passkey_credential_id,
relyingPartyId,
credentialIdList
)
)
}
}
},
onDatabaseClosed = {
Log.d(TAG, "Add pending intent for passkey selection in closed database")
@@ -202,7 +251,8 @@ class PasskeyProviderService : CredentialProviderService() {
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.SELECTION,
searchInfo = searchInfo
searchInfo = searchInfo,
userVerifiedWithAuth = true
)?.let { pendingIntent ->
passkeyEntries.add(
PublicKeyCredentialEntry(
@@ -222,6 +272,42 @@ class PasskeyProviderService : CredentialProviderService() {
)
}
/**
* To easily manage user verification condition
*/
private fun manageUserVerification(
passkeyEntries: MutableList<CredentialEntry>,
searchInfo: SearchInfo,
option: BeginGetPublicKeyCredentialOption,
userVerification: UserVerificationRequirement,
standardAction: () -> Unit
) {
if (userVerification == UserVerificationRequirement.REQUIRED && isAuthenticatorsAllowed().not()) {
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.SELECTION,
searchInfo = searchInfo,
userVerification = userVerification,
userVerifiedWithAuth = 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,
@@ -257,14 +343,17 @@ class PasskeyProviderService : CredentialProviderService() {
private fun MutableList<CreateEntry>.addPendingIntentCreationNewEntry(
accountName: String,
searchInfo: SearchInfo?
searchInfo: SearchInfo?,
userVerification: UserVerificationRequirement
) {
Log.d(TAG, "Add pending intent for registration in opened database to create new item")
// TODO add a setting to directly store in a specific group
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.REGISTRATION,
searchInfo = searchInfo
searchInfo = searchInfo,
userVerification = userVerification,
userVerifiedWithAuth = false
)?.let { pendingIntent ->
this.add(
CreateEntry(
@@ -287,11 +376,13 @@ class PasskeyProviderService : CredentialProviderService() {
getString(R.string.passkey_database_username)
else databaseName
val createEntries: MutableList<CreateEntry> = mutableListOf()
val relyingPartyId = PublicKeyCredentialCreationOptions(
val publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions(
requestJson = request.requestJson,
clientDataHash = request.clientDataHash
).relyingPartyEntity.id
)
val relyingPartyId = publicKeyCredentialCreationOptions.relyingPartyEntity.id
val searchInfo = buildPasskeySearchInfo(relyingPartyId)
val userVerification = publicKeyCredentialCreationOptions.authenticatorSelection.userVerification
Log.d(TAG, "Build passkey search for relying party $relyingPartyId")
SearchHelper.checkAutoSearchInfo(
context = this,
@@ -302,7 +393,11 @@ class PasskeyProviderService : CredentialProviderService() {
throw RegisterInReadOnlyDatabaseException()
} else {
// To create a new entry
createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo)
createEntries.addPendingIntentCreationNewEntry(
accountName = accountName,
searchInfo = searchInfo,
userVerification = userVerification
)
/* TODO Overwrite
// To select an existing entry and permit an overwrite
Log.w(TAG, "Passkey already registered")
@@ -333,7 +428,11 @@ class PasskeyProviderService : CredentialProviderService() {
if (database.isReadOnly) {
throw RegisterInReadOnlyDatabaseException()
} else {
createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo)
createEntries.addPendingIntentCreationNewEntry(
accountName = accountName,
searchInfo = searchInfo,
userVerification = userVerification
)
}
callback(createEntries)
},
@@ -342,7 +441,8 @@ class PasskeyProviderService : CredentialProviderService() {
Log.d(TAG, "Add pending intent for passkey registration in closed database")
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.REGISTRATION
specialMode = SpecialMode.REGISTRATION,
userVerifiedWithAuth = true
)?.let { pendingIntent ->
createEntries.add(
CreateEntry(

View File

@@ -16,7 +16,25 @@
package com.kunzisoft.keepass.credentialprovider.passkey.data
data class PublicKeyCredentialRpEntity(val name: String, val id: String)
import com.kunzisoft.encrypt.Base64Helper
import org.json.JSONObject
data class PublicKeyCredentialRpEntity(
val name: String,
val id: String
) {
companion object {
fun JSONObject.getPublicKeyCredentialRpEntity(
parameterName: String
): PublicKeyCredentialRpEntity {
val rpJson = this.getJSONObject(parameterName)
return PublicKeyCredentialRpEntity(
rpJson.getString("name"),
rpJson.getString("id")
)
}
}
}
data class PublicKeyCredentialUserEntity(
val name: String,
@@ -42,9 +60,41 @@ data class PublicKeyCredentialUserEntity(
result = 31 * result + displayName.hashCode()
return result
}
companion object {
fun JSONObject.getPublicKeyCredentialUserEntity(
parameterName: String
): PublicKeyCredentialUserEntity {
val rpUser = this.getJSONObject(parameterName)
return PublicKeyCredentialUserEntity(
rpUser.getString("name"),
Base64Helper.b64Decode(rpUser.getString("id")),
rpUser.getString("displayName")
)
}
}
}
data class PublicKeyCredentialParameters(val type: String, val alg: Long)
data class PublicKeyCredentialParameters(
val type: String,
val alg: Long
) {
companion object {
fun JSONObject.getPublicKeyCredentialParametersList(
parameterName: String
): List<PublicKeyCredentialParameters> {
val pubKeyCredParamsJson = this.getJSONArray(parameterName)
val pubKeyCredParamsTmp: MutableList<PublicKeyCredentialParameters> = mutableListOf()
for (i in 0 until pubKeyCredParamsJson.length()) {
val e = pubKeyCredParamsJson.getJSONObject(i)
pubKeyCredParamsTmp.add(
PublicKeyCredentialParameters(e.getString("type"), e.getLong("alg"))
)
}
return pubKeyCredParamsTmp.toList()
}
}
}
data class PublicKeyCredentialDescriptor(
val type: String,
@@ -70,11 +120,104 @@ data class PublicKeyCredentialDescriptor(
result = 31 * result + transports.hashCode()
return result
}
companion object {
fun JSONObject.getPublicKeyCredentialDescriptorList(
parameterName: String
): List<PublicKeyCredentialDescriptor> {
val credentialsJson = this.getJSONArray(parameterName)
val credentialsTmp: MutableList<PublicKeyCredentialDescriptor> = mutableListOf()
for (i in 0 until credentialsJson.length()) {
val credentialJson = credentialsJson.getJSONObject(i)
val transports: MutableList<String> = mutableListOf()
val transportsJson = credentialJson.getJSONArray("transports")
for (j in 0 until transportsJson.length()) {
transports.add(transportsJson.getString(j))
}
credentialsTmp.add(
PublicKeyCredentialDescriptor(
type = credentialJson.getString("type"),
id = Base64Helper.b64Decode(credentialJson.getString("id")),
transports = transports
)
)
}
return credentialsTmp.toList()
}
}
}
// https://www.w3.org/TR/webauthn-3/#dictdef-authenticatorselectioncriteria
data class AuthenticatorSelectionCriteria(
val authenticatorAttachment: String,
val residentKey: String,
val requireResidentKey: Boolean = false,
val userVerification: String = "preferred"
)
val authenticatorAttachment: String? = null,
val residentKey: ResidentKeyRequirement? = null,
val requireResidentKey: Boolean?,
val userVerification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED
) {
companion object {
fun JSONObject.getAuthenticatorSelectionCriteria(
parameterName: String
): AuthenticatorSelectionCriteria {
val authenticatorSelection = this.optJSONObject(parameterName)
?: return AuthenticatorSelectionCriteria(requireResidentKey = null)
val authenticatorAttachment = if (!authenticatorSelection.isNull("authenticatorAttachment"))
authenticatorSelection.getString("authenticatorAttachment") else null
var residentKey = if (!authenticatorSelection.isNull("residentKey"))
ResidentKeyRequirement.fromString(authenticatorSelection.getString("residentKey"))
else null
val requireResidentKey = authenticatorSelection.optBoolean("requireResidentKey", false)
val userVerification = UserVerificationRequirement
.fromString(authenticatorSelection.optString("userVerification", "preferred"))
?: UserVerificationRequirement.PREFERRED
// https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement
if (residentKey == null) {
residentKey = if (requireResidentKey) {
ResidentKeyRequirement.REQUIRED
} else {
ResidentKeyRequirement.DISCOURAGED
}
}
return AuthenticatorSelectionCriteria(
authenticatorAttachment = authenticatorAttachment,
residentKey = residentKey,
requireResidentKey = requireResidentKey,
userVerification = userVerification
)
}
}
}
// https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement
enum class ResidentKeyRequirement(val value: String) {
DISCOURAGED("discouraged"),
PREFERRED("preferred"),
REQUIRED("required");
override fun toString(): String {
return value
}
companion object {
fun fromString(value: String): ResidentKeyRequirement? {
return ResidentKeyRequirement.entries.firstOrNull {
it.value.equals(other = value, ignoreCase = true)
}
}
}
}
// https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement
enum class UserVerificationRequirement(val value: String) {
REQUIRED("required"),
PREFERRED("preferred"),
DISCOURAGED("discouraged");
override fun toString(): String {
return value
}
companion object {
fun fromString(value: String): UserVerificationRequirement? {
return UserVerificationRequirement.entries.firstOrNull {
it.value.equals(other = value, ignoreCase = true)
}
}
}
}

View File

@@ -20,52 +20,42 @@
package com.kunzisoft.keepass.credentialprovider.passkey.data
import com.kunzisoft.encrypt.Base64Helper
import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorSelectionCriteria.Companion.getAuthenticatorSelectionCriteria
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialDescriptor.Companion.getPublicKeyCredentialDescriptorList
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialParameters.Companion.getPublicKeyCredentialParametersList
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRpEntity.Companion.getPublicKeyCredentialRpEntity
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUserEntity.Companion.getPublicKeyCredentialUserEntity
import org.json.JSONObject
class PublicKeyCredentialCreationOptions(
requestJson: String,
var clientDataHash: ByteArray?
) {
val json: JSONObject = JSONObject(requestJson)
private val json: JSONObject = JSONObject(requestJson)
val relyingPartyEntity: PublicKeyCredentialRpEntity
val userEntity: PublicKeyCredentialUserEntity
val challenge: ByteArray
val pubKeyCredParams: List<PublicKeyCredentialParameters>
val relyingPartyEntity: PublicKeyCredentialRpEntity =
json.getPublicKeyCredentialRpEntity("rp")
var timeout: Long
var excludeCredentials: List<PublicKeyCredentialDescriptor>
var authenticatorSelection: AuthenticatorSelectionCriteria
var attestation: String
val userEntity: PublicKeyCredentialUserEntity =
json.getPublicKeyCredentialUserEntity("user")
init {
val rpJson = json.getJSONObject("rp")
relyingPartyEntity = PublicKeyCredentialRpEntity(rpJson.getString("name"), rpJson.getString("id"))
val rpUser = json.getJSONObject("user")
val userId = Base64Helper.b64Decode(rpUser.getString("id"))
userEntity =
PublicKeyCredentialUserEntity(
rpUser.getString("name"),
userId,
rpUser.getString("displayName")
)
challenge = Base64Helper.b64Decode(json.getString("challenge"))
val pubKeyCredParamsJson = json.getJSONArray("pubKeyCredParams")
val pubKeyCredParamsTmp: MutableList<PublicKeyCredentialParameters> = mutableListOf()
for (i in 0 until pubKeyCredParamsJson.length()) {
val e = pubKeyCredParamsJson.getJSONObject(i)
pubKeyCredParamsTmp.add(
PublicKeyCredentialParameters(e.getString("type"), e.getLong("alg"))
)
}
pubKeyCredParams = pubKeyCredParamsTmp.toList()
val challenge: ByteArray =
Base64Helper.b64Decode(json.getString("challenge"))
timeout = json.optLong("timeout", 0)
// TODO: Fix excludeCredentials and authenticatorSelection
excludeCredentials = emptyList()
authenticatorSelection = AuthenticatorSelectionCriteria("platform", "required")
attestation = json.optString("attestation", "none")
}
val pubKeyCredParams: List<PublicKeyCredentialParameters> =
json.getPublicKeyCredentialParametersList("pubKeyCredParams")
var timeout: Long =
json.optLong("timeout", 0)
var excludeCredentials: List<PublicKeyCredentialDescriptor> =
json.getPublicKeyCredentialDescriptorList("excludeCredentials")
var authenticatorSelection: AuthenticatorSelectionCriteria =
json.getAuthenticatorSelectionCriteria("authenticatorSelection")
var attestation: String =
json.optString("attestation", "none")
companion object {
private val TAG = PublicKeyCredentialCreationOptions::class.simpleName

View File

@@ -20,12 +20,33 @@
package com.kunzisoft.keepass.credentialprovider.passkey.data
import com.kunzisoft.encrypt.Base64Helper
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialDescriptor.Companion.getPublicKeyCredentialDescriptorList
import org.json.JSONObject
// https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement
class PublicKeyCredentialRequestOptions(requestJson: String) {
val json: JSONObject = JSONObject(requestJson)
val challenge: ByteArray = Base64Helper.b64Decode(json.getString("challenge"))
val timeout: Long = json.optLong("timeout", 0)
val rpId: String = json.optString("rpId", "")
val userVerification: String = json.optString("userVerification", "preferred")
private val json: JSONObject = JSONObject(requestJson)
val challenge: ByteArray =
Base64Helper.b64Decode(json.getString("challenge"))
val timeout: Long =
json.optLong("timeout", 0)
val rpId: String =
json.optString("rpId", "")
val allowCredentials: List<PublicKeyCredentialDescriptor> =
json.getPublicKeyCredentialDescriptorList("allowCredentials")
val userVerification: UserVerificationRequirement =
UserVerificationRequirement.fromString(
json.optString("userVerification", "preferred"))
?: UserVerificationRequirement.PREFERRED
// TODO Hints
val hints: List<String> = listOf()
// TODO Extensions
// val extensions: AuthenticationExtensionsClientInputs
}

View File

@@ -22,6 +22,7 @@ package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.os.Build
import android.os.Bundle
import android.security.keystore.KeyGenParameterSpec
@@ -29,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
@@ -55,6 +60,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredential
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.UserVerificationRequirement
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.getOriginFromPrivilegedAllowLists
import com.kunzisoft.keepass.model.AndroidOrigin
import com.kunzisoft.keepass.model.AppOrigin
@@ -62,7 +68,9 @@ import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.utils.AppUtil
import com.kunzisoft.keepass.utils.StringUtil.toHexString
import com.kunzisoft.keepass.utils.getEnumExtra
import com.kunzisoft.keepass.utils.getParcelableExtraCompat
import com.kunzisoft.keepass.utils.putEnumExtra
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
@@ -90,6 +98,8 @@ 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_USER_VERIFICATION = "com.kunzisoft.keepass.extra.userVerification"
private const val EXTRA_USER_VERIFIED_WITH_AUTH = "com.kunzisoft.keepass.extra.userVerifiedWithAuth"
private const val SEPARATOR = "_"
@@ -106,6 +116,60 @@ object PasskeyHelper {
private val internalSecureRandom: SecureRandom = SecureRandom()
/**
* Add the User Verification to the intent
*/
fun Intent.addUserVerification(
userVerification: UserVerificationRequirement,
userVerifiedWithAuth: Boolean
) {
putEnumExtra(EXTRA_USER_VERIFICATION, userVerification)
putExtra(EXTRA_USER_VERIFIED_WITH_AUTH, userVerifiedWithAuth)
}
/**
* Get the User Verification from the intent
*/
fun Intent.getUserVerificationCondition(): Boolean {
return (getEnumExtra<UserVerificationRequirement>(EXTRA_USER_VERIFICATION)
?: UserVerificationRequirement.PREFERRED) == UserVerificationRequirement.REQUIRED
}
/**
* Define if the User is verified with authentification from the intent
*/
fun Intent.getUserVerifiedWithAuth(): Boolean {
return getBooleanExtra(EXTRA_USER_VERIFIED_WITH_AUTH, true)
}
/**
* Remove the User Verification from the intent
*/
fun Intent.removeUserVerification() {
removeExtra(EXTRA_USER_VERIFICATION)
}
/**
* Remove the User verified with auth from the intent
*/
fun Intent.removeUserVerifiedWithAuth() {
removeExtra(EXTRA_USER_VERIFIED_WITH_AUTH)
}
/**
* 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
*/
@@ -200,6 +264,28 @@ object PasskeyHelper {
}
}
/**
* Build the Passkey error response
*/
fun Activity.buildPasskeyErrorAndSetResult(
resources: Resources,
relyingPartyId: String?,
credentialIds: List<String>
) {
val error = resources.getString(
R.string.error_passkey_credential_id,
relyingPartyId,
credentialIds
)
Log.e(javaClass.name, error)
Toast.makeText(
this,
error,
Toast.LENGTH_SHORT
).show()
setResult(Activity.RESULT_CANCELED)
}
/**
* Check the timestamp and authentication code transmitted via PendingIntent
*/
@@ -471,6 +557,7 @@ object PasskeyHelper {
*/
fun buildCreatePublicKeyCredentialResponse(
publicKeyCredentialCreationParameters: PublicKeyCredentialCreationParameters,
userVerified: Boolean,
backupEligibility: Boolean,
backupState: Boolean
): CreatePublicKeyCredentialResponse {
@@ -488,7 +575,7 @@ object PasskeyHelper {
keyTypeId = keyTypeId
) ?: mapOf<Int, Any>()),
userPresent = true,
userVerified = true,
userVerified = userVerified,
backupEligibility = backupEligibility,
backupState = backupState,
publicKeyTypeId = keyTypeId,
@@ -560,6 +647,7 @@ object PasskeyHelper {
requestOptions: PublicKeyCredentialRequestOptions,
clientDataResponse: ClientDataResponse,
passkey: Passkey,
userVerified: Boolean,
defaultBackupEligibility: Boolean,
defaultBackupState: Boolean
): PublicKeyCredential {
@@ -568,7 +656,7 @@ object PasskeyHelper {
response = AuthenticatorAssertionResponse(
requestOptions = requestOptions,
userPresent = true,
userVerified = true,
userVerified = userVerified,
backupEligibility = passkey.backupEligibility ?: defaultBackupEligibility,
backupState = passkey.backupState ?: defaultBackupState,
userHandle = passkey.userHandle,

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

@@ -24,6 +24,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredential
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.getUserVerificationCondition
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
@@ -64,14 +65,16 @@ class PasskeyLauncherViewModel(application: Application): CredentialLauncherView
private var mPasskey: Passkey? = null
private var mLockDatabaseAfterSelection: Boolean = false
private var mUserVerified: Boolean = true
private var mBackupEligibility: Boolean = true
private var mBackupState: Boolean = false
private val mUiState = MutableStateFlow<UIState>(UIState.Loading)
val uiState: StateFlow<UIState> = mUiState
fun initialize() {
fun initialize(userVerified: Boolean) {
mLockDatabaseAfterSelection = PreferencesUtil.isPasskeyCloseDatabaseEnable(getApplication())
mUserVerified = userVerified
mBackupEligibility = PreferencesUtil.isPasskeyBackupEligibilityEnable(getApplication())
mBackupState = PreferencesUtil.isPasskeyBackupStateEnable(getApplication())
}
@@ -149,16 +152,31 @@ class PasskeyLauncherViewModel(application: Application): CredentialLauncherView
}
}
fun launchAction(
userVerified: Boolean,
intent: Intent,
specialMode: SpecialMode,
) {
this.mUserVerified = userVerified
super.launchActionIfNeeded(intent, specialMode, mDatabase)
}
override fun launchActionIfNeeded(
intent: Intent,
specialMode: SpecialMode,
database: ContextualDatabase?
) {
if (intent.getUserVerificationCondition()) {
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,
@@ -307,6 +325,7 @@ class PasskeyLauncherViewModel(application: Application): CredentialLauncherView
appOrigin = appOrigin
),
passkey = passkey,
userVerified = mUserVerified,
defaultBackupEligibility = mBackupEligibility,
defaultBackupState = mBackupState
)
@@ -363,6 +382,7 @@ class PasskeyLauncherViewModel(application: Application): CredentialLauncherView
appOrigin = appOrigin
),
passkey = passkey,
userVerified = mUserVerified,
defaultBackupEligibility = mBackupEligibility,
defaultBackupState = mBackupState
)
@@ -505,6 +525,7 @@ class PasskeyLauncherViewModel(application: Application): CredentialLauncherView
intent = responseIntent,
response = buildCreatePublicKeyCredentialResponse(
publicKeyCredentialCreationParameters = it,
userVerified = mUserVerified,
backupEligibility = passkey?.backupEligibility
?: mBackupEligibility,
backupState = passkey?.backupState

View File

@@ -94,6 +94,7 @@ object SearchHelper {
callback.invoke(
SearchParameters().apply {
searchQuery = query
searchOptions = optionsString()
allowEmptyQuery = false
searchInTitles = false
searchInUsernames = false

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

@@ -0,0 +1 @@
unqualifiedResLocale=en-US

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>
@@ -775,4 +776,6 @@
<string name="passkey_backup_eligibility">Passkey Backup Eligibility</string>
<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

@@ -27,6 +27,7 @@ import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.model.AppOriginEntryField.isAppId
import com.kunzisoft.keepass.model.AppOriginEntryField.isAppIdSignature
import com.kunzisoft.keepass.model.AppOriginEntryField.isWebDomain
import com.kunzisoft.keepass.model.PasskeyEntryFields.isCredentialId
import com.kunzisoft.keepass.model.PasskeyEntryFields.isPasskey
import com.kunzisoft.keepass.model.PasskeyEntryFields.isRelyingParty
import com.kunzisoft.keepass.otp.OtpEntryFields.isOTP
@@ -176,11 +177,29 @@ class SearchHelper {
}
}
if (searchParameters.searchInRelyingParty) {
if(entry.getExtraFields().any { field ->
val relyingParty = searchParameters.searchQuery
val credentialIds = searchParameters.searchOptions
val containsRelyingParty = entry.getExtraFields().any { field ->
field.isRelyingParty()
&& checkSearchQuery(field.protectedValue.stringValue, searchParameters)
})
return true
&& field.protectedValue.stringValue
.equals(relyingParty, ignoreCase = true)
}
// Check empty to allow any credential if not defined
val containsCredentialId = if(credentialIds.isEmpty()) true
else entry.getExtraFields().any { field ->
field.isCredentialId()
&& credentialIds.any { credentialId ->
checkSearchQuery(
stringToCheck = field.protectedValue.stringValue,
searchParameters = SearchParameters().apply {
searchQuery = credentialId
caseSensitive = false
isRegex = false
}
)
}
}
return containsRelyingParty && containsCredentialId
}
if (searchParameters.searchInNotes) {
if (checkSearchQuery(entry.notes, searchParameters))

View File

@@ -27,6 +27,8 @@ import android.os.Parcelable
*/
class SearchParameters() : Parcelable{
var searchQuery: String = ""
// Add an optional string to search with the main search query
var searchOptions: List<String> = listOf()
var allowEmptyQuery = true
var caseSensitive = false
var isRegex = false

View File

@@ -176,6 +176,13 @@ object PasskeyEntryFields {
}
}
/**
* Detect if the current field is a Passkey credential id
*/
fun Field.isCredentialId(): Boolean {
return name == FIELD_CREDENTIAL_ID
}
/**
* Detect if the current field is a Passkey relying party
*/

View File

@@ -35,6 +35,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
return if (webDomain == null) null else field
}
var relyingParty: String? = null
var credentialIds: List<String> = listOf()
var otpString: String? = null
constructor()
@@ -46,6 +47,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
webDomain = toCopy?.webDomain
webScheme = toCopy?.webScheme
relyingParty = toCopy?.relyingParty
credentialIds = toCopy?.credentialIds ?: listOf()
otpString = toCopy?.otpString
}
@@ -61,6 +63,9 @@ class SearchInfo : ObjectNameResource, Parcelable {
webScheme = if (readScheme.isNullOrEmpty()) null else readScheme
val readRelyingParty = parcel.readString()
relyingParty = if (readRelyingParty.isNullOrEmpty()) null else readRelyingParty
val readCredentialIdList = mutableListOf<String>()
parcel.readStringList(readCredentialIdList)
credentialIds = readCredentialIdList.toList()
val readOtp = parcel.readString()
otpString = if (readOtp.isNullOrEmpty()) null else readOtp
}
@@ -76,6 +81,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
parcel.writeString(webDomain ?: "")
parcel.writeString(webScheme ?: "")
parcel.writeString(relyingParty ?: "")
parcel.writeStringList(credentialIds)
parcel.writeString(otpString ?: "")
}
@@ -94,6 +100,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
&& webDomain == null
&& webScheme == null
&& relyingParty == null
&& credentialIds.isEmpty()
&& otpString == null
}
@@ -127,6 +134,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
if (webDomain != other.webDomain) return false
if (webScheme != other.webScheme) return false
if (relyingParty != other.relyingParty) return false
if (credentialIds != other.credentialIds) return false
if (otpString != other.otpString) return false
return true
@@ -139,6 +147,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
result = 31 * result + (webDomain?.hashCode() ?: 0)
result = 31 * result + (webScheme?.hashCode() ?: 0)
result = 31 * result + (relyingParty?.hashCode() ?: 0)
result = 31 * result + (credentialIds.hashCode())
result = 31 * result + (otpString?.hashCode() ?: 0)
return result
}
@@ -147,6 +156,10 @@ class SearchInfo : ObjectNameResource, Parcelable {
return otpString ?: webDomain ?: applicationId ?: relyingParty ?: tag ?: ""
}
fun optionsString(): List<String> {
return if (isPasskeySearch && credentialIds.isNotEmpty()) credentialIds else listOf()
}
fun toRegisterInfo(): RegisterInfo {
return RegisterInfo(this)
}

View File

@@ -0,0 +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

@@ -0,0 +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