Checks
Describe the bug you encountered:
For: keepass2android issue #3245 · Branch: feature/passkey-support (PR #3228, experimental passkey support)
Environment
- Device: Honor Magic8
- OS: Magic OS / Android 16
- App: Keepass2Android v1.16-pre1
- Browser: Microsoft Edge (
com.microsoft.emmx); same failure on GitHub and Microsoft account
- Feature: experimental passkey support (
feature/passkey-support)
Summary
When saving a passkey from a website (GitHub / Microsoft) through the Android Credential Manager,
Keepass2Android shows up in the "save passkey" sheet, the entry-edit page is pre-filled with the
passkey details, and after tapping Save the KeePass entry is actually created and the passkey data
is stored correctly in the KPEX fields. However, the website then shows "Passkey registration failed"
and the passkey is not registered.
What works vs. what fails
- ✅ The CredMan provider is invoked; the "save to K2A" option appears.
- ✅ The entry is created and the passkey is persisted in the KeePass entry (verified by opening the new entry).
- ❌ The registration response returned to the Relying Party is rejected → "Passkey registration failed".
- ❌ No K2A crash / no
Error creating passkey response — the response is returned but fails server-side validation.
⇒ The failure is entirely in the registration (attestation) response K2A sends back, not in storage.
Static analysis (source review on feature/passkey-support)
The create (attestation) path:
Kp2aCredentialLauncherActivity.CreatePasskeyResponse
(src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs:351)
→ BuildClientDataResponse (:530) picks one of:
ClientDataDefinedResponse (placeholder) when clientDataHash != null (privileged browser flow), or
ClientDataBuildResponse (self-built clientDataJSON) when clientDataHash == null.
clientDataHash is taken from CreatePublicKeyCredentialRequest.GetClientDataHash() (:750, stored at :799).
Note: this is the C# binding, not the JNI hack ExtractClientDataHashFromOption, which is used
only on the assertion (login) path (:935). So this registration failure is unrelated to that JNI code.
COSE public key (PasskeyCryptoHelper.ConvertPublicKeyToMap, crypto.cs:354-394) and
rpIdHash = SHA256(RelyingPartyEntity.Id) (AuthenticatorResponses.cs:105,233) were checked and are spec-compliant.
Possible problematic points
1. Placeholder clientDataJSON not substituted by the Android 16 CredMan framework (most likely)
ClientDataDefinedResponse.BuildResponse() returns the literal "<placeholder>"
(src/Kp2aPasskey.Core/AuthenticatorResponses.cs:128-137). In a privileged browser flow the framework is
expected to replace this placeholder with the real clientDataJSON (whose hash equals the provided
clientDataHash). On Android 16 this substitution appears to no longer happen (or behaves differently),
so the placeholder (origin="<placeholder>") reaches the RP → origin/type validation fails →
"Passkey registration failed".
Why this is the top suspect: with Edge (a trusted browser — com.microsoft.emmx is in IsTrustedBrowser),
_passkeyOrigin is correctly https://github.com. If the self-built path (point 2) were taken, the origin
would be correct and GitHub would accept a none-attestation response (it only checks challenge+origin
bytes). A deterministic failure therefore points at the placeholder being passed through — i.e. this point.
2. GetClientDataHash() returns null on Android 16 → self-built clientDataJSON hash mismatch
If CreatePublicKeyCredentialRequest.GetClientDataHash() (:750) returns null on Android 16 (framework
treats the browser request as non-privileged), K2A falls back to ClientDataBuildResponse (:553) and
builds its own clientDataJSON. The privileged framework holds the real clientDataJSON (hash = the one it
provided) and rejects the mismatch.
3. Non-standard fields in the attestation response
AuthenticatorAttestationResponse.ToJson() (AuthenticatorResponses.cs:269-285) emits authenticatorData,
publicKey, and publicKeyAlgorithm inside response. A standard WebAuthn AuthenticatorAttestationResponse
only has clientDataJSON + attestationObject (the public key lives in the COSE inside
attestationObject.authData). Redundant/non-standard fields can make strict parsers
(AndroidX CreatePublicKeyCredentialResponse / the browser) error out.
4. Origin derived from a browser whitelist instead of the request's web origin
BuildClientDataResponse (:539-544) and the create path (:782-786) derive the origin from the calling
package via IsTrustedBrowser / GetOriginForCallingApp. Browsers not in the hardcoded whitelist
(e.g. Honor/Huawei browsers) get origin = "android:apk-key-hash:" (literal, no hash appended) → RP
rejects. This is not the reporter's case (Edge is whitelisted) but affects other browsers. The robust
fix is to use the web origin carried by CreatePublicKeyCredentialRequest (privileged flow) instead of
reconstructing it from the package name.
Proposed diagnostic (to confirm point 1 vs. point 2)
Add one log line after Kp2aCredentialLauncherActivity.cs:750:
Kp2aLog.Log($"Create clientDataHash null? {clientDataHash == null}; origin={_passkeyOrigin}");
null? True → point 2.
null? False → point 1; additionally dump responseJson in CreateFidoAttestationResponse (:589) and
check whether clientDataJSON is the base64 of <placeholder>.
Proposed fix directions
- Don't rely on the framework substituting a placeholder
clientDataJSON. In a browser flow, build a
real clientDataJSON from the known web origin (https://<rpId> / the request's origin) + the
challenge from creationOptions.Challenge, and return that instead of the placeholder.
- Remove the redundant
authenticatorData / publicKey / publicKeyAlgorithm fields from the attestation
response (point 3).
- Prefer the request's web
origin over the package-name whitelist (point 4).
- Add diagnostics logging for
clientDataHash so this class of issue is observable on future Android versions.
Note
This analysis is static (source review on feature/passkey-support); no device log was captured. The
maintainer's insight on whether Android 16 changed the CredMan behavior around clientDataHash / placeholder
substitution would be very helpful. The feature is marked experimental ("don't rely on it to work"), but
registration is a core path and this looks like an Android-16-specific regression.
Describe what you expected to happen:
No response
What version of Keepass2Android are you using?
1.16
Which version of Android are you on?
16
Checks
Describe the bug you encountered:
Environment
com.microsoft.emmx); same failure on GitHub and Microsoft accountfeature/passkey-support)Summary
When saving a passkey from a website (GitHub / Microsoft) through the Android Credential Manager,
Keepass2Android shows up in the "save passkey" sheet, the entry-edit page is pre-filled with the
passkey details, and after tapping Save the KeePass entry is actually created and the passkey data
is stored correctly in the KPEX fields. However, the website then shows "Passkey registration failed"
and the passkey is not registered.
What works vs. what fails
Error creating passkey response— the response is returned but fails server-side validation.⇒ The failure is entirely in the registration (attestation) response K2A sends back, not in storage.
Static analysis (source review on
feature/passkey-support)The create (attestation) path:
Kp2aCredentialLauncherActivity.CreatePasskeyResponse(
src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs:351)→
BuildClientDataResponse(:530) picks one of:ClientDataDefinedResponse(placeholder) whenclientDataHash != null(privileged browser flow), orClientDataBuildResponse(self-builtclientDataJSON) whenclientDataHash == null.clientDataHashis taken fromCreatePublicKeyCredentialRequest.GetClientDataHash()(:750, stored at:799).Note: this is the C# binding, not the JNI hack
ExtractClientDataHashFromOption, which is usedonly on the assertion (login) path (
:935). So this registration failure is unrelated to that JNI code.COSE public key (
PasskeyCryptoHelper.ConvertPublicKeyToMap,crypto.cs:354-394) andrpIdHash = SHA256(RelyingPartyEntity.Id)(AuthenticatorResponses.cs:105,233) were checked and are spec-compliant.Possible problematic points
1. Placeholder
clientDataJSONnot substituted by the Android 16 CredMan framework (most likely)ClientDataDefinedResponse.BuildResponse()returns the literal"<placeholder>"(
src/Kp2aPasskey.Core/AuthenticatorResponses.cs:128-137). In a privileged browser flow the framework isexpected to replace this placeholder with the real
clientDataJSON(whose hash equals the providedclientDataHash). On Android 16 this substitution appears to no longer happen (or behaves differently),so the placeholder (
origin="<placeholder>") reaches the RP → origin/type validation fails →"Passkey registration failed".
Why this is the top suspect: with Edge (a trusted browser —
com.microsoft.emmxis inIsTrustedBrowser),_passkeyOriginis correctlyhttps://github.com. If the self-built path (point 2) were taken, the originwould be correct and GitHub would accept a
none-attestation response (it only checks challenge+originbytes). A deterministic failure therefore points at the placeholder being passed through — i.e. this point.
2.
GetClientDataHash()returns null on Android 16 → self-built clientDataJSON hash mismatchIf
CreatePublicKeyCredentialRequest.GetClientDataHash()(:750) returnsnullon Android 16 (frameworktreats the browser request as non-privileged), K2A falls back to
ClientDataBuildResponse(:553) andbuilds its own
clientDataJSON. The privileged framework holds the realclientDataJSON(hash = the one itprovided) and rejects the mismatch.
3. Non-standard fields in the attestation response
AuthenticatorAttestationResponse.ToJson()(AuthenticatorResponses.cs:269-285) emitsauthenticatorData,publicKey, andpublicKeyAlgorithminsideresponse. A standard WebAuthnAuthenticatorAttestationResponseonly has
clientDataJSON+attestationObject(the public key lives in the COSE insideattestationObject.authData). Redundant/non-standard fields can make strict parsers(AndroidX
CreatePublicKeyCredentialResponse/ the browser) error out.4. Origin derived from a browser whitelist instead of the request's web origin
BuildClientDataResponse(:539-544) and the create path (:782-786) derive the origin from the callingpackage via
IsTrustedBrowser/GetOriginForCallingApp. Browsers not in the hardcoded whitelist(e.g. Honor/Huawei browsers) get
origin = "android:apk-key-hash:"(literal, no hash appended) → RPrejects. This is not the reporter's case (Edge is whitelisted) but affects other browsers. The robust
fix is to use the web
origincarried byCreatePublicKeyCredentialRequest(privileged flow) instead ofreconstructing it from the package name.
Proposed diagnostic (to confirm point 1 vs. point 2)
Add one log line after
Kp2aCredentialLauncherActivity.cs:750:null? True→ point 2.null? False→ point 1; additionally dumpresponseJsoninCreateFidoAttestationResponse(:589) andcheck whether
clientDataJSONis the base64 of<placeholder>.Proposed fix directions
clientDataJSON. In a browser flow, build areal
clientDataJSONfrom the known web origin (https://<rpId>/ the request'sorigin) + thechallenge from
creationOptions.Challenge, and return that instead of the placeholder.authenticatorData/publicKey/publicKeyAlgorithmfields from the attestationresponse (point 3).
originover the package-name whitelist (point 4).clientDataHashso this class of issue is observable on future Android versions.Note
This analysis is static (source review on
feature/passkey-support); no device log was captured. Themaintainer's insight on whether Android 16 changed the CredMan behavior around
clientDataHash/ placeholdersubstitution would be very helpful. The feature is marked experimental ("don't rely on it to work"), but
registration is a core path and this looks like an Android-16-specific regression.
Describe what you expected to happen:
No response
What version of Keepass2Android are you using?
1.16
Which version of Android are you on?
16