From 9a21f4ea307421be2a94fb54e87814a49fb7bf91 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 23 Mar 2026 09:11:06 +0100 Subject: [PATCH 01/18] (squashed) first version of KP2A with passkey support --- src/KeePass.sln | 56 +- .../database/edit/CreateDB.cs | 3 +- .../AuthenticatorResponses.cs | 448 +++++++ src/Kp2aPasskey.Core/Kp2aPasskey.Core.csproj | 19 + src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs | 457 +++++++ src/Kp2aPasskey.Core/PasskeyData.cs | 98 ++ src/Kp2aPasskey.Core/PasskeyStorage.cs | 187 +++ .../PublicKeyCredentialHelpers.cs | 312 +++++ src/keepass2android-app/PasskeyPreferences.cs | 71 + .../Resources/values/config.xml | 221 ++-- .../Resources/values/strings.xml | 29 + .../Resources/xml/credentials_provider.xml | 30 + .../Resources/xml/pref_app.xml | 5 + .../Resources/xml/pref_app_passkeys.xml | 38 + .../SelectCurrentDbActivity.cs | 2 +- src/keepass2android-app/app/App.cs | 9 +- src/keepass2android-app/app/AppTask.cs | 124 +- .../keepass2android-app.csproj | 2 + .../Kp2aDigitalAssetLinksDataSource.cs | 71 +- .../GetCredentialHelper.cs | 236 ++++ .../Kp2aCredentialLauncherActivity.cs | 1147 +++++++++++++++++ .../Kp2aCredentialProviderService.cs | 298 +++++ .../PasskeyOptionParsingHelper.cs | 120 ++ .../UserVerificationHelper.cs | 120 ++ .../settings/AppSettingsActivity.cs | 9 +- 25 files changed, 3973 insertions(+), 139 deletions(-) create mode 100644 src/Kp2aPasskey.Core/AuthenticatorResponses.cs create mode 100644 src/Kp2aPasskey.Core/Kp2aPasskey.Core.csproj create mode 100644 src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs create mode 100644 src/Kp2aPasskey.Core/PasskeyData.cs create mode 100644 src/Kp2aPasskey.Core/PasskeyStorage.cs create mode 100644 src/Kp2aPasskey.Core/PublicKeyCredentialHelpers.cs create mode 100644 src/keepass2android-app/PasskeyPreferences.cs create mode 100644 src/keepass2android-app/Resources/xml/credentials_provider.xml create mode 100644 src/keepass2android-app/Resources/xml/pref_app_passkeys.xml create mode 100644 src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs create mode 100644 src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs create mode 100644 src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialProviderService.cs create mode 100644 src/keepass2android-app/services/Kp2aCredentialProvider/PasskeyOptionParsingHelper.cs create mode 100644 src/keepass2android-app/services/Kp2aCredentialProvider/UserVerificationHelper.cs diff --git a/src/KeePass.sln b/src/KeePass.sln index 4123c9ff1..229c14219 100644 --- a/src/KeePass.sln +++ b/src/KeePass.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.4.33205.214 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ZlibAndroid", "ZlibAndroid\ZlibAndroid.csproj", "{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}" EndProject @@ -31,6 +31,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kp2aAutofillParser.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DropboxBinding", "DropboxBinding\DropboxBinding.csproj", "{2FE6E335-E834-4F86-AB83-2C5D225DA929}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kp2aPasskey.Tests", "Kp2aPasskey.Tests\Kp2aPasskey.Tests.csproj", "{093AF66F-C59F-041F-2B79-06532DC19E8F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kp2aPasskey.Core", "Kp2aPasskey.Core\Kp2aPasskey.Core.csproj", "{6B081853-9DEB-9E9F-FF45-6758B0C357CC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -395,6 +399,54 @@ Global {2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU {2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU {2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|x64.Build.0 = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Win32.ActiveCfg = Debug|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Win32.Build.0 = Debug|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|x64.ActiveCfg = Debug|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|x64.Build.0 = Debug|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Any CPU.Build.0 = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Win32.ActiveCfg = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Win32.Build.0 = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|x64.ActiveCfg = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|x64.Build.0 = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Mixed Platforms.Build.0 = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Win32.ActiveCfg = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU + {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|x64.Build.0 = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Win32.ActiveCfg = Debug|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Win32.Build.0 = Debug|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|x64.ActiveCfg = Debug|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|x64.Build.0 = Debug|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Release|Any CPU.Build.0 = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Release|Win32.ActiveCfg = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Release|Win32.Build.0 = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Release|x64.ActiveCfg = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Release|x64.Build.0 = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.ReleaseNoNet|Mixed Platforms.Build.0 = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.ReleaseNoNet|Win32.ActiveCfg = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU + {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.ReleaseNoNet|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Kp2aBusinessLogic/database/edit/CreateDB.cs b/src/Kp2aBusinessLogic/database/edit/CreateDB.cs index 5e03ab73d..3983ac9da 100644 --- a/src/Kp2aBusinessLogic/database/edit/CreateDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/CreateDB.cs @@ -28,6 +28,7 @@ namespace keepass2android public class CreateDb : OperationWithFinishHandler { + public const string DefaultDbName = "Keepass2Android Password Database"; private readonly IOConnectionInfo _ioc; private readonly bool _dontSave; private readonly IKp2aApp _app; @@ -68,7 +69,7 @@ public override void Run() db.KpDatabase.New(_ioc, _key, _app.GetFileStorage(_ioc).GetFilenameWithoutPathAndExt(_ioc)); db.KpDatabase.KdfParameters = (new AesKdf()).GetDefaultParameters(); - db.KpDatabase.Name = "Keepass2Android Password Database"; + db.KpDatabase.Name = DefaultDbName; //re-set the name of the root group because the PwDatabase uses UrlUtil which is not appropriate for all file storages: db.KpDatabase.RootGroup.Name = _app.GetFileStorage(_ioc).GetFilenameWithoutPathAndExt(_ioc); diff --git a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs new file mode 100644 index 000000000..592d97f85 --- /dev/null +++ b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs @@ -0,0 +1,448 @@ +// Derived from KeePassDX (https://github.com/Kunzisoft/KeePassDX) +// Original work Copyright 2025 Jeremy Jamet / Kunzisoft. +// Licensed under the GNU General Public License v3 or later. +// +// Modifications Copyright 2026 Philipp Crocoll. +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using System.Security.Cryptography; +using System.Text; +using Android.Util; +using Kp2aPasskey.Core; +using Org.Json; +using PeterO.Cbor; + +namespace keepass2android.services.Kp2aCredentialProvider.Passkey +{ + /// + /// Builds authenticator data structure for FIDO2/WebAuthn protocol. + /// + /// This is a core component of the WebAuthn specification that creates cryptographic evidence + /// during both passkey registration (attestation) and authentication (assertion). + /// + /// The authenticator data proves: + /// - The request came from the correct relying party (via RP ID hash) + /// - User presence and verification status + /// - Whether the credential is backed up and eligible for sync + /// - For registration: includes attested credential data (AAGUID + credential ID + public key) + /// + /// Structure per https://www.w3.org/TR/webauthn-3/#table-authData: + /// - rpIdHash: 32 bytes (SHA-256 of relying party ID) + /// - flags: 1 byte (bit flags for UP/UV/BE/BS/AT/ED) + /// - signCount: 4 bytes (signature counter, always 0 for keepass2android) + /// - attestedCredentialData: variable length (only present when AT flag is set) + /// - extensions: variable length (only present when ED flag is set) + /// + /// Used by: + /// - AuthenticatorAttestationResponse: During passkey creation + /// - AuthenticatorAssertionResponse: During passkey authentication + /// + public static class AuthenticatorDataBuilder + { + /// + /// Builds the authenticator data byte array per WebAuthn specification. + /// + /// The relying party identifier (typically domain name) as UTF-8 bytes + /// True if user presence was verified (e.g., user interacted with device) + /// True if user was cryptographically verified (biometric/PIN) + /// True if credential can be backed up to other devices (BE flag) + /// True if credential is currently backed up (BS flag) + /// True to set AT flag (indicates attested credential data follows) + /// Authenticator data byte array (37+ bytes) + public static byte[] BuildAuthenticatorData( + byte[] relyingPartyId, + bool userPresent, + bool userVerified, + bool backupEligibility, + bool backupState, + bool attestedCredentialData = false + ) + { + // Build flags byte per WebAuthn spec + // https://www.w3.org/TR/webauthn-3/#table-authData + byte flags = 0; + + // Bit 0: User Present (UP) - User interacted with authenticator + if (userPresent) + flags |= 0x01; + + // Bit 2: User Verified (UV) - User verified via biometric/PIN + if (userVerified) + flags |= 0x04; + + // Bit 3: Backup Eligibility (BE) - Credential can be backed up + // Indicates if this credential can be synced to other devices + if (backupEligibility) + flags |= 0x08; + + // Bit 4: Backup State (BS) - Credential is currently backed up + // Indicates if this credential is currently synced/backed up + if (backupState) + flags |= 0x10; + + // Bit 6: Attested Credential Data (AT) - Credential data present + // Set during registration to indicate AAGUID + credentialId + publicKey follow + if (attestedCredentialData) + flags |= 0x40; + + // Note: Bit 1 (reserved), Bit 5 (reserved), Bit 7 (ED - Extensions Data) not used + + // Construct authenticator data: rpIdHash (32 bytes) + flags (1 byte) + signCount (4 bytes) + // signCount is always 0 for keepass2android (we don't track signature counts) + return PasskeyCryptoHelper.HashSha256(relyingPartyId) + .Concat([flags]) + .Concat("\0\0\0\0"u8.ToArray()) // signCount = 0 (big-endian). This is explicitly allowed per spec. + .ToArray(); + } + } + + /// + /// Client data response interface + /// + public interface IClientDataResponse + { + byte[] HashData(); + string BuildResponse(); + } + + /// + /// Client data response when hash is pre-calculated by the system. + /// Used for privileged apps where Android provides the pre-calculated hash. + /// Always returns a placeholder for clientDataJSON as the system already has the actual JSON. + /// + public class ClientDataDefinedResponse(byte[] clientDataHash) : IClientDataResponse + { + private const string ClientDataJsonPrivileged = ""; + + public byte[] HashData() => clientDataHash; + + public string BuildResponse() + { + // Always return placeholder for privileged contexts + // The system already has the actual clientDataJSON + return ClientDataJsonPrivileged; + } + } + + /// + /// Client data response built from challenge and origin. + /// + public class ClientDataBuildResponse : IClientDataResponse + { + public enum RequestType + { + Create, + Get + } + + private readonly JSONObject _clientDataJson; + + public ClientDataBuildResponse(RequestType type, byte[] challenge, string origin) + { + + // Build the client data JSON object + _clientDataJson = new JSONObject(); + _clientDataJson.Put("type", type == RequestType.Create ? "webauthn.create" : "webauthn.get"); + _clientDataJson.Put("challenge", Base64EncodeUrlSafe(challenge)); + _clientDataJson.Put("origin", origin); + } + + public byte[] HashData() + { + // Hash the RAW JSON string (not base64-encoded) + using var sha256 = SHA256.Create(); + return sha256.ComputeHash(Encoding.UTF8.GetBytes(_clientDataJson.ToString())); + } + + public string BuildResponse() + { + // Return base64-encoded JSON (matching KeePassDX) + return Base64EncodeUrlSafe(Encoding.UTF8.GetBytes(_clientDataJson.ToString())); + } + + private static string Base64EncodeUrlSafe(byte[] data) + { + return Base64.EncodeToString(data, Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap); + } + } + + /// + /// Authenticator Attestation Response for passkey creation (registration). + /// + /// This class constructs the response returned to the relying party when a new passkey + /// is created. It implements the WebAuthn AuthenticatorAttestationResponse structure. + /// + /// The response contains: + /// - clientDataJSON: Base64-encoded JSON containing challenge, origin, and type + /// - authenticatorData: Binary data including RP ID hash, flags, counter, and credential info + /// - attestationObject: CBOR-encoded object containing format, statement, and auth data + /// - transports: Supported transport methods (internal, hybrid) + /// - publicKey: The credential's public key in CBOR format + /// - publicKeyAlgorithm: COSE algorithm identifier (ES256=-7, RS256=-257, EdDSA=-8) + /// + /// The authenticatorData includes: + /// - AAGUID: Authenticator Attestation GUID identifying keepass2android + /// - Credential ID: Unique identifier for this credential + /// - Credential Public Key: COSE-encoded public key + /// - Flags: AT (attested credential data present), BE/BS (backup eligibility/state), UP/UV + /// + /// Attestation format is "none" as we don't provide attestation signatures. + /// + /// See: https://www.w3.org/TR/webauthn-3/#authenticatorattestationresponse + /// + public class AuthenticatorAttestationResponse( + PublicKeyCredentialCreationOptions requestOptions, + byte[] credentialId, + byte[] credentialPublicKey, + bool userPresent, + bool userVerified, + bool backupEligibility, + bool backupState, + long publicKeyTypeId, + byte[] publicKeySpki, + IClientDataResponse clientDataResponse + ) + + { + // AAGUID in RFC 4122 (big-endian) format, not Microsoft's mixed-endian format + + public static byte[] AaGuid1 { get; } = + [ + 0xea, 0xec, 0xde, 0xf2, 0x1c, 0x31, 0x56, 0x34, + 0x86, 0x39, 0xf1, 0xcb, 0xd9, 0xc0, 0x0a, 0x08 + ]; + + private byte[] BuildAuthData() + { + var authData = AuthenticatorDataBuilder.BuildAuthenticatorData( + relyingPartyId: Encoding.UTF8.GetBytes(requestOptions.RelyingPartyEntity.Id), + userPresent: userPresent, + userVerified: userVerified, + backupEligibility: backupEligibility, + backupState: backupState, + attestedCredentialData: true + ); + + // Log for debugging + Log.Debug("AuthenticatorAttestationResponse", + $"Base authData length: {authData.Length}, RP ID: {requestOptions.RelyingPartyEntity.Id}"); + Log.Debug("AuthenticatorAttestationResponse", + $"AAGUID length: {AaGuid1.Length}, CredentialId length: {credentialId.Length}"); + Log.Debug("AuthenticatorAttestationResponse", + $"CredentialPublicKey length: {credentialPublicKey.Length}"); + Log.Debug("AuthenticatorAttestationResponse", + $"CredentialPublicKey CBOR hex: {BitConverter.ToString(credentialPublicKey)}"); + + // Append AAGUID + credIdLen + credentialId + credentialPublicKey + var credIdLen = new[] + { + (byte)(credentialId.Length >> 8), + (byte)credentialId.Length + }; + + var result = authData + .Concat(AaGuid1) + .Concat(credIdLen) + .Concat(credentialId) + .Concat(credentialPublicKey) + .ToArray(); + + Log.Debug("AuthenticatorAttestationResponse", + $"Final authData length: {result.Length}"); + + return result; + } + + private byte[] BuildAttestationObject() + { + // https://www.w3.org/TR/webauthn-3/#attestation-object + var cbor = CBORObject.NewMap() + .Add("fmt", "none") + .Add("attStmt", CBORObject.NewMap()) + .Add("authData", BuildAuthData()); + + return cbor.EncodeToBytes(); + } + + public JSONObject ToJson() + { + var authData = BuildAuthData(); + var attestationObject = BuildAttestationObject(); + + // Log hex bytes for debugging - FULL dumps for comparison with KeePassDX + Log.Debug("AuthenticatorAttestationResponse", + $"=== FULL AuthData ({authData.Length} bytes) ==="); + Log.Debug("AuthenticatorAttestationResponse", + BitConverter.ToString(authData)); + Log.Debug("AuthenticatorAttestationResponse", + $"=== FULL AttestationObject ({attestationObject.Length} bytes) ==="); + Log.Debug("AuthenticatorAttestationResponse", + BitConverter.ToString(attestationObject)); + + var json = new JSONObject(); + json.Put("clientDataJSON", clientDataResponse.BuildResponse()); + json.Put("authenticatorData", Base64EncodeUrlSafe(authData)); + var transports = new JSONArray(); + transports.Put("internal"); + transports.Put("hybrid"); + json.Put("transports", transports); + json.Put("publicKey", Base64EncodeUrlSafe(publicKeySpki)); + json.Put("publicKeyAlgorithm", publicKeyTypeId); + json.Put("attestationObject", Base64EncodeUrlSafe(attestationObject)); + return json; + } + + private static string Base64EncodeUrlSafe(byte[] data) + { + return Base64.EncodeToString(data, Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap); + } + } + + /// + /// Authenticator Assertion Response for passkey authentication (login). + /// + /// This class constructs the response returned to the relying party when authenticating + /// with an existing passkey. It implements the WebAuthn AuthenticatorAssertionResponse. + /// + /// The response contains: + /// - clientDataJSON: Base64-encoded JSON containing challenge, origin, and type (webauthn.get) + /// - authenticatorData: Binary data with RP ID hash, flags, and signature counter + /// - signature: Cryptographic signature over authenticatorData || hash(clientDataJSON) + /// - userHandle: Base64-encoded user ID to identify which user is authenticating + /// + /// The signature proves: + /// 1. Possession of the private key (only the key holder can create valid signatures) + /// 2. Freshness via the challenge (prevents replay attacks) + /// 3. Binding to the relying party via RP ID hash in authenticatorData + /// + /// The authenticatorData includes flags indicating: + /// - User Present (UP): User interacted with the authenticator + /// - User Verified (UV): User was verified (biometric/PIN) + /// - Backup Eligibility (BE) and State (BS): Credential sync status + /// + /// The signature is computed using the credential's private key (ECDSA P-256, RSA, or EdDSA) + /// over the concatenation of authenticatorData and the SHA-256 hash of clientDataJSON. + /// + /// See: https://www.w3.org/TR/webauthn-3/#authenticatorassertionresponse + /// + public class AuthenticatorAssertionResponse + { + private readonly string _userHandle; + private readonly byte[] _authenticatorData; + private readonly byte[] _signature; + + public AuthenticatorAssertionResponse( + PublicKeyCredentialRequestOptions requestOptions, + bool userPresent, + bool userVerified, + bool backupEligibility, + bool backupState, + string userHandle, + string privateKeyPem, + IClientDataResponse clientDataResponse + ) + { + _userHandle = userHandle; + + Log.Debug("AuthenticatorAssertionResponse", "=== AUTHENTICATION DEBUG ==="); + Log.Debug("AuthenticatorAssertionResponse", $"RP ID: {requestOptions.RpId}"); + Log.Debug("AuthenticatorAssertionResponse", $"Challenge (base64): {Base64.EncodeToString(requestOptions.Challenge, Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap)}"); + + _authenticatorData = AuthenticatorDataBuilder.BuildAuthenticatorData( + relyingPartyId: Encoding.UTF8.GetBytes(requestOptions.RpId), + userPresent: userPresent, + userVerified: userVerified, + backupEligibility: backupEligibility, + backupState: backupState + ); + + Log.Debug("AuthenticatorAssertionResponse", $"AuthenticatorData ({_authenticatorData.Length} bytes): {BitConverter.ToString(_authenticatorData)}"); + + var clientDataHash = clientDataResponse.HashData(); + Log.Debug("AuthenticatorAssertionResponse", $"ClientDataHash ({clientDataHash.Length} bytes): {BitConverter.ToString(clientDataHash)}"); + Log.Debug("AuthenticatorAssertionResponse", $"ClientDataJSON: {Encoding.UTF8.GetString(Base64.Decode(clientDataResponse.BuildResponse(), Base64Flags.UrlSafe | Base64Flags.NoPadding))}"); + + // Sign: authenticatorData || clientDataHash + var dataToSign = _authenticatorData.Concat(clientDataHash).ToArray(); + Log.Debug("AuthenticatorAssertionResponse", $"DataToSign ({dataToSign.Length} bytes): {BitConverter.ToString(dataToSign)}"); + + _signature = PasskeyCryptoHelper.Sign(privateKeyPem, dataToSign); + Log.Debug("AuthenticatorAssertionResponse", $"Signature ({_signature.Length} bytes): {BitConverter.ToString(_signature)}"); + } + + public JSONObject ToJson(IClientDataResponse clientDataResponse) + { + var json = new JSONObject(); + var clientDataJson = clientDataResponse.BuildResponse(); + var authenticatorDataB64 = Base64EncodeUrlSafe(_authenticatorData); + var signatureB64 = Base64EncodeUrlSafe(_signature); + + json.Put("clientDataJSON", clientDataJson); + json.Put("authenticatorData", authenticatorDataB64); + json.Put("signature", signatureB64); + json.Put("userHandle", _userHandle); + + Log.Debug("AuthenticatorAssertionResponse", "=== RESPONSE JSON FIELDS ==="); + Log.Debug("AuthenticatorAssertionResponse", $"clientDataJSON: {clientDataJson}"); + Log.Debug("AuthenticatorAssertionResponse", $"authenticatorData (base64): {authenticatorDataB64}"); + Log.Debug("AuthenticatorAssertionResponse", $"signature (base64): {signatureB64}"); + Log.Debug("AuthenticatorAssertionResponse", $"userHandle: {_userHandle}"); + + return json; + } + + private static string Base64EncodeUrlSafe(byte[] data) + { + return Base64.EncodeToString(data, Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap); + } + } + + /// + /// FIDO Public Key Credential wrapper. + /// + public class FidoPublicKeyCredential(string id, JSONObject response, string authenticatorAttachment = "platform") + { + + public string ToJson() + { + var json = new JSONObject(); + json.Put("id", id); + json.Put("rawId", id); + json.Put("type", "public-key"); + json.Put("authenticatorAttachment", authenticatorAttachment); + json.Put("response", response); + json.Put("clientExtensionResults", new JSONObject()); // TODO credProps + + var jsonString = json.ToString(); + Log.Debug("FidoPublicKeyCredential", "=== COMPLETE CREDENTIAL JSON ==="); + Log.Debug("FidoPublicKeyCredential", $"id: {id}"); + Log.Debug("FidoPublicKeyCredential", $"rawId: {id}"); + Log.Debug("FidoPublicKeyCredential", $"type: public-key"); + Log.Debug("FidoPublicKeyCredential", $"authenticatorAttachment: {authenticatorAttachment}"); + Log.Debug("FidoPublicKeyCredential", $"Full JSON length: {jsonString.Length}"); + + // Log JSON in chunks to avoid truncation + const int chunkSize = 200; + for (int i = 0; i < jsonString.Length; i += chunkSize) + { + int length = Math.Min(chunkSize, jsonString.Length - i); + Log.Debug("FidoPublicKeyCredential", $"Complete JSON chunk {i / chunkSize}: {jsonString.Substring(i, length)}"); + } + + return jsonString; + } + } +} diff --git a/src/Kp2aPasskey.Core/Kp2aPasskey.Core.csproj b/src/Kp2aPasskey.Core/Kp2aPasskey.Core.csproj new file mode 100644 index 000000000..f8977810c --- /dev/null +++ b/src/Kp2aPasskey.Core/Kp2aPasskey.Core.csproj @@ -0,0 +1,19 @@ + + + + net9.0-android + enable + enable + keepass2android.services.Kp2aCredentialProvider.Passkey + + + + + + + + + + + + diff --git a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs new file mode 100644 index 000000000..d32f0acf0 --- /dev/null +++ b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs @@ -0,0 +1,457 @@ +// Derived from KeePassDX (https://github.com/Kunzisoft/KeePassDX) +// Original work Copyright 2025 Jeremy Jamet / Kunzisoft. +// Licensed under the GNU General Public License v3 or later. +// +// Modifications Copyright 2026 Philipp Crocoll. +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using System.Security.Cryptography; +using Android.Util; +using Java.Lang; +using Java.Security; +using Java.Security.Spec; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; +using PeterO.Cbor; +using AndroidKeyPair = Java.Security.KeyPair; +using Exception = System.Exception; + +namespace keepass2android.services.Kp2aCredentialProvider.Passkey +{ + /// + /// Wrapper for BouncyCastle Ed25519 private key to implement Java IPrivateKey interface + /// + internal class Ed25519PrivateKeyWrapper(byte[] pkcs8EncodedKey) : Java.Lang.Object, IPrivateKey + { + public string Algorithm => "Ed25519"; + public string Format => "PKCS#8"; + public byte[] GetEncoded() => pkcs8EncodedKey; + } + + /// + /// Wrapper for BouncyCastle Ed25519 public key to implement Java IPublicKey interface + /// + public class Ed25519PublicKeyWrapper(byte[] x509EncodedKey) : Java.Lang.Object, IPublicKey + { + public string Algorithm => "Ed25519"; + public string Format => "X.509"; + public byte[] GetEncoded() => x509EncodedKey; + } + + /// + /// Cryptography helper for FIDO2/WebAuthn passkey operations + /// + public static class PasskeyCryptoHelper + { + + // COSE Algorithm Identifiers (https://www.iana.org/assignments/cose/cose.xhtml) + public const long CoseAlgEs256 = -7; // ECDSA with SHA-256 (ES256_ALGORITHM) + public const long CoseAlgRs256 = -257; // RSASSA-PKCS1-v1_5 with SHA-256 (RS256_ALGORITHM) + public const long CoseAlgEd25519 = -8; // EdDSA with Ed25519 (ED_DSA_ALGORITHM) + + // COSE Key Common Parameter Labels (https://www.iana.org/assignments/cose/cose.xhtml#key-common-parameters) + private const int CoseKeyKeytype = 1; // Key Type + private const int CoseKeyAlgorithm = 3; // Key Algorithm + private const int CoseKeyCurve = -1; // Curve (EC2/OKP) or Modulus (RSA) + private const int CoseKeyX = -2; // X-coordinate (EC2/OKP) or Public Exponent (RSA) + private const int CoseKeyY = -3; // Y-coordinate (EC2) + + // COSE Key Type Values (https://www.iana.org/assignments/cose/cose.xhtml#key-type) + private const int CoseKtyOkp = 1; // Octet Key Pair (Ed25519) + private const int CoseKtyEc2 = 2; // Elliptic Curve Keys with x- and y-coordinate pair (P-256) + private const int CoseKtyRsa = 3; // RSA + + // COSE Elliptic Curves (https://www.iana.org/assignments/cose/cose.xhtml#elliptic-curves) + private const int CoseCurveP256 = 1; // NIST P-256 (secp256r1) + private const int CoseCurveEd25519 = 6; // Ed25519 for use with EdDSA + + private const int Rs256KeySizeInBits = 2048; + + /// + /// Generate a key pair for passkey based on the supported algorithms + /// + /// List of COSE algorithm identifiers + /// Tuple of (KeyPair, algorithm ID) or null if no supported algorithm found + public static (AndroidKeyPair keyPair, long algorithmId)? GenerateKeyPair(IEnumerable supportedAlgorithms) + { + // IMPORTANT: Iterate through algorithms in the order provided by the relying party + // This respects the RP's preference and matches the WebAuthn spec + var algorithmIds = supportedAlgorithms.ToList(); + foreach (var algorithmId in algorithmIds) + { + switch (algorithmId) + { + case CoseAlgEd25519: + { + var keyPair = GenerateEd25519KeyPair(); + if (keyPair != null) + { + Log.Debug("PasskeyCryptoHelper", $"Generated Ed25519 key pair (algorithm {algorithmId})"); + return (keyPair, CoseAlgEd25519); + } + break; + } + case CoseAlgEs256: + { + var keyPair = GenerateEs256KeyPair(); + if (keyPair != null) + { + Log.Debug("PasskeyCryptoHelper", $"Generated ES256 key pair (algorithm {algorithmId})"); + return (keyPair, CoseAlgEs256); + } + break; + } + case CoseAlgRs256: + { + var keyPair = GenerateRs256KeyPair(); + if (keyPair != null) + { + Log.Debug("PasskeyCryptoHelper", $"Generated RS256 key pair (algorithm {algorithmId})"); + return (keyPair, CoseAlgRs256); + } + break; + } + default: + // Unsupported algorithm, try next one + Log.Debug("PasskeyCryptoHelper", $"Unsupported algorithm {algorithmId}, skipping"); + break; + } + } + + var errorMsg = $"No supported algorithm found. Requested: [{string.Join(", ", algorithmIds)}]. " + + $"Supported: EdDSA(-8, may vary by device), ES256(-7), RS256(-257)"; + Log.Error("PasskeyCryptoHelper", errorMsg); + return null; + } + + /// + /// Generate an EC (secp256r1 / P-256) key pair + /// + private static AndroidKeyPair? GenerateEs256KeyPair() + { + try + { + var es256CurveNameBc = "secp256r1"; + var spec = new ECGenParameterSpec(es256CurveNameBc); + var keyPairGenerator = KeyPairGenerator.GetInstance("EC"); + keyPairGenerator?.Initialize(spec); + return keyPairGenerator?.GenerateKeyPair(); + } + catch (Exception ex) + { + Log.Error("PasskeyCryptoHelper", "Failed to generate EC key pair", ex); + return null; + } + } + + /// + /// Generate an RSA key pair (2048 bits) + /// + private static AndroidKeyPair? GenerateRs256KeyPair() + { + try + { + var keyPairGenerator = KeyPairGenerator.GetInstance("RSA"); + keyPairGenerator?.Initialize(Rs256KeySizeInBits); + return keyPairGenerator?.GenerateKeyPair(); + } + catch (Exception ex) + { + Log.Error("PasskeyCryptoHelper", "Failed to generate RSA key pair", ex); + return null; + } + } + + /// + /// Generate an Ed25519 key pair + /// Uses BouncyCastle C# API since Android's native Ed25519 requires Keystore + /// + private static AndroidKeyPair? GenerateEd25519KeyPair() + { + try + { + Log.Debug("PasskeyCryptoHelper", $"Attempting to generate Ed25519 key pair on Android {Android.OS.Build.VERSION.SdkInt} (API {(int)Android.OS.Build.VERSION.SdkInt})"); + + // Android's Ed25519 KeyPairGenerator requires Keystore, so we use BouncyCastle C# API + Log.Debug("PasskeyCryptoHelper", "Using BouncyCastle C# API to generate Ed25519 key pair"); + + var keyPairGenerator = new Ed25519KeyPairGenerator(); + keyPairGenerator.Init(new Ed25519KeyGenerationParameters(new Org.BouncyCastle.Security.SecureRandom())); + + var bcKeyPair = keyPairGenerator.GenerateKeyPair(); + + // Convert BouncyCastle key pair to encoded bytes + var privateKeyParams = (Ed25519PrivateKeyParameters)bcKeyPair.Private; + var publicKeyParams = (Ed25519PublicKeyParameters)bcKeyPair.Public; + + // Get PKCS#8 encoded private key + var privateKeyInfo = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(privateKeyParams); + var privateKeyBytes = privateKeyInfo.GetEncoded(); + + // Get X.509 encoded public key + var publicKeyInfo = Org.BouncyCastle.X509.SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(publicKeyParams); + var publicKeyBytes = publicKeyInfo.GetEncoded(); + + Log.Debug("PasskeyCryptoHelper", $"Generated Ed25519 keys - Private: {privateKeyBytes.Length} bytes, Public: {publicKeyBytes.Length} bytes"); + + // Create wrapper keys that implement Java interfaces + var privateKey = new Ed25519PrivateKeyWrapper(privateKeyBytes); + var publicKey = new Ed25519PublicKeyWrapper(publicKeyBytes); + + var keyPair = new AndroidKeyPair(publicKey, privateKey); + Log.Debug("PasskeyCryptoHelper", $"Ed25519 key pair wrapped successfully. Public key algorithm: {keyPair.Public?.Algorithm}, Format: {keyPair.Public?.Format}"); + return keyPair; + } + catch (Exception ex) + { + Log.Error("PasskeyCryptoHelper", + $"Ed25519 generation failed (Android {Android.OS.Build.VERSION.SdkInt}): {ex.GetType().Name}: {ex.Message}", ex); + return null; + } + } + + /// + /// Convert a private key to PEM format for storage (PKCS#8). + /// Uses BouncyCastle PemWriter (PKCS#8 DER → base64 with PEM headers). + /// + public static string ConvertPrivateKeyToPem(IPrivateKey privateKey) + { + var encoded = privateKey?.GetEncoded() + ?? throw new ArgumentException("Cannot encode private key"); + + // Parse the PKCS#8 DER bytes into a BouncyCastle PrivateKeyInfo ASN.1 structure + var privateKeyInfo = Org.BouncyCastle.Asn1.Pkcs.PrivateKeyInfo.GetInstance(encoded); + + // Let PemWriter handle headers, base64 and line-wrapping + using var writer = new StringWriter(); + new PemWriter(writer).WriteObject(privateKeyInfo); + return writer.ToString().Trim(); + + } + + /// + /// Sign data with a private key using BouncyCastle + /// + /// PEM-formatted private key + /// Data to sign + /// Signature bytes in DER format + public static byte[] Sign(string privateKeyPem, byte[] dataToSign) + { + try + { + // Parse PEM once with BouncyCastle — covers PKCS#8, SEC1 (EC), PKCS#1 (RSA) + AsymmetricKeyParameter privateKeyParams; + using (var reader = new StringReader(privateKeyPem)) + { + var pemObject = new PemReader(reader).ReadObject(); + privateKeyParams = pemObject switch + { + AsymmetricCipherKeyPair kp => kp.Private, + AsymmetricKeyParameter kp => kp, + _ => throw new ArgumentException($"Unsupported PEM object type: {pemObject?.GetType().Name}") + }; + } + + // Determine signature algorithm directly from key type — no string matching on .Algorithm + string algorithmSignature = privateKeyParams switch + { + ECPrivateKeyParameters => "SHA256withECDSA", + RsaPrivateCrtKeyParameters => "SHA256withRSA", + Ed25519PrivateKeyParameters => "Ed25519", + _ => throw new SecurityException($"Unknown key type: {privateKeyParams.GetType().Name}") + }; + + Log.Debug("PasskeyCryptoHelper", $"Signing with {algorithmSignature} (key type: {privateKeyParams.GetType().Name})"); + + // Sign using BouncyCastle C# API + var signer = SignerUtilities.GetSigner(algorithmSignature); + signer.Init(true, privateKeyParams); + signer.BlockUpdate(dataToSign, 0, dataToSign.Length); + var signatureBytes = signer.GenerateSignature(); + + Log.Debug("PasskeyCryptoHelper", $"Signed {dataToSign.Length} bytes → {signatureBytes.Length} byte signature"); + return signatureBytes; + } + catch (Exception ex) + { + Log.Error("PasskeyCryptoHelper", $"Failed to sign data: {ex.Message}"); + throw new Exception("Failed to sign data", ex); + } + } + + + /// + /// Convert a public key to CBOR format for COSE encoding + /// + public static CBORObject? ConvertPublicKeyToMap(IPublicKey publicKey, long keyTypeId) + { + if (publicKey == null) + return null; + + var keyAlgorithm = publicKey.Algorithm; + + if (keyTypeId == CoseAlgEd25519 && (keyAlgorithm?.Equals("Ed25519", StringComparison.OrdinalIgnoreCase) == true || + keyAlgorithm?.Equals("EdDSA", StringComparison.OrdinalIgnoreCase) == true)) + { + return ConvertEd25519PublicKeyToMap(publicKey); + } + else if (keyTypeId == CoseAlgEs256 && keyAlgorithm?.Equals("EC", StringComparison.OrdinalIgnoreCase) == true) + { + return ConvertEcPublicKeyToMap(publicKey); + } + else if (keyTypeId == CoseAlgRs256 && keyAlgorithm?.Equals("RSA", StringComparison.OrdinalIgnoreCase) == true) + { + return ConvertRsaPublicKeyToMap(publicKey); + } + + return null; + } + + private static CBORObject ConvertEcPublicKeyToMap(IPublicKey ecPublicKey) + { + // Use BouncyCastle to parse the X.509 SubjectPublicKeyInfo + var encoded = ecPublicKey.GetEncoded(); + if (encoded == null) throw new ArgumentException("Cannot encode EC public key"); + + var bcPublicKey = PublicKeyFactory.CreateKey(encoded); + if (bcPublicKey is not ECPublicKeyParameters ecParams) + throw new ArgumentException("Public key is not an EC key"); + + // Extract coordinates from BouncyCastle EC parameters + var x = ecParams.Q.AffineXCoord.GetEncoded(); + var y = ecParams.Q.AffineYCoord.GetEncoded(); + + // Ensure coordinates are exactly 32 bytes (P-256) + if (x.Length != 32 || y.Length != 32) + throw new ArgumentException($"Invalid P-256 coordinate length: x={x.Length}, y={y.Length}"); + + // Build COSE EC2 key structure + return CBORObject.NewMap() + .Add(CoseKeyKeytype, CoseKtyEc2) + .Add(CoseKeyAlgorithm, (int)CoseAlgEs256) + .Add(CoseKeyCurve, CoseCurveP256) + .Add(CoseKeyX, x) + .Add(CoseKeyY, y); + } + + private static CBORObject ConvertEd25519PublicKeyToMap(IPublicKey ed25519PublicKey) + { + // Use BouncyCastle to parse the X.509 SubjectPublicKeyInfo + var encoded = ed25519PublicKey.GetEncoded(); + if (encoded == null) throw new ArgumentException("Cannot encode Ed25519 public key"); + + var bcPublicKey = PublicKeyFactory.CreateKey(encoded); + if (bcPublicKey is not Ed25519PublicKeyParameters ed25519Params) + throw new ArgumentException("Public key is not an Ed25519 key"); + + // Extract public key bytes from BouncyCastle parameters + var publicKeyBytes = ed25519Params.GetEncoded(); + + // Ed25519 public keys must be exactly 32 bytes + if (publicKeyBytes.Length != 32) + throw new ArgumentException($"Invalid Ed25519 public key length: {publicKeyBytes.Length}"); + + // Build COSE OKP key structure + return CBORObject.NewMap() + .Add(CoseKeyKeytype, CoseKtyOkp) + .Add(CoseKeyAlgorithm, (int)CoseAlgEd25519) + .Add(CoseKeyCurve, CoseCurveEd25519) + .Add(CoseKeyX, publicKeyBytes); + } + + private static CBORObject ConvertRsaPublicKeyToMap(IPublicKey rsaPublicKey) + { + // Extract RSA key information from encoded key data + // Use KeyFactory to convert to RSAPublicKeySpec to access modulus and exponent + try + { + var encoded = rsaPublicKey.GetEncoded(); + if (encoded == null) + throw new ArgumentException("Cannot encode RSA public key"); + + var keyFactory = KeyFactory.GetInstance("RSA"); + var rsaSpec = keyFactory?.GetKeySpec(rsaPublicKey, Class.FromType(typeof(RSAPublicKeySpec))); + + if (rsaSpec is RSAPublicKeySpec rsaPublicKeySpec) + { + var n = rsaPublicKeySpec.Modulus?.ToByteArray(); + var e = rsaPublicKeySpec.PublicExponent?.ToByteArray(); + + n = RemoveLeadingZero(n); + e = RemoveLeadingZero(e); + + // Build COSE RSA key structure + return CBORObject.NewMap() + .Add(CoseKeyKeytype, CoseKtyRsa) + .Add(CoseKeyAlgorithm, (int)CoseAlgRs256) + .Add(CoseKeyCurve, n!) // n: modulus (reusing CRV label per COSE spec for RSA) + .Add(CoseKeyX, e!); // e: exponent (reusing X label per COSE spec for RSA) + } + + throw new ArgumentException("Failed to extract RSA key specification"); + } + catch (Exception ex) + { + throw new ArgumentException("Failed to convert RSA public key to map", ex); + } + } + + /// + /// Convert public key to X.509 SubjectPublicKeyInfo format for JSON response + /// Returns the full encoded public key in SPKI format, not just the EC point + /// Note: Java's GetEncoded() already returns EC points in UNCOMPRESSED format (0x04 || x || y) + /// within the X.509 SPKI structure, so we don't need to explicitly set the point format + /// like BouncyCastle's BCECPublicKey.setPointFormat("UNCOMPRESSED") + /// + public static byte[]? ConvertPublicKey(IPublicKey publicKey, long keyTypeId) + { + if (publicKey == null) + return null; + + // Return the full X.509 SubjectPublicKeyInfo (SPKI) format + // This is what GetEncoded() returns by default for Java public keys + return publicKey.GetEncoded(); + } + + private static byte[]? RemoveLeadingZero(byte[]? bytes) + { + if (bytes == null || bytes.Length == 0) + return bytes; + + if (bytes[0] == 0 && bytes.Length > 1) + { + var result = new byte[bytes.Length - 1]; + Array.Copy(bytes, 1, result, 0, result.Length); + return result; + } + + return bytes; + } + + + /// + /// Compute SHA-256 hash of data + /// + public static byte[] HashSha256(byte[] data) + { + using var sha256 = SHA256.Create(); + return sha256.ComputeHash(data); + } + } +} diff --git a/src/Kp2aPasskey.Core/PasskeyData.cs b/src/Kp2aPasskey.Core/PasskeyData.cs new file mode 100644 index 000000000..28b773834 --- /dev/null +++ b/src/Kp2aPasskey.Core/PasskeyData.cs @@ -0,0 +1,98 @@ +// Derived from KeePassDX (https://github.com/Kunzisoft/KeePassDX) +// Original work Copyright 2025 Jeremy Jamet / Kunzisoft. +// Licensed under the GNU General Public License v3 or later. +// +// Modifications Copyright 2026 Philipp Crocoll. +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using System; +using Android.OS; +using Java.Interop; + +namespace Kp2aPasskey.Core +{ + /// + /// Data class for FIDO2/WebAuthn passkey credentials + /// + [Android.Runtime.Preserve(AllMembers = true)] + public class PasskeyData : Java.Lang.Object, IParcelable + { + public string Username { get; set; } = string.Empty; + public string PrivateKeyPem { get; set; } = string.Empty; + public string CredentialId { get; set; } = string.Empty; + public string UserHandle { get; set; } = string.Empty; + public string RelyingParty { get; set; } = string.Empty; + public bool? BackupEligibility { get; set; } + public bool? BackupState { get; set; } + + public PasskeyData() + { + } + + public PasskeyData(Parcel parcel) + { + Username = parcel.ReadString() ?? string.Empty; + PrivateKeyPem = parcel.ReadString() ?? string.Empty; + CredentialId = parcel.ReadString() ?? string.Empty; + UserHandle = parcel.ReadString() ?? string.Empty; + RelyingParty = parcel.ReadString() ?? string.Empty; + + var hasBackupEligibility = parcel.ReadInt() == 1; + BackupEligibility = hasBackupEligibility ? parcel.ReadInt() == 1 : null; + + var hasBackupState = parcel.ReadInt() == 1; + BackupState = hasBackupState ? parcel.ReadInt() == 1 : null; + } + + public void WriteToParcel(Parcel dest, ParcelableWriteFlags flags) + { + dest.WriteString(Username); + dest.WriteString(PrivateKeyPem); + dest.WriteString(CredentialId); + dest.WriteString(UserHandle); + dest.WriteString(RelyingParty); + + dest.WriteInt(BackupEligibility.HasValue ? 1 : 0); + if (BackupEligibility.HasValue) + dest.WriteInt(BackupEligibility.Value ? 1 : 0); + + dest.WriteInt(BackupState.HasValue ? 1 : 0); + if (BackupState.HasValue) + dest.WriteInt(BackupState.Value ? 1 : 0); + } + + public int DescribeContents() => 0; + + [ExportField("CREATOR")] + public static ParcelableCreator InitializeCreator() + { + return new ParcelableCreator(); + } + + public class ParcelableCreator : Java.Lang.Object, IParcelableCreator + { + public Java.Lang.Object CreateFromParcel(Parcel source) + { + return new PasskeyData(source); + } + + public Java.Lang.Object[]? NewArray(int size) + { + return new PasskeyData[size]; + } + } + } +} diff --git a/src/Kp2aPasskey.Core/PasskeyStorage.cs b/src/Kp2aPasskey.Core/PasskeyStorage.cs new file mode 100644 index 000000000..c11fd4c5a --- /dev/null +++ b/src/Kp2aPasskey.Core/PasskeyStorage.cs @@ -0,0 +1,187 @@ +// Derived from KeePassDX (https://github.com/Kunzisoft/KeePassDX) +// Original work Copyright 2025 Jeremy Jamet / Kunzisoft. +// Licensed under the GNU General Public License v3 or later. +// +// Modifications Copyright 2026 Philipp Crocoll. +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using System; +using System.Linq; +using KeePassLib; +using KeePassLib.Security; + +namespace Kp2aPasskey.Core +{ + /// + /// Helper class for storing and retrieving passkey data in KeePass entries. + /// Passkeys are stored in ExtraFields (Strings) using the KPEX interop format, + /// compatible with KeePassXC and other apps supporting KPEX passkey fields. + /// + public static class PasskeyStorage + { + // ExtraField keys for passkey storage — KPEX interop format + // KPEX prefix (KeePass EXtension) is the established interop standard for passkey fields + public const string FIELD_USERNAME = "KPEX_PASSKEY_USERNAME"; + public const string FIELD_PRIVATE_KEY = "KPEX_PASSKEY_PRIVATE_KEY_PEM"; + public const string FIELD_CREDENTIAL_ID = "KPEX_PASSKEY_CREDENTIAL_ID"; + public const string FIELD_USER_HANDLE = "KPEX_PASSKEY_USER_HANDLE"; + public const string FIELD_RELYING_PARTY = "KPEX_PASSKEY_RELYING_PARTY"; + public const string FIELD_FLAG_BE = "KPEX_PASSKEY_FLAG_BE"; + public const string FIELD_FLAG_BS = "KPEX_PASSKEY_FLAG_BS"; + public const string PASSKEY_TAG = "Passkey"; + + /// + /// Store passkey data in a KeePass entry using ExtraFields (Strings) + /// + public static void StorePasskey(PwEntry entry, PasskeyData passkey) + { + if (entry == null || passkey == null) + throw new ArgumentNullException(); + + // Add Passkey tag + if (!entry.Tags.Contains(PASSKEY_TAG)) + { + var tags = new List(entry.Tags); + tags.Add(PASSKEY_TAG); + entry.Tags = tags; + } + + // Store passkey data in ExtraFields (Strings) + entry.Strings.Set(FIELD_USERNAME, new ProtectedString(false, passkey.Username)); + entry.Strings.Set(FIELD_PRIVATE_KEY, new ProtectedString(true, passkey.PrivateKeyPem)); // Protected + entry.Strings.Set(FIELD_CREDENTIAL_ID, new ProtectedString(true, passkey.CredentialId)); // Protected + entry.Strings.Set(FIELD_USER_HANDLE, new ProtectedString(true, passkey.UserHandle)); // Protected + entry.Strings.Set(FIELD_RELYING_PARTY, new ProtectedString(false, passkey.RelyingParty)); + + if (passkey.BackupEligibility.HasValue) + entry.Strings.Set(FIELD_FLAG_BE, new ProtectedString(false, passkey.BackupEligibility.Value.ToString().ToLower())); + + if (passkey.BackupState.HasValue) + entry.Strings.Set(FIELD_FLAG_BS, new ProtectedString(false, passkey.BackupState.Value.ToString().ToLower())); + + // Also store the username in the standard username field if not already set + if (string.IsNullOrEmpty(entry.Strings.ReadSafe(PwDefs.UserNameField))) + { + entry.Strings.Set(PwDefs.UserNameField, new ProtectedString(false, passkey.Username)); + } + + // Add passkey: URL for searchability + var url = entry.Strings.ReadSafe(PwDefs.UrlField); + var passkeyUrl = $"passkey:{passkey.RelyingParty}"; + if (!url.Contains(passkeyUrl)) + { + var newUrl = string.IsNullOrEmpty(url) ? passkeyUrl : $"{url}\n{passkeyUrl}"; + entry.Strings.Set(PwDefs.UrlField, new ProtectedString(false, newUrl)); + } + } + + /// + /// Retrieve passkey data from a KeePass entry + /// + public static PasskeyData? RetrievePasskey(PwEntry entry) + { + if (entry == null) + return null; + + // Check if entry contains passkey data (must have at minimum private key and credential ID) + if (!entry.Strings.Exists(FIELD_PRIVATE_KEY) || + !entry.Strings.Exists(FIELD_CREDENTIAL_ID)) + { + return null; + } + + var username = entry.Strings.ReadSafe(FIELD_USERNAME); + var privateKeyPem = entry.Strings.ReadSafe(FIELD_PRIVATE_KEY); + var credentialId = entry.Strings.ReadSafe(FIELD_CREDENTIAL_ID); + var userHandle = entry.Strings.ReadSafe(FIELD_USER_HANDLE); + var relyingParty = entry.Strings.ReadSafe(FIELD_RELYING_PARTY); + + // All required fields must be present + if (string.IsNullOrEmpty(username) || + string.IsNullOrEmpty(privateKeyPem) || + string.IsNullOrEmpty(credentialId) || + string.IsNullOrEmpty(userHandle) || + string.IsNullOrEmpty(relyingParty)) + { + return null; + } + + var passkey = new PasskeyData + { + Username = username, + PrivateKeyPem = privateKeyPem, + CredentialId = credentialId, + UserHandle = userHandle, + RelyingParty = relyingParty + }; + + // Parse optional boolean fields + var backupEligibleStr = entry.Strings.ReadSafe(FIELD_FLAG_BE); + if (!string.IsNullOrEmpty(backupEligibleStr) && bool.TryParse(backupEligibleStr, out var backupEligible)) + { + passkey.BackupEligibility = backupEligible; + } + + var backupStateStr = entry.Strings.ReadSafe(FIELD_FLAG_BS); + if (!string.IsNullOrEmpty(backupStateStr) && bool.TryParse(backupStateStr, out var backupState)) + { + passkey.BackupState = backupState; + } + + return passkey; + } + + /// + /// Check if an entry contains passkey data + /// + public static bool HasPasskey(PwEntry entry) + { + return entry != null && + (entry.Tags.Contains(PASSKEY_TAG) || + entry.Strings.Exists(FIELD_USERNAME) || + entry.Strings.Exists(FIELD_PRIVATE_KEY) || + entry.Strings.Exists(FIELD_CREDENTIAL_ID) || + entry.Strings.Exists(FIELD_USER_HANDLE) || + entry.Strings.Exists(FIELD_RELYING_PARTY)); + } + + /// + /// Remove passkey data from an entry + /// + public static void RemovePasskey(PwEntry entry) + { + if (entry == null) + return; + + // Remove tag + if (entry.Tags.Contains(PASSKEY_TAG)) + { + var tags = new List(entry.Tags); + tags.Remove(PASSKEY_TAG); + entry.Tags = tags; + } + + // Remove all passkey fields + entry.Strings.Remove(FIELD_USERNAME); + entry.Strings.Remove(FIELD_PRIVATE_KEY); + entry.Strings.Remove(FIELD_CREDENTIAL_ID); + entry.Strings.Remove(FIELD_USER_HANDLE); + entry.Strings.Remove(FIELD_RELYING_PARTY); + entry.Strings.Remove(FIELD_FLAG_BE); + entry.Strings.Remove(FIELD_FLAG_BS); + } + } +} diff --git a/src/Kp2aPasskey.Core/PublicKeyCredentialHelpers.cs b/src/Kp2aPasskey.Core/PublicKeyCredentialHelpers.cs new file mode 100644 index 000000000..72761e809 --- /dev/null +++ b/src/Kp2aPasskey.Core/PublicKeyCredentialHelpers.cs @@ -0,0 +1,312 @@ +// Derived from KeePassDX (https://github.com/Kunzisoft/KeePassDX) +// Original work Copyright 2025 Jeremy Jamet / Kunzisoft. +// Licensed under the GNU General Public License v3 or later. +// +// Modifications Copyright 2026 Philipp Crocoll. +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using System; +using System.Collections.Generic; +using Org.Json; + +namespace Kp2aPasskey.Core +{ + /// + /// WebAuthn user verification requirement. + /// https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement + /// + public enum UserVerificationRequirement + { + Required, + Preferred, + Discouraged + } + + public static class UserVerificationRequirementExtensions + { + public static string ToWebAuthnString(this UserVerificationRequirement value) + { + return value switch + { + UserVerificationRequirement.Required => "required", + UserVerificationRequirement.Preferred => "preferred", + UserVerificationRequirement.Discouraged => "discouraged", + _ => "preferred" + }; + } + + public static UserVerificationRequirement FromString(string? value) + { + if (string.IsNullOrEmpty(value)) return UserVerificationRequirement.Preferred; + var v = value.Trim().ToLowerInvariant(); + return v switch + { + "required" => UserVerificationRequirement.Required, + "preferred" => UserVerificationRequirement.Preferred, + "discouraged" => UserVerificationRequirement.Discouraged, + _ => UserVerificationRequirement.Preferred + }; + } + } + + /// + /// Helper class to parse PublicKeyCredentialRequestOptions from JSON. + /// https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions + /// + public class PublicKeyCredentialRequestOptions + { + public JSONObject Json { get; } + public byte[] Challenge { get; } + public long Timeout { get; } + public string RpId { get; } + public string UserVerification { get; } + public List AllowCredentials { get; } + + /// + /// Parsed user verification requirement; defaults to Preferred if unknown. + /// + public UserVerificationRequirement UserVerificationRequirement => + UserVerificationRequirementExtensions.FromString(UserVerification); + + public PublicKeyCredentialRequestOptions(string requestJson) + { + Json = new JSONObject(requestJson); + Challenge = Android.Util.Base64.Decode( + Json.GetString("challenge"), + Android.Util.Base64Flags.UrlSafe | Android.Util.Base64Flags.NoPadding + ); + Timeout = Json.OptLong("timeout", 0); + RpId = Json.OptString("rpId", ""); + UserVerification = Json.OptString("userVerification", "preferred"); + AllowCredentials = ParseAllowCredentials(Json); + } + + private static List ParseAllowCredentials(JSONObject json) + { + var allowCredentials = new List(); + try + { + var allowCredentialsArray = json.OptJSONArray("allowCredentials"); + if (allowCredentialsArray != null) + { + for (int i = 0; i < allowCredentialsArray.Length(); i++) + { + var credentialJson = allowCredentialsArray.GetJSONObject(i); + var type = credentialJson?.OptString("type", "public-key") ?? "public-key"; + var idBase64 = credentialJson?.GetString("id"); + if (idBase64 == null) continue; + + var id = Android.Util.Base64.Decode( + idBase64, + Android.Util.Base64Flags.UrlSafe | Android.Util.Base64Flags.NoPadding + ); + if (id == null) continue; + + // Parse optional transports array + var transports = new List(); + var transportsArray = credentialJson.OptJSONArray("transports"); + if (transportsArray != null) + { + for (int j = 0; j < transportsArray.Length(); j++) + { + var transport = transportsArray.GetString(j); + if (transport != null) + { + transports.Add(transport); + } + } + } + + allowCredentials.Add(new PublicKeyCredentialDescriptor(type, id, transports)); + } + } + } + catch (Exception) + { + // If parsing fails, return empty list (no filtering) + } + return allowCredentials; + } + } + + /// + /// Helper class to parse PublicKeyCredentialCreationOptions from JSON. + /// https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptions + /// + public class PublicKeyCredentialCreationOptions + { + public JSONObject Json { get; } + public PublicKeyCredentialRpEntity RelyingPartyEntity { get; } + public PublicKeyCredentialUserEntity UserEntity { get; } + public byte[] Challenge { get; } + public List PubKeyCredParams { get; } + public long Timeout { get; } + public List ExcludeCredentials { get; } + public AuthenticatorSelectionCriteria AuthenticatorSelection { get; } + public string Attestation { get; } + public byte[]? ClientDataHash { get; } + + /// + /// User verification requirement from authenticatorSelection; defaults to Preferred if unknown. + /// + public UserVerificationRequirement UserVerificationRequirement => + UserVerificationRequirementExtensions.FromString(AuthenticatorSelection?.UserVerification); + + public PublicKeyCredentialCreationOptions(string requestJson, byte[]? clientDataHash) + { + Json = new JSONObject(requestJson); + ClientDataHash = clientDataHash; + + // Parse relying party + var rpJson = Json.GetJSONObject("rp"); + RelyingPartyEntity = new PublicKeyCredentialRpEntity( + rpJson.GetString("name"), + rpJson.GetString("id") + ); + + // Parse user + var userJson = Json.GetJSONObject("user"); + var userId = Android.Util.Base64.Decode( + userJson.GetString("id"), + Android.Util.Base64Flags.UrlSafe | Android.Util.Base64Flags.NoPadding + ); + UserEntity = new PublicKeyCredentialUserEntity( + userJson.GetString("name"), + userId, + userJson.GetString("displayName") + ); + + // Parse challenge + Challenge = Android.Util.Base64.Decode( + Json.GetString("challenge"), + Android.Util.Base64Flags.UrlSafe | Android.Util.Base64Flags.NoPadding + ); + + // Parse pubKeyCredParams + var pubKeyCredParamsJson = Json.GetJSONArray("pubKeyCredParams"); + var pubKeyCredParamsList = new List(); + for (int i = 0; i < pubKeyCredParamsJson.Length(); i++) + { + var e = pubKeyCredParamsJson.GetJSONObject(i); + pubKeyCredParamsList.Add( + new PublicKeyCredentialParameters( + e.GetString("type"), + e.GetLong("alg") + ) + ); + } + PubKeyCredParams = pubKeyCredParamsList; + + // Parse optional fields + Timeout = Json.OptLong("timeout", 0); + ExcludeCredentials = new List(); + AuthenticatorSelection = ParseAuthenticatorSelection(Json); + Attestation = Json.OptString("attestation", "none"); + } + + private static AuthenticatorSelectionCriteria ParseAuthenticatorSelection(JSONObject json) + { + try + { + var sel = json.OptJSONObject("authenticatorSelection"); + if (sel == null) + return new AuthenticatorSelectionCriteria("platform", "required", false, "preferred"); + var attachment = sel.OptString("authenticatorAttachment", "platform"); + var residentKey = sel.OptString("residentKey", "required"); + var requireResidentKey = sel.OptBoolean("requireResidentKey", false); + var userVerification = sel.OptString("userVerification", "preferred"); + return new AuthenticatorSelectionCriteria(attachment, residentKey, requireResidentKey, userVerification); + } + catch + { + return new AuthenticatorSelectionCriteria("platform", "required", false, "preferred"); + } + } + } + + public class PublicKeyCredentialRpEntity + { + public string Name { get; } + public string Id { get; } + + public PublicKeyCredentialRpEntity(string name, string id) + { + Name = name; + Id = id; + } + } + + public class PublicKeyCredentialUserEntity + { + public string Name { get; } + public byte[] Id { get; } + public string DisplayName { get; } + + public PublicKeyCredentialUserEntity(string name, byte[] id, string displayName) + { + Name = name; + Id = id; + DisplayName = displayName; + } + } + + public class PublicKeyCredentialParameters + { + public string Type { get; } + public long Alg { get; } + + public PublicKeyCredentialParameters(string type, long alg) + { + Type = type; + Alg = alg; + } + } + + public class PublicKeyCredentialDescriptor + { + public string Type { get; } + public byte[] Id { get; } + public List Transports { get; } + + public PublicKeyCredentialDescriptor(string type, byte[] id, List transports) + { + Type = type; + Id = id; + Transports = transports; + } + } + + public class AuthenticatorSelectionCriteria + { + public string AuthenticatorAttachment { get; } + public string ResidentKey { get; } + public bool RequireResidentKey { get; } + public string UserVerification { get; } + + public AuthenticatorSelectionCriteria( + string authenticatorAttachment, + string residentKey, + bool requireResidentKey = false, + string userVerification = "preferred" + ) + { + AuthenticatorAttachment = authenticatorAttachment; + ResidentKey = residentKey; + RequireResidentKey = requireResidentKey; + UserVerification = userVerification; + } + } +} diff --git a/src/keepass2android-app/PasskeyPreferences.cs b/src/keepass2android-app/PasskeyPreferences.cs new file mode 100644 index 000000000..caf07debc --- /dev/null +++ b/src/keepass2android-app/PasskeyPreferences.cs @@ -0,0 +1,71 @@ +// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using Android.Content; +using Android.Preferences; + +namespace keepass2android +{ + /// + /// Helper class for reading passkey-related preferences + /// Based on KeePassDX PreferencesUtil pattern + /// + public static class PasskeyPreferences + { + /// + /// Get the backup eligibility preference value. + /// Determines whether new passkeys should be marked as eligible for backup. + /// + public static bool GetBackupEligibility(Context context) + { + var prefs = PreferenceManager.GetDefaultSharedPreferences(context); + return prefs.GetBoolean( + context.GetString(Resource.String.passkeys_backup_eligibility_key), + context.Resources.GetBoolean(Resource.Boolean.passkeys_backup_eligibility_default) + ); + } + + /// + /// Get the backup state preference value. + /// Determines whether new passkeys should be marked as currently backed up. + /// Note: This only returns true if backup eligibility is also enabled. + /// + public static bool GetBackupState(Context context) + { + // If backup eligibility is disabled, backup state must be false + if (!GetBackupEligibility(context)) + return false; + + var prefs = PreferenceManager.GetDefaultSharedPreferences(context); + return prefs.GetBoolean( + context.GetString(Resource.String.passkeys_backup_state_key), + context.Resources.GetBoolean(Resource.Boolean.passkeys_backup_state_default) + ); + } + + /// + /// When true, treat WebAuthn "preferred" user verification as "required" + /// (show device credential/biometric prompt before using or creating a passkey). + /// + public static bool GetForceUserVerificationWhenPreferred(Context context) + { + var prefs = PreferenceManager.GetDefaultSharedPreferences(context); + return prefs.GetBoolean( + context.GetString(Resource.String.passkeys_force_user_verification_when_preferred_key), + context.Resources.GetBoolean(Resource.Boolean.passkeys_force_user_verification_when_preferred_default) + ); + } + } +} diff --git a/src/keepass2android-app/Resources/values/config.xml b/src/keepass2android-app/Resources/values/config.xml index b84fee880..f0f1c047f 100644 --- a/src/keepass2android-app/Resources/values/config.xml +++ b/src/keepass2android-app/Resources/values/config.xml @@ -2,7 +2,8 @@ - AutoReturnFromQuery_key - AlwaysMergeOnConflict - NoDalVerification_key - InlineSuggestions_key - LogAutofillView_key - algorithm - app - app_timeout_key + Arno Welzel, Sebastián Ramírez, A. Finkhäuser, Makoto Mizukami + https://github.com/PhilippC/keepass2android/issues + market://details?id=org.openintents.filemanager + https://openintents.googlecode.com/files/FileManager-2.0.2.apk + KP2A Search + KP2A Choose autofill dataset + AutoFillTotp_prefs_screen_key + + + + AutoReturnFromQuery_key + AlwaysMergeOnConflict + NoDalVerification_key + InlineSuggestions_key + LogAutofillView_key + algorithm + app + app_timeout_key show_kill_app_key - clip_timeout_key - db - kdf_screen - kdf_key - rounds + clip_timeout_key + db + kdf_screen + kdf_key + rounds change_master_pwd - keyfile - maskpass - masktotp - NoAutofillDisabling - omitbackup - list_size + keyfile + maskpass + masktotp + NoAutofillDisabling + omitbackup + list_size design_key - app_language_pref_key + app_language_pref_key sort_key sort_key_new sortgroups_key @@ -81,69 +83,72 @@ ShowGroupnameInSearchResult_key ShowUsernameInList_key RememberRecentFiles_key - defaultUsername - databaseName - - /mnt/sdcard/keepass2android/binaries/ - true - true - true - true - true - true - true + defaultUsername + databaseName + + /mnt/sdcard/keepass2android/binaries/ + true + true + true + true + true + true + true ViewDatabaseSecure - no_secure_display_check - CloseDatabaseAfterFailedBiometricQuickUnlock_key - true + no_secure_display_check + + CloseDatabaseAfterFailedBiometricQuickUnlock_key + true TrayTotp_SettingsField_key TrayTotp_SeedField_key TrayTotp_prefs_key - DebugLog_key - FtpDebug_key - DebugLog_prefs_key - DebugLog_send - AutofillDisabledQueriesPreference_key - OfferSaveCredentials_key - AutoFill_prefs_screen_key - + Passkey_prefs_key + DebugLog_key + FtpDebug_key + DebugLog_prefs_key + DebugLog_send + AutofillDisabledQueriesPreference_key + OfferSaveCredentials_key + AutoFill_prefs_screen_key + password_access_prefs_key security_prefs_key display_prefs_key QuickUnlock_prefs_key FileHandling_prefs_key keyboardswitch_prefs_key - AutoFill_prefs_key - QuickUnlockHideLength_key - + AutoFill_prefs_key + QuickUnlockHideLength_key + - OfflineMode_key + OfflineMode_key Enable_QuickUnlock_by_default QuickUnlockLength 3 QuickUnlockIconHidden_key QuickUnlockIconHidden16_key - QuickUnlockBlockedWhenDeviceNotSecure_key - + + QuickUnlockBlockedWhenDeviceNotSecure_key + - permit_cleartext_traffic - + permit_cleartext_traffic + UsageCount LastInfoVersion - + UseFileTransactions LockWhenScreenOff kp2a_switch_rootedLockWhenNavigateBack - UseKp2aKeyboardInKp2a - + UseKp2aKeyboardInKp2a + - NoDonateOption - NoDonationReminder + NoDonateOption + NoDonationReminder UseOfflineCache - SyncOfflineCacheInBackground_key - CreateBackups_key + SyncOfflineCacheInBackground_key + CreateBackups_key AcceptAllServerCertificates CheckForFileChangesOnSave CheckForDuplicateUuids_key @@ -153,59 +158,60 @@ market://details?id= https://github.com/PhilippC/keepass2android/issues https://crowdin.net/project/keepass2android - + ShowCopyToClipboardNotification true ShowSeparateNotifications_key false - + ShowKp2aKeyboardNotification true - + OpenKp2aKeyboardAutomatically true - OpenKp2aKeyboardAutomaticallyOnlyAfterSearch_key + + OpenKp2aKeyboardAutomaticallyOnlyAfterSearch_key false AutoSwitchBackKeyboard_key true - 300000 - - 30000 - 60000 - 300000 + 300000 + + 30000 + 60000 + 300000 600000 900000 1800000 3600000 - -1 - + -1 + 20 - - 15 - 20 - 28 - - - - SHA-1 - SHA-256 - SHA-512 - + + 15 + 20 + 28 + + + + SHA-1 + SHA-256 + SHA-512 + System Light Dark - System + System - + ERROR IGNORE @@ -220,11 +226,14 @@ PreloadDatabaseEnabled true - SyncAfterQuickUnlock_key + SyncAfterQuickUnlock_key ClearPasswordOnLeave 15 - + true + true + false + - + \ No newline at end of file diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index f583fd3f4..0453c244c 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -636,6 +636,7 @@ TOTP Settings field name Enter the field name of the settings field for TrayTotp here. TrayTotp + Passkey Settings Log-File for Debugging Use log file FTP/SFTP debug logging @@ -1352,5 +1353,33 @@ Periodic background synchronization time interval Set the interval for background synchronization in minutes. + + Password credential provider + Save Password in new entry + Database is locked. Open Keepass2Android to unlock. + Save Passkey + Open %1$s + Manage Credentials + + + Configure passkey backup behavior + Backup Flags + User Verification + + + passkeys_backup_eligibility_key + Passkey Backup Eligibility + Determine at creation time whether passkeys can be backed up. This affects how websites treat your passkeys. + + passkeys_backup_state_key + Passkey Backup State + Mark passkeys as backed up (requires Backup Eligibility enabled). Enable if you sync your database across devices. + + + passkeys_force_user_verification_when_preferred_key + Force user verification when preferred + When a site requests \"preferred\" user verification, still require biometric or device lock before using a passkey. + User verification required + Confirm your identity to use this passkey. \ No newline at end of file diff --git a/src/keepass2android-app/Resources/xml/credentials_provider.xml b/src/keepass2android-app/Resources/xml/credentials_provider.xml new file mode 100644 index 000000000..71ea75e9a --- /dev/null +++ b/src/keepass2android-app/Resources/xml/credentials_provider.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/src/keepass2android-app/Resources/xml/pref_app.xml b/src/keepass2android-app/Resources/xml/pref_app.xml index ea5b70c30..9982c4aea 100644 --- a/src/keepass2android-app/Resources/xml/pref_app.xml +++ b/src/keepass2android-app/Resources/xml/pref_app.xml @@ -49,6 +49,11 @@ android:title="@string/TrayTotp_prefs" app:fragment="keepass2android.settings.TotpPreferenceFragment" /> + + + + + + + + + + + + + + + + diff --git a/src/keepass2android-app/SelectCurrentDbActivity.cs b/src/keepass2android-app/SelectCurrentDbActivity.cs index 0871eab1c..1b32ce4b6 100644 --- a/src/keepass2android-app/SelectCurrentDbActivity.cs +++ b/src/keepass2android-app/SelectCurrentDbActivity.cs @@ -490,7 +490,7 @@ protected override void OnResume() } //database(s) unlocked - if ((App.Kp2a.OpenDatabases.Count() == 1) || (AppTask is SearchUrlTask)) + if (((App.Kp2a.OpenDatabases.Count() == 1) || (AppTask is SearchUrlTask)) && (AppTask?.ExplicitlySelectDatabase != true)) { LaunchingOther = true; AppTask.LaunchFirstGroupActivity(this); diff --git a/src/keepass2android-app/app/App.cs b/src/keepass2android-app/app/App.cs index 4123500a2..0b310bde2 100644 --- a/src/keepass2android-app/app/App.cs +++ b/src/keepass2android-app/app/App.cs @@ -59,13 +59,13 @@ You should have received a copy of the GNU General Public License using GoogleDriveAppDataFileStorage = keepass2android.Io.GoogleDriveAppDataFileStorage; using PCloudFileStorage = keepass2android.Io.PCloudFileStorage; using static keepass2android.Util; -using static Android.Provider.Telephony.MmsSms; #endif #endif using Java.Interop; using AndroidX.Lifecycle; using keepass2android.services; +using Java.Util.Concurrent.Atomic; namespace keepass2android @@ -1628,6 +1628,13 @@ public void PerformPendingActions(int instanceId) } } } + + private readonly AtomicInteger requestCodeForCredentialProvider = new(); + public int RequestCodeForCredentialProvider + { + get => requestCodeForCredentialProvider.IncrementAndGet(); + } + } diff --git a/src/keepass2android-app/app/AppTask.cs b/src/keepass2android-app/app/AppTask.cs index b7a5e7e96..6dabded30 100644 --- a/src/keepass2android-app/app/AppTask.cs +++ b/src/keepass2android-app/app/AppTask.cs @@ -1,18 +1,18 @@ -// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll. -// -// Keepass2Android is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Keepass2Android is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Keepass2Android. If not, see . - +// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + using System; using System.Globalization; using Android.App; @@ -194,6 +194,14 @@ public virtual bool CanActivateSearchViewOnStart set; } + /// + /// If true, the SelectCurrentDbActivity doesn't forward to the already opened database + /// + public virtual bool ExplicitlySelectDatabase + { + get { return false; } + } + /// /// Returns the parameters of the task for storage in a bundle or intent @@ -411,6 +419,49 @@ public class NullTask : AppTask } + public class UnlockThenCloseWithResultTask : AppTask + { + public UnlockThenCloseWithResultTask() + { + } + public UnlockThenCloseWithResultTask(bool explicitlySelectDatabase) + { + this.explicitlySelectDatabase = explicitlySelectDatabase; + } + + public const String ResultCodeKey = "UnlockThenCloseWithResultTask_ResultCode"; + public const String ExplicitlySelectDatabaseKey = "UnlockThenCloseWithResultTask_ExplicitlySelectDatabase"; + public int ResultCode { get; set; } + public override void Setup(Bundle b) + { + base.Setup(b); + ResultCode = b.GetInt(ResultCodeKey, 0); + explicitlySelectDatabase = b.GetBoolean(ExplicitlySelectDatabaseKey, false); + } + + private bool explicitlySelectDatabase; + public override bool ExplicitlySelectDatabase + { + get { return explicitlySelectDatabase; } + } + + public override IEnumerable Extras + { + get + { + foreach (IExtra e in base.Extras) + yield return e; + yield return new IntExtra { Key = ResultCodeKey, Value = ResultCode }; + yield return new BoolExtra { Key = ExplicitlySelectDatabaseKey, Value = ExplicitlySelectDatabase }; + } + } + public override void LaunchFirstGroupActivity(Activity act) + { + SetActivityResult(act, (Result)ResultCode); + act.Finish(); + } + } + /// /// User is about to search an entry for a given URL /// @@ -770,6 +821,10 @@ public override bool CanActivateSearchViewOnStart /// public const String ProtectedFieldsListKey = Keepass2android.Pluginsdk.Strings.ExtraProtectedFieldsList; + /// + /// extra key for CustomData as JSON string (key-value pairs). optional. + /// + public const String CustomDataKey = "CreateEntry_CustomData"; /// /// Extra key to specify whether user notifications (e.g. for copy password or keyboard) should be displayed when the entry @@ -777,6 +832,11 @@ public override bool CanActivateSearchViewOnStart /// public const String ShowUserNotificationsKey = "ShowUserNotifications"; + /// + /// Extra key to specify tags for the entry. Passed as StringArrayExtra. optional. + /// + public const String TagsKey = "CreateEntry_Tags"; + public string Url { get; set; } @@ -784,6 +844,10 @@ public override bool CanActivateSearchViewOnStart public IList ProtectedFieldsList { get; set; } + public string CustomData { get; set; } + + public IList Tags { get; set; } + public ActivationCondition ShowUserNotifications { get; set; } @@ -795,6 +859,8 @@ public override void Setup(Bundle b) Url = b.GetString(UrlKey); AllFields = b.GetString(AllFieldsKey); ProtectedFieldsList = b.GetStringArrayList(ProtectedFieldsListKey); + CustomData = b.GetString(CustomDataKey); + Tags = b.GetStringArrayList(TagsKey); } public override IEnumerable Extras { @@ -806,6 +872,10 @@ public override IEnumerable Extras yield return new StringExtra { Key = AllFieldsKey, Value = AllFields }; if (ProtectedFieldsList != null) yield return new StringArrayListExtra { Key = ProtectedFieldsListKey, Value = ProtectedFieldsList }; + if (CustomData != null) + yield return new StringExtra { Key = CustomDataKey, Value = CustomData }; + if (Tags != null) + yield return new StringArrayListExtra { Key = TagsKey, Value = Tags }; yield return new StringExtra { Key = ShowUserNotificationsKey, Value = ShowUserNotifications.ToString() }; } @@ -833,6 +903,30 @@ public override void PrepareNewEntry(PwEntry newEntry) } + if (CustomData != null) + { + var customDataJson = new Org.Json.JSONObject(CustomData); + for (var iter = customDataJson.Keys(); iter.HasNext;) + { + string key = iter.Next().ToString(); + string value = customDataJson.Get(key).ToString(); + newEntry.CustomData.Set(key, value); + } + } + + if (Tags != null && Tags.Count > 0) + { + var existingTags = new System.Collections.Generic.List(newEntry.Tags); + foreach (var tag in Tags) + { + if (!existingTags.Contains(tag)) + { + existingTags.Add(tag); + } + } + newEntry.Tags = existingTags; + } + } public override void AfterAddNewEntry(EntryEditActivity entryEditActivity, PwEntry newEntry) diff --git a/src/keepass2android-app/keepass2android-app.csproj b/src/keepass2android-app/keepass2android-app.csproj index fd597ffec..160baaf98 100644 --- a/src/keepass2android-app/keepass2android-app.csproj +++ b/src/keepass2android-app/keepass2android-app.csproj @@ -748,6 +748,7 @@ + @@ -771,6 +772,7 @@ + diff --git a/src/keepass2android-app/services/AutofillBase/Kp2aDigitalAssetLinksDataSource.cs b/src/keepass2android-app/services/AutofillBase/Kp2aDigitalAssetLinksDataSource.cs index 4c40b62df..7dd1ea40f 100644 --- a/src/keepass2android-app/services/AutofillBase/Kp2aDigitalAssetLinksDataSource.cs +++ b/src/keepass2android-app/services/AutofillBase/Kp2aDigitalAssetLinksDataSource.cs @@ -35,9 +35,14 @@ public Kp2aDigitalAssetLinksDataSource(Context ctx) _ctx = ctx; } + static public bool IsTrustedBrowser(string packageName) + { + return _trustedBrowsers.Contains(packageName); + } + public bool IsTrustedApp(string packageName) { - if (_trustedBrowsers.Contains(packageName)) + if (IsTrustedBrowser(packageName)) return true; var prefs = PreferenceManager.GetDefaultSharedPreferences(_ctx); var trustedApps = prefs.GetStringSet(Autofilltrustedapps, new List()).ToHashSet(); @@ -84,17 +89,59 @@ private static string BuildLink(string domain, string package) static readonly HashSet _trustedBrowsers = new HashSet - { - "org.mozilla.firefox","org.mozilla.firefox_beta","org.mozilla.klar","org.mozilla.focus", - "org.mozilla.fenix","org.mozilla.reference.browser", - "com.android.browser","com.android.chrome","com.chrome.beta","com.chrome.dev","com.chrome.canary", - "com.google.android.apps.chrome","com.google.android.apps.chrome_dev", - "com.opera.browser","com.opera.browser.beta","com.opera.mini.native","com.opera.mini.native.beta","com.opera.touch", - "com.brave.browser","com.yandex.browser","com.microsoft.emmx","com.amazon.cloud9", - "com.sec.android.app.sbrowser","com.sec.android.app.sbrowser.beta","org.codeaurora.swe.browser", - "mark.via.gp","org.bromite.bromite", "org.mozilla.fennec_fdroid", "com.vivaldi.browser","com.kiwibrowser.browser", - "acr.browser.lightning", "acr.browser.barebones", "jp.hazuki.yuzubrowser" - }; + { + // Chrome variants + "com.android.browser", + "com.android.chrome", + "com.chrome.beta", + "com.chrome.canary", + "com.chrome.dev", + "com.google.android.apps.chrome", + "com.google.android.apps.chrome_dev", + "org.chromium.chrome", + + // Firefox variants + "org.mozilla.fenix", + "org.mozilla.fennec_fdroid", + "org.mozilla.firefox", + "org.mozilla.firefox_beta", + "org.mozilla.focus", + "org.mozilla.klar", + "org.mozilla.reference.browser", + + // Microsoft Edge + "com.microsoft.emmx", + + // Opera variants + "com.opera.browser", + "com.opera.browser.beta", + "com.opera.mini.native", + "com.opera.mini.native.beta", + "com.opera.touch", + + // Samsung Internet + "com.sec.android.app.sbrowser", + "com.sec.android.app.sbrowser.beta", + + // Other established browsers + "com.brave.browser", + "com.kiwibrowser.browser", + "com.vivaldi.browser", + "com.yandex.browser", + + // Privacy-focused browsers + "acr.browser.barebones", + "acr.browser.lightning", + "io.github.forkmaintainers.iceraven", + "mark.via.gp", + "org.bromite.bromite", + "org.cromite.cromite", + "org.ironfoxoss.ironfox", + + // Regional/specialized browsers + "jp.hazuki.yuzubrowser", + "org.codeaurora.swe.browser", + }; } } diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs new file mode 100644 index 000000000..1d12de5b3 --- /dev/null +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs @@ -0,0 +1,236 @@ +// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using Android.Content; +using Android.Util; +using AndroidX.Core.App; +using AndroidX.Credentials.Provider; +using Java.Time; +using Keepass2android.Pluginsdk; +using KeePassLib; +using Kp2aPasskey.Core; + +namespace keepass2android.services.Kp2aCredentialProvider +{ + /// + /// Shared helpers for building entries. + /// Used by both (DB already unlocked) and + /// (after-unlock path via + /// PendingOperation.UnlockForGetCredentials). + /// + [System.Runtime.Versioning.SupportedOSPlatform("android31.0")] + internal static class GetCredentialHelper + { + /// + /// Searches the open databases for password entries matching + /// and adds a for each one to . + /// + public static void AddMatchingPasswordEntries( + Context context, + string callingPackage, + BeginGetPasswordOption option, + BeginGetCredentialResponse.Builder responseBuilder + ) + { + var query = $"{KeePass.AndroidAppScheme}{callingPackage}"; + var searchResults = ShareUrlResults.GetSearchResultsForUrl(query); + var foundEntries = searchResults?.Entries.ToList() ?? new List(); + + var lastOpenedEntry = App.Kp2a.LastOpenedEntry; + if (lastOpenedEntry != null && lastOpenedEntry.SearchUrl == query) + { + foundEntries.Clear(); + foundEntries.Add(lastOpenedEntry.Entry); + } + + foreach (var entry in foundEntries) + { + var username = entry.Strings.ReadSafe(PwDefs.UserNameField); + if (string.IsNullOrEmpty(username)) continue; + + responseBuilder.AddCredentialEntry( + new PasswordCredentialEntry.Builder( + context, + username, + PendingIntentCompat.GetActivity( + context, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(context, typeof(Kp2aCredentialLauncherActivity)) + .PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeGetPasswordForEntry + ) + .PutExtra(Strings.ExtraEntryId, entry.Uuid.ToHexString()), + (int)PendingIntentFlags.UpdateCurrent, + true + )!, + option + ) + .SetDisplayName(entry.Strings.ReadSafe(PwDefs.TitleField)) + .SetAffiliatedDomain(entry.ParentGroup?.Name) + .SetLastUsedTime( + Instant.OfEpochMilli( + new DateTimeOffset(entry.LastAccessTime).ToUnixTimeMilliseconds() + ) + ) + .Build() + ); + } + } + + /// + /// Searches the open databases for passkey entries matching the relying party declared in + /// and adds a for each one + /// to . If allowCredentials is specified in the request, + /// only passkeys with matching credential IDs are returned. When no entries are found, a generic + /// "search all passkeys" entry is added instead. + /// + public static void AddMatchingPasskeyEntries( + Context context, + BeginGetPublicKeyCredentialOption option, + BeginGetCredentialResponse.Builder responseBuilder, + bool isAutoSelectAllowed = false + ) + { + try + { + var requestOptions = new PublicKeyCredentialRequestOptions(option.RequestJson); + var relyingPartyId = requestOptions.RpId; + var allowCredentials = requestOptions.AllowCredentials; + + var query = $"passkey:{relyingPartyId}"; + var searchResults = ShareUrlResults.GetSearchResultsForUrl(query); + var foundEntries = searchResults?.Entries.ToList() ?? new List(); + + // Filter by allowCredentials if specified + if (allowCredentials.Count > 0) + { + foundEntries = FilterEntriesByAllowCredentials(foundEntries, allowCredentials); + } + + if (foundEntries.Count > 0) + { + foreach (var entry in foundEntries) + { + var username = entry.Strings.ReadSafe(PwDefs.UserNameField); + if (string.IsNullOrEmpty(username)) username = "Unknown"; + + responseBuilder.AddCredentialEntry( + new PublicKeyCredentialEntry.Builder( + context, + username, + PendingIntentCompat.GetActivity( + context, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(context, typeof(Kp2aCredentialLauncherActivity)) + .PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeGetPasskeyForEntry + ) + .PutExtra(Strings.ExtraEntryId, entry.Uuid.ToHexString()) + .PutExtra(Kp2aCredentialLauncherActivity.ExtraRelyingPartyId, relyingPartyId) + .PutExtra(Kp2aCredentialLauncherActivity.ExtraRequestJson, option.RequestJson), + (int)PendingIntentFlags.UpdateCurrent, + true + )!, + option + ) + .SetDisplayName(entry.Strings.ReadSafe(PwDefs.TitleField)) + .SetLastUsedTime( + Instant.OfEpochMilli( + new DateTimeOffset(entry.LastAccessTime).ToUnixTimeMilliseconds() + ) + ) + .SetAutoSelectAllowed(isAutoSelectAllowed) + .Build() + ); + } + } + else + { + // No passkeys found for this RP — show a generic "search all passkeys" entry. + // This allows unlocking a closed database or manual entry selection. + responseBuilder.AddAuthenticationAction( + CreateAuthenticationAction(context) + ).Build(); + } + } + catch (Exception e) + { + Kp2aLog.Log($"Error handling passkey get request {e.Message}"); + } + } + + /// + /// Filters entries to only those whose credential ID matches one in allowCredentials list. + /// + private static List FilterEntriesByAllowCredentials( + List entries, + List allowCredentials + ) + { + var filtered = new List(); + + // Convert allowCredentials IDs to base64url strings for comparison + var allowedCredentialIds = new HashSet(); + foreach (var descriptor in allowCredentials) + { + // Convert the byte array to base64url string (URL-safe, no padding) + var credentialIdBase64 = Base64.EncodeToString( + descriptor.Id, + Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap + ); + if (!string.IsNullOrEmpty(credentialIdBase64)) + { + allowedCredentialIds.Add(credentialIdBase64); + } + } + + // Filter entries + foreach (var entry in entries) + { + var passkey = Kp2aPasskey.Core.PasskeyStorage.RetrievePasskey(entry); + if (passkey != null && allowedCredentialIds.Contains(passkey.CredentialId)) + { + filtered.Add(entry); + } + } + + return filtered; + } + + + public static AuthenticationAction CreateAuthenticationAction(Context context) + { + var action = new AuthenticationAction( + // Providers that require unlocking the credentials before returning any credentialEntries, + // must set up a pending intent that navigates the user to the app's unlock flow. + context.GetString(AppNames.AppNameResource), + PendingIntentCompat.GetActivity( + context, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(context, typeof(Kp2aCredentialLauncherActivity)).PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeUnlockForGetCredentials + ), + (int)PendingIntentFlags.UpdateCurrent, + true + )! + ); + + return action; + } + } +} diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs new file mode 100644 index 000000000..349d7ac46 --- /dev/null +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs @@ -0,0 +1,1147 @@ +// Parts of the file are derived from KeePassDX (https://github.com/Kunzisoft/KeePassDX) +// Original work Copyright 2025 Jeremy Jamet / Kunzisoft. +// Licensed under the GNU General Public License v3 or later. +// +// Modifications Copyright 2026 Philipp Crocoll. +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using Android.Content; +using Android.Content.PM; +using Android.OS; +using Android.Runtime; +using Android.Util; +using Android.Views; +using AndroidX.Core.App; +using AndroidX.Credentials; +using AndroidX.Credentials.Exceptions; +using AndroidX.Credentials.Provider; +using Java.Security; +using Java.Security.Spec; +using Java.Time; +using Keepass2android.Pluginsdk; +using keepass2android.services.AutofillBase; +using keepass2android.services.Kp2aCredentialProvider.Passkey; +using KeePassLib; +using Kp2aPasskey.Core; +using KeePassLib.Utility; +using Org.Json; + +namespace keepass2android.services.Kp2aCredentialProvider +{ + [Activity( + Label = AppNames.AppName, + ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.KeyboardHidden, + Theme = "@style/Kp2aTheme_ActionBar", + WindowSoftInputMode = SoftInput.AdjustResize, + Permission = "keepass2android." + AppNames.PackagePart + ".permission.Kp2aChooseAutofill" + )] + public class Kp2aCredentialLauncherActivity : AndroidX.AppCompat.App.AppCompatActivity + { + public const string CredentialRequestTypeKey = "credential_request_type"; + public const int CredentialRequestTypeCreatePassword = 1; + public const int CredentialRequestTypeUnlockForGetCredentials = 2; + public const int CredentialRequestTypeGetPasswordForEntry = 3; + public const int CredentialRequestTypeCreatePasskey = 4; + public const int CredentialRequestTypeGetPasskeyForEntry = 6; + + // Intent extra keys + public const string ExtraRelyingPartyId = "extra_relying_party_id"; + public const string ExtraRequestJson = "extra_request_json"; + + private const int CreateEntryRequestCode = 100; + private const int UnlockRequestCode = 300; + + // Bundle keys for state persistence + private const string BundleKeyPasskeyRequestJson = "passkey_request_json"; + private const string BundleKeyPasskeyCallingPackage = "passkey_calling_package"; + private const string BundleKeyPasskeyOrigin = "passkey_origin"; + private const string BundleKeyPasskeyResponseData = "passkey_response_data"; + private const string BundleKeyPasskeyClientDataHash = "passkey_client_data_hash"; + private const string BundleKeyPendingOperation = "pending_operation"; + private const string BundleKeyUserVerifiedForCreate = "user_verified_for_create"; + + // ViewModel-style state variables for passkey operations + private PublicKeyCredentialCreationOptions? _passkeyCreationOptions; + private string? _passkeyRequestJson; + private string? _passkeyCallingPackage; + private string? _passkeyOrigin; + private JSONObject? _passkeyResponseData; + private byte[]? _passkeyClientDataHash; + private enum PendingOperation + { + None, + UnlockForGetCredentials, // Unlock so the Begin-Get picker can be (re-)populated with all credential types + CreatePasskey, // DB was open when creation started → awaiting CreateEntryRequestCode + GetPasskeyForEntryAfterUnlock, // Same as above, for a pre-selected entry + } + + private PendingOperation _pendingOperation; + + /// True when user completed device credential/biometric before creating a passkey. + /// This field is only needed for passkey creation because that involves an activity lifecycle break after UV. + private bool _userVerifiedForCreate; + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // Restore state from saved instance if activity was recreated + if (savedInstanceState != null) + { + RestoreInstanceState(savedInstanceState); + } + + var intent = Intent; + if (intent == null) + { + // should never happen + SetResult(Result.Canceled); + Finish(); + } + else + { + var requestType = intent.GetIntExtra(CredentialRequestTypeKey, 0); + switch (requestType) + { + + case CredentialRequestTypeCreatePassword: + // create request for user/password credentials (non-passkey) + HandleCreatePasswordRequest(intent); + break; + case CredentialRequestTypeCreatePasskey: + // create request for passkey + HandleCreatePasskeyRequest(intent); + break; + case CredentialRequestTypeUnlockForGetCredentials: + // The database was (and probably still is) locked when the onBeginGetCredentialRequest came in. We're supposed to unlock the database and then return matching entries. + //We can use the same workflow for get-password as well as get-passkey requests. + HandleUnlockForGetCredentialsRequest(intent); + break; + case CredentialRequestTypeGetPasswordForEntry: + // After we (or the service) have returned entries for Android's credential UI (see GetCredentialHelper), the user has clicked one of these. + // The previously returned entries only contained username/display name/UUID but not the actual FIDO2 assertion response. + HandleGetPasswordForEntryRequest(intent); + break; + + case CredentialRequestTypeGetPasskeyForEntry: + // After we (or the service) have returned entries for Android's Passkey UI (see GetCredentialHelper), the user has clicked one of these. + // The previously returned entries only contained username/display name/UUID but not the actual FIDO2 assertion response. + HandleGetPasskeyForEntryRequest(intent); + break; + + default: + // unexpected intent + SetResult(Result.Canceled); + Finish(); + break; + } + } + } + + private void HandleCreatePasswordRequest(Intent requestIntent) + { + var createRequest = PendingIntentHandler.RetrieveProviderCreateCredentialRequest( + requestIntent + ); + if (createRequest is { CallingRequest: CreatePasswordRequest }) + { + if (createRequest.CallingRequest is not CreatePasswordRequest request) + { + SetUpFailureResponseForCreateAndFinish("Unable to extract request from intent"); + } + else + { + var callingPackage = createRequest.CallingAppInfo.PackageName; + + var forwardIntent = new Intent(this, typeof(SelectCurrentDbActivity)); + + Dictionary outputFields = []; + if (callingPackage != null) + { + outputFields.TryAdd(PwDefs.UrlField, $"{KeePass.AndroidAppScheme}{callingPackage}"); + } + + outputFields.TryAdd(PwDefs.UserNameField, request.Id); + outputFields.TryAdd(PwDefs.PasswordField, request.Password); + + JSONObject jsonOutput = new(outputFields); + var jsonOutputStr = jsonOutput.ToString(); + forwardIntent.PutExtra(Strings.ExtraEntryOutputData, jsonOutputStr); + + JSONArray jsonProtectedFields = new( + (System.Collections.ICollection)Array.Empty() + ); + forwardIntent.PutExtra(Strings.ExtraProtectedFieldsList, jsonProtectedFields.ToString()); + + forwardIntent.PutExtra(AppTask.AppTaskKey, "CreateEntryThenCloseTask"); + forwardIntent.PutExtra(CreateEntryThenCloseTask.ShowUserNotificationsKey, "false"); + StartActivityForResult(forwardIntent, CreateEntryRequestCode); + } + } + else + { + SetUpFailureResponseForCreateAndFinish("Unable to extract request from intent"); + } + } + + private void HandleUnlockForGetCredentialsRequest(Intent requestIntent) + { + _pendingOperation = PendingOperation.UnlockForGetCredentials; + LaunchUnlockDatabase(); + } + + /// + /// Launches to unlock the database and return here via + /// with . + /// + private void LaunchUnlockDatabase() + { + var forwardIntent = new Intent(this, typeof(SelectCurrentDbActivity)); + new UnlockThenCloseWithResultTask(true) { ResultCode = (int)Result.Ok }.ToIntent(forwardIntent); + StartActivityForResult(forwardIntent, UnlockRequestCode); + } + + private void HandleGetPasswordForEntryRequest(Intent requestIntent) + { + var getRequest = PendingIntentHandler.RetrieveProviderGetCredentialRequest(requestIntent); + var options = getRequest?.CredentialOptions; + if (options == null || options.Count == 0) + { + SetUpFailureResponseForGetAndFinish(); + return; + } + + var option = options.FirstOrDefault(o => o is GetPasswordOption); + if (option is GetPasswordOption) + { + var entryId = requestIntent.GetStringExtra(Strings.ExtraEntryId); + if (string.IsNullOrEmpty(entryId)) + { + SetUpFailureResponseForGetAndFinish(); + return; + } + var entryUuid = new PwUuid(MemUtil.HexStringToByteArray(entryId)); + + var lastOpenedEntry = App.Kp2a.LastOpenedEntry; + if (lastOpenedEntry != null && entryUuid.Equals(lastOpenedEntry.Uuid)) + { + SetupGetCredentialResponseForEntryAndFinish(lastOpenedEntry.Entry); + } + else + { + foreach (Database db in App.Kp2a.OpenDatabases) + { + if (db.EntriesById.TryGetValue(entryUuid, out var resultEntry)) + { + SetupGetCredentialResponseForEntryAndFinish(resultEntry); + return; + } + } + //nothing found in open databases + SetUpNoCredentialResponseForGetAndFinish(); + } + } + else + { + SetUpFailureResponseForGetAndFinish(); + } + } + + protected override void OnActivityResult( + int requestCode, + [GeneratedEnum] Result resultCode, + Intent? data + ) + { + base.OnActivityResult(requestCode, resultCode, data); + switch (requestCode) + { + case CreateEntryRequestCode: + SetupCreateCredentialResponseAndFinish(resultCode); + break; + + case UnlockRequestCode: + SetupUnlockResponseAndFinish(resultCode); + break; + } + } + + /// + /// Saves the password/passkey and sets the response back to the calling app. + /// + /// The result code of forward intent. + private void SetupCreateCredentialResponseAndFinish([GeneratedEnum] Result resultCode) + { + var isPasskeyRequest = _pendingOperation is PendingOperation.CreatePasskey; + Intent result = new Intent(); + + if (resultCode == KeePass.ExitCloseAfterTaskComplete) + { + if (isPasskeyRequest) + { + // Handle passkey creation + try + { + // Retrieve stored passkey data from instance variables + var callingPackage = _passkeyCallingPackage ?? ""; + var origin = _passkeyOrigin; + var passkeyData = _passkeyResponseData; + var clientDataHash = _passkeyClientDataHash; + var creationOptions = _passkeyCreationOptions; + + var credentialResponse = CreatePasskeyResponse(passkeyData, creationOptions, clientDataHash, callingPackage, origin); + PendingIntentHandler.SetCreateCredentialResponse(result, credentialResponse); + + SetResult(Result.Ok, result); + } + catch (Exception ex) + { + Kp2aLog.Log($"Kp2aCredentialLauncherActivity: Error creating passkey response: {ex.Message}"); + PendingIntentHandler.SetCreateCredentialException( + result, + new CreateCredentialUnknownException(ex.Message) + ); + SetResult(Result.Canceled, result); + } + finally + { + // Clear the state after processing + ClearPasskeyCreationState(); + } + } + else + { + // Handle password creation + PendingIntentHandler.SetCreateCredentialResponse(result, new CreatePasswordResponse()); + SetResult(Result.Ok, result); + } + } + else + { + PendingIntentHandler.SetCreateCredentialException( + result, + new CreateCredentialCancellationException() + ); + SetResult(Result.Canceled, result); + // Clear the state on cancellation + if (isPasskeyRequest) + { + ClearPasskeyCreationState(); + } + } + if (!IsFinishing) + { + Finish(); + } + } + + private CreatePublicKeyCredentialResponse CreatePasskeyResponse(JSONObject? passkeyData, + PublicKeyCredentialCreationOptions? creationOptions, byte[]? clientDataHash, string callingPackage, string? origin) + { + if (passkeyData == null || creationOptions == null) + { + throw new InvalidOperationException("Passkey data not found in state"); + } + + var relyingPartyId = creationOptions.RelyingPartyEntity.Id; + + // Get the created entry (passkey data should already be in CustomData) + var entry = App.Kp2a.LastOpenedEntry?.Entry; + if (entry == null) + { + throw new InvalidOperationException("Entry was not created"); + } + + // Retrieve passkey data from the entry's CustomData + var passkey = PasskeyStorage.RetrievePasskey(entry); + if (passkey == null) + { + throw new InvalidOperationException("Passkey data not found in entry"); + } + var keyTypeId = passkeyData.OptLong("keyTypeId", -7); + + // Convert credential ID from base64 + var credentialId = Base64.Decode(passkey.CredentialId, Base64Flags.UrlSafe | Base64Flags.NoPadding); + + // Load public key from stored data + var publicKey = LoadPublicKeyFromPasskeyData(passkeyData); + + // Build client data response + var clientDataResponse = BuildClientDataResponse(clientDataHash, callingPackage, origin, relyingPartyId, creationOptions); + + // Build public key encodings (CBOR for credential, X.509 SPKI for Android) + var (credentialPublicKeyCbor, publicKeySpki) = BuildPublicKeyEncodings(publicKey, keyTypeId); + + // Build attestation response + var attestationResponse = new AuthenticatorAttestationResponse( + requestOptions: creationOptions, + credentialId: credentialId, + credentialPublicKey: credentialPublicKeyCbor, + userPresent: true, + userVerified: _userVerifiedForCreate, + backupEligibility: passkey.BackupEligibility ?? PasskeyPreferences.GetBackupEligibility(this), + backupState: passkey.BackupState ?? PasskeyPreferences.GetBackupState(this), + publicKeyTypeId: keyTypeId, + publicKeySpki: publicKeySpki, + clientDataResponse: clientDataResponse + ); + + // Create FIDO credential and set response + var credentialResponse = CreateFidoAttestationResponse(passkey.CredentialId, attestationResponse); + _userVerifiedForCreate = false; // clear after use + return credentialResponse; + + } + + private void ClearPasskeyCreationState() + { + _passkeyCreationOptions = null; + _passkeyRequestJson = null; + _passkeyCallingPackage = null; + _passkeyOrigin = null; + _passkeyResponseData = null; + _passkeyClientDataHash = null; + _pendingOperation = PendingOperation.None; + } + + protected override void OnSaveInstanceState(Bundle outState) + { + base.OnSaveInstanceState(outState); + + // Save passkey creation state to survive activity recreation + if (_pendingOperation != PendingOperation.None) + { + outState.PutString(BundleKeyPasskeyRequestJson, _passkeyRequestJson); + outState.PutString(BundleKeyPasskeyCallingPackage, _passkeyCallingPackage); + outState.PutString(BundleKeyPasskeyOrigin, _passkeyOrigin); + outState.PutString(BundleKeyPasskeyResponseData, _passkeyResponseData?.ToString()); + + if (_passkeyClientDataHash != null) + { + outState.PutByteArray(BundleKeyPasskeyClientDataHash, _passkeyClientDataHash); + } + + outState.PutInt(BundleKeyPendingOperation, (int)_pendingOperation); + outState.PutBoolean(BundleKeyUserVerifiedForCreate, _userVerifiedForCreate); + } + } + + private void RestoreInstanceState(Bundle savedInstanceState) + { + _passkeyRequestJson = savedInstanceState.GetString(BundleKeyPasskeyRequestJson); + _passkeyCallingPackage = savedInstanceState.GetString(BundleKeyPasskeyCallingPackage); + _passkeyOrigin = savedInstanceState.GetString(BundleKeyPasskeyOrigin); + + var passkeyResponseDataStr = savedInstanceState.GetString(BundleKeyPasskeyResponseData); + if (!string.IsNullOrEmpty(passkeyResponseDataStr)) + { + try + { + _passkeyResponseData = new JSONObject(passkeyResponseDataStr); + } + catch (Exception ex) + { + Kp2aLog.Log($"Kp2aCredentialLauncherActivity: Failed to restore passkey response data: {ex.Message}"); + } + } + + _passkeyClientDataHash = savedInstanceState.GetByteArray(BundleKeyPasskeyClientDataHash); + _pendingOperation = (PendingOperation)savedInstanceState.GetInt(BundleKeyPendingOperation); + _userVerifiedForCreate = savedInstanceState.GetBoolean(BundleKeyUserVerifiedForCreate); + + // Recreate PublicKeyCredentialCreationOptions from stored JSON if needed + if (_pendingOperation is PendingOperation.CreatePasskey + && !string.IsNullOrEmpty(_passkeyRequestJson)) + { + try + { + _passkeyCreationOptions = new PublicKeyCredentialCreationOptions( + _passkeyRequestJson, + _passkeyClientDataHash + ); + } + catch (Exception ex) + { + Kp2aLog.Log($"Kp2aCredentialLauncherActivity: Failed to restore creation options: {ex.Message}"); + // Clear the state if we can't restore it properly + ClearPasskeyCreationState(); + } + } + } + + private IPublicKey LoadPublicKeyFromPasskeyData(JSONObject passkeyData) + { + var publicKeyBase64 = passkeyData.GetString("publicKeyBase64"); + if (string.IsNullOrEmpty(publicKeyBase64)) + { + throw new InvalidOperationException("Public key not found in passkey data"); + } + + var publicKeyBytes = Base64.Decode(publicKeyBase64, Base64Flags.Default); + var x509KeySpec = new X509EncodedKeySpec(publicKeyBytes); + + // Try different key types: EC, RSA, Ed25519 + // For Ed25519: use wrapper instead of KeyFactory (which requires Keystore on Android) + var keyTypes = new[] { "EC", "RSA" }; + foreach (var keyType in keyTypes) + { + try + { + var keyFactory = KeyFactory.GetInstance(keyType); + var publicKey = keyFactory.GeneratePublic(x509KeySpec); + if (publicKey != null) + { + return publicKey; + } + } + catch + { + // Try next key type + } + } + + // Try Ed25519 using wrapper (Java's Ed25519 KeyFactory requires Keystore) + try + { + return new Ed25519PublicKeyWrapper(publicKeyBytes); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to load public key (tried EC, RSA, Ed25519): {ex.Message}"); + } + } + + private IClientDataResponse BuildClientDataResponse( + byte[]? clientDataHash, + string callingPackage, + string? origin, + string relyingParty, + PublicKeyCredentialCreationOptions creationOptions + ) + { + // Use stored origin or calculate fallback + if (string.IsNullOrEmpty(origin)) + { + // Fallback: calculate from package name + origin = Kp2aDigitalAssetLinksDataSource.IsTrustedBrowser(callingPackage) + ? $"https://{relyingParty}" + : $"android:apk-key-hash:"; // Placeholder if origin not stored + } + + // If hash is pre-calculated by system (privileged app), use ClientDataDefinedResponse with placeholder + if (clientDataHash != null) + { + return new ClientDataDefinedResponse(clientDataHash); + } + + return new ClientDataBuildResponse( + ClientDataBuildResponse.RequestType.Create, + creationOptions.Challenge, + origin + ); + } + + private (byte[] credentialPublicKeyCbor, byte[] publicKeySpki) BuildPublicKeyEncodings(IPublicKey publicKey, long keyTypeId) + { + var coseKeyObject = PasskeyCryptoHelper.ConvertPublicKeyToMap(publicKey, keyTypeId); + if (coseKeyObject == null) + { + throw new InvalidOperationException("Failed to convert public key"); + } + + var credentialPublicKeyCbor = coseKeyObject.EncodeToBytes(); + + var publicKeySpki = PasskeyCryptoHelper.ConvertPublicKey(publicKey, keyTypeId); + if (publicKeySpki == null) + { + throw new InvalidOperationException("Failed to convert public key to X.509 format"); + } + + return (credentialPublicKeyCbor, publicKeySpki); + } + + private CreatePublicKeyCredentialResponse CreateFidoAttestationResponse( + string credentialIdBase64, + AuthenticatorAttestationResponse attestationResponse + ) + { + var fidoCredential = new FidoPublicKeyCredential( + id: credentialIdBase64, + response: attestationResponse.ToJson() + ); + + var responseJson = fidoCredential.ToJson(); + return new CreatePublicKeyCredentialResponse(responseJson); + } + + private GetCredentialResponse CreateFidoAssertionResponse( + string credentialId, + AuthenticatorAssertionResponse assertionResponse, + IClientDataResponse clientDataResponse + ) + { + var fidoCredential = new FidoPublicKeyCredential( + id: credentialId, + response: assertionResponse.ToJson(clientDataResponse) + ); + + var responseJson = fidoCredential.ToJson(); + return new GetCredentialResponse(new PublicKeyCredential(responseJson)); + } + + /// + /// Sets and returns the credential list from the unlocked database + /// + /// The result code of forward intent. + private void SetupUnlockResponseAndFinish([GeneratedEnum] Result resultCode) + { + var thisIntent = Intent; + if (thisIntent != null) + { + + if (_pendingOperation == PendingOperation.GetPasskeyForEntryAfterUnlock) + { + if (resultCode == Result.Ok && App.Kp2a.DatabaseIsUnlocked) + HandleGetPasskeyForEntryRequest(thisIntent); // re-enter to run UV if required + else + Finish(); // unlock was cancelled or failed + return; + } + + // UnlockForGetCredentials path: unlock completed, now populate the Begin-Get picker + // with all available credential types so the user can make their selection. + if (_pendingOperation == PendingOperation.UnlockForGetCredentials) + { + var getRequest = PendingIntentHandler.RetrieveBeginGetCredentialRequest(thisIntent); + if (getRequest != null && App.Kp2a.DatabaseIsUnlocked) + { + var response = new BeginGetCredentialResponse.Builder(); + var callingPackage = getRequest.CallingAppInfo?.PackageName; + foreach (var option in getRequest.BeginGetCredentialOptions) + { + if (option is BeginGetPasswordOption passwordOption && callingPackage != null) + GetCredentialHelper.AddMatchingPasswordEntries(this, callingPackage, passwordOption, response); + else if (option is BeginGetPublicKeyCredentialOption passkeyOption) + GetCredentialHelper.AddMatchingPasskeyEntries(this, passkeyOption, response); + } + var result = new Intent(); + PendingIntentHandler.SetBeginGetCredentialResponse(result, response.Build()); + SetResult(Result.Ok, result); + } + } + } + + if (!IsFinishing) + { + Finish(); + } + } + + private void SetupGetCredentialResponseForEntryAndFinish(PwEntry entry) + { + var username = entry.Strings.ReadSafe(PwDefs.UserNameField); + var password = entry.Strings.ReadSafe(PwDefs.PasswordField); + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + { + SetUpNoCredentialResponseForGetAndFinish(); + return; + } + + var result = new Intent(); + PendingIntentHandler.SetGetCredentialResponse( + result, + new GetCredentialResponse(new PasswordCredential(username, password)) + ); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + + /// + /// If the request is null, send an unknown exception to client and finish the flow. + /// + /// The error message to send to the client. + private void SetUpFailureResponseForCreateAndFinish(string? message = null) + { + var result = new Intent(); + PendingIntentHandler.SetCreateCredentialException( + result, + new CreateCredentialUnknownException(message) + ); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + + /// + /// Sets up a failure response for get request and finishes the activity. + /// + /// The error message to include in the response. + private void SetUpFailureResponseForGetAndFinish(string? message = null) + { + var result = new Intent(); + PendingIntentHandler.SetGetCredentialException( + result, + new GetCredentialUnknownException(message) + ); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + + /// + /// Sets up a no credential response for get request and finishes the activity. + /// + private void SetUpNoCredentialResponseForGetAndFinish() + { + Intent result = new Intent(); + PendingIntentHandler.SetGetCredentialException(result, new NoCredentialException()); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + + #region helper methods + + /// + /// Validates and parses the passkey creation request, generates the key pair, populates all + /// instance state, and launches to create the entry. + /// Throws on any error so the caller can map exceptions to a failure response uniformly. + /// + private void PreparePasskeyCreationAndLaunchEntryCreation(Intent requestIntent) + { + var createRequest = PendingIntentHandler.RetrieveProviderCreateCredentialRequest(requestIntent); + + if (createRequest == null || createRequest.CallingRequest is not CreatePublicKeyCredentialRequest passkeyRequest) + throw new InvalidOperationException("Unable to extract passkey create request from intent"); + + var requestJson = passkeyRequest.RequestJson; + if (string.IsNullOrEmpty(requestJson)) + throw new InvalidOperationException("Missing request JSON"); + + var clientDataHash = passkeyRequest.GetClientDataHash(); + + var creationOptions = _passkeyCreationOptions + ?? new PublicKeyCredentialCreationOptions(requestJson, clientDataHash); + + var requestedAlgorithms = creationOptions.PubKeyCredParams.Select(p => p.Alg).ToList(); + + // Generate credential ID (16 random bytes) + var credentialId = new byte[16]; + new System.Security.Cryptography.RNGCryptoServiceProvider().GetBytes(credentialId); + + // Generate key pair + var keyPairResult = PasskeyCryptoHelper.GenerateKeyPair(requestedAlgorithms); + if (!keyPairResult.HasValue) + throw new InvalidOperationException( + $"No supported public key algorithm found. Requested: [{string.Join(", ", requestedAlgorithms)}]" + ); + + var (keyPair, keyTypeId) = keyPairResult.Value; + var privateKeyPem = PasskeyCryptoHelper.ConvertPrivateKeyToPem(keyPair.Private); + PasskeyData passkey = new PasskeyData + { + Username = creationOptions.UserEntity.Name, + PrivateKeyPem = privateKeyPem, + CredentialId = Base64.EncodeToString(credentialId, Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap), + UserHandle = Base64.EncodeToString(creationOptions.UserEntity.Id, Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap), + RelyingParty = creationOptions.RelyingPartyEntity.Id, + BackupEligibility = PasskeyPreferences.GetBackupEligibility(this), + BackupState = PasskeyPreferences.GetBackupState(this) + }; + + var callingPackage = createRequest.CallingAppInfo?.PackageName ?? ""; + var origin = Kp2aDigitalAssetLinksDataSource.IsTrustedBrowser(callingPackage) + ? $"https://{creationOptions.RelyingPartyEntity.Id}" + : PasskeyOptionParsingHelper.GetOriginForCallingApp(createRequest.CallingAppInfo); + + var passkeyFieldsJson = CreatePasskeyFieldsJson(creationOptions.RelyingPartyEntity.Id, creationOptions.UserEntity.Name, passkey); + + var forwardIntent = BuildLaunchIntentToCreatePasskey(passkeyFieldsJson); + + // Build the response JSON we'll need once the entry has been created. + var passkeyResponseJson = CreatePasskeyResponseJson(keyPair, keyTypeId, passkeyFieldsJson); + + // Persist state so OnActivityResult / ContinuePasskeyCreationAfterUnlock can reconstruct the response. + _passkeyCreationOptions = creationOptions; + _passkeyRequestJson = requestJson; + _passkeyCallingPackage = callingPackage; + _passkeyOrigin = origin; + _passkeyResponseData = passkeyResponseJson; + _passkeyClientDataHash = clientDataHash; + _pendingOperation = PendingOperation.CreatePasskey; + + StartActivityForResult(forwardIntent, CreateEntryRequestCode); + } + + private Intent BuildLaunchIntentToCreatePasskey(JSONObject passkeyFieldsJson) + { + var jsonProtectedFields = new JSONArray(new List + { + PasskeyStorage.FIELD_PRIVATE_KEY, + PasskeyStorage.FIELD_CREDENTIAL_ID, + PasskeyStorage.FIELD_USER_HANDLE + }); + var jsonTags = new JSONArray(new List { PasskeyStorage.PASSKEY_TAG }); + + var forwardIntent = new Intent(this, typeof(SelectCurrentDbActivity)); + forwardIntent.PutExtra(Strings.ExtraEntryOutputData, passkeyFieldsJson.ToString()); + forwardIntent.PutExtra(Strings.ExtraProtectedFieldsList, jsonProtectedFields.ToString()); + forwardIntent.PutExtra(CreateEntryThenCloseTask.TagsKey, jsonTags.ToString()); + forwardIntent.PutExtra(AppTask.AppTaskKey, "CreateEntryThenCloseTask"); + forwardIntent.PutExtra(CreateEntryThenCloseTask.ShowUserNotificationsKey, "false"); + return forwardIntent; + } + + private JSONObject CreatePasskeyResponseJson(KeyPair keyPair, long keyTypeId, JSONObject passkeyFieldsJson) + { + var publicKeyBytes = keyPair.Public.GetEncoded(); + var passkeyResponseJson = new JSONObject(); + passkeyResponseJson.Put("publicKeyBase64", Base64.EncodeToString(publicKeyBytes, Base64Flags.Default)); + passkeyResponseJson.Put("keyTypeId", keyTypeId); + passkeyResponseJson.Put("passkeyFieldsJson", passkeyFieldsJson.ToString()); + return passkeyResponseJson; + } + + private static JSONObject CreatePasskeyFieldsJson(string relyingParty, string username, PasskeyData passkey) + { + // Build AllFields JSON with passkey data in extra fields (compatible with KeePassDX/KeePassXC) + // Start with standard entry fields + var passkeyFieldsJson = new JSONObject(); + passkeyFieldsJson.Put(PwDefs.TitleField, $"Passkey for {relyingParty}"); + passkeyFieldsJson.Put(PwDefs.UserNameField, username); + passkeyFieldsJson.Put(PwDefs.UrlField, $"passkey:{relyingParty}"); + + // Add passkey extra fields + passkeyFieldsJson.Put(PasskeyStorage.FIELD_USERNAME, passkey.Username); + passkeyFieldsJson.Put(PasskeyStorage.FIELD_PRIVATE_KEY, passkey.PrivateKeyPem); + passkeyFieldsJson.Put(PasskeyStorage.FIELD_CREDENTIAL_ID, passkey.CredentialId); + passkeyFieldsJson.Put(PasskeyStorage.FIELD_USER_HANDLE, passkey.UserHandle); + passkeyFieldsJson.Put(PasskeyStorage.FIELD_RELYING_PARTY, passkey.RelyingParty); + if (passkey.BackupEligibility.HasValue) + passkeyFieldsJson.Put(PasskeyStorage.FIELD_FLAG_BE, passkey.BackupEligibility.Value.ToString().ToLower()); + if (passkey.BackupState.HasValue) + passkeyFieldsJson.Put(PasskeyStorage.FIELD_FLAG_BS, passkey.BackupState.Value.ToString().ToLower()); + return passkeyFieldsJson; + } + + #endregion + + + #region Passkey Handler Methods + + /// + /// Handles the request to create a new passkey. + /// If user verification is required, shows device credential/biometric prompt first. + /// + private void HandleCreatePasskeyRequest(Intent requestIntent) + { + try + { + var createRequest = PendingIntentHandler.RetrieveProviderCreateCredentialRequest(requestIntent); + if (createRequest?.CallingRequest is not CreatePublicKeyCredentialRequest passkeyRequest) + { + throw new InvalidOperationException("Unable to extract passkey create request from intent"); + } + + var requestJson = passkeyRequest.RequestJson; + if (string.IsNullOrEmpty(requestJson)) + { + throw new InvalidOperationException("Missing request JSON"); + } + + var clientDataHash = passkeyRequest.GetClientDataHash(); + _passkeyCreationOptions = new PublicKeyCredentialCreationOptions(requestJson, clientDataHash); + var uvRequired = _passkeyCreationOptions.UserVerificationRequirement == Kp2aPasskey.Core.UserVerificationRequirement.Required + || (PasskeyPreferences.GetForceUserVerificationWhenPreferred(this) + && _passkeyCreationOptions.UserVerificationRequirement == Kp2aPasskey.Core.UserVerificationRequirement.Preferred); + + if (!uvRequired) + { + _userVerifiedForCreate = false; + PreparePasskeyCreationAndLaunchEntryCreation(requestIntent); + return; + } + + if (!UserVerificationHelper.CanAuthenticate(this)) + { + SetUpFailureResponseForCreateAndFinish("User verification required but not available on this device."); + return; + } + + var title = GetString(Resource.String.passkey_user_verification_required_title); + var subtitle = GetString(Resource.String.passkey_user_verification_required_description); + UserVerificationHelper.ShowUserVerification( + this, + title, + subtitle, + onSuccess: () => + { + _userVerifiedForCreate = true; + try + { + PreparePasskeyCreationAndLaunchEntryCreation(requestIntent); + } + catch (Exception e) + { + Kp2aLog.Log($"Kp2aCredentialLauncherActivity: Error creating passkey: {e.Message}"); + SetUpFailureResponseForCreateAndFinish(e.Message); + } + }, + onCancelOrError: () => + { + var result = new Intent(); + PendingIntentHandler.SetCreateCredentialException(result, new CreateCredentialCancellationException()); + SetResult(Result.Ok, result); + if (!IsFinishing) Finish(); + }); + } + catch (Exception e) + { + Kp2aLog.Log($"Kp2aCredentialLauncherActivity: Error creating passkey: {e.Message}"); + SetUpFailureResponseForCreateAndFinish(e.Message); + } + } + + + + /// + /// Builds a FIDO2 assertion response for the given passkey and returns it to the credential manager. + /// + /// True if user completed device credential/biometric verification. + private void BuildAndReturnAssertionFromPasskeyAndFinish( + PasskeyData passkey, + CallingAppInfo? callingAppInfo, + PublicKeyCredentialRequestOptions requestOptions, + GetPublicKeyCredentialOption passkeyOption, + bool userVerified) + { + // Build client data response with the correct origin + string origin; + var callingPackage = callingAppInfo?.PackageName ?? ""; + + if (Kp2aDigitalAssetLinksDataSource.IsTrustedBrowser(callingPackage)) + { + // For browsers, use the RP ID as the web origin + origin = $"https://{requestOptions.RpId}"; + } + else + { + // Calculate Android APK origin for native apps + origin = PasskeyOptionParsingHelper.GetOriginForCallingApp(callingAppInfo); + } + // Try to extract clientDataHash from GetPublicKeyCredentialOption + // According to Android docs: if clientDataHash is provided, use it and set placeholder for clientDataJSON + var clientDataHash = PasskeyOptionParsingHelper.ExtractClientDataHashFromOption(passkeyOption); + + IClientDataResponse clientDataResponse; + if (clientDataHash != null) + { + // Privileged app: use provided hash and placeholder for clientDataJSON + clientDataResponse = new ClientDataDefinedResponse(clientDataHash); + } + else + { + // Non-privileged app: build actual clientDataJSON + clientDataResponse = new ClientDataBuildResponse( + ClientDataBuildResponse.RequestType.Get, + requestOptions.Challenge, + origin + ); + } + + // Build assertion response + var assertionResponse = new AuthenticatorAssertionResponse( + requestOptions: requestOptions, + userPresent: true, + userVerified: userVerified, + backupEligibility: passkey.BackupEligibility ?? PasskeyPreferences.GetBackupEligibility(this), + backupState: passkey.BackupState ?? PasskeyPreferences.GetBackupState(this), + userHandle: passkey.UserHandle, + privateKeyPem: passkey.PrivateKeyPem, + clientDataResponse: clientDataResponse + ); + + var getCredentialResponse = CreateFidoAssertionResponse( + passkey.CredentialId, + assertionResponse, + clientDataResponse + ); + + var result = new Intent(); + PendingIntentHandler.SetGetCredentialResponse(result, getCredentialResponse); + SetResult(Result.Ok, result); + + if (!IsFinishing) + { + Finish(); + } + } + + /// + /// Handles the request to get a specific passkey for an entry. + /// Unlocks the database first if needed. If user verification is required, shows device credential/biometric prompt. + /// + private void HandleGetPasskeyForEntryRequest(Intent requestIntent) + { + // Check if database is locked and launch unlock flow if needed + if (!App.Kp2a.DatabaseIsUnlocked) + { + _pendingOperation = PendingOperation.GetPasskeyForEntryAfterUnlock; + LaunchUnlockDatabase(); + return; + } + + var requestJson = requestIntent.GetStringExtra(ExtraRequestJson); + if (string.IsNullOrEmpty(requestJson)) + { + ReturnPasskeyForEntryAndFinish(requestIntent, userVerified: false); + return; + } + + var requestOptions = new PublicKeyCredentialRequestOptions(requestJson); + var uvRequired = requestOptions.UserVerificationRequirement == Kp2aPasskey.Core.UserVerificationRequirement.Required + || (PasskeyPreferences.GetForceUserVerificationWhenPreferred(this) + && requestOptions.UserVerificationRequirement == Kp2aPasskey.Core.UserVerificationRequirement.Preferred); + + if (!uvRequired) + { + ReturnPasskeyForEntryAndFinish(requestIntent, userVerified: false); + return; + } + + if (!UserVerificationHelper.CanAuthenticate(this)) + { + SetUpFailureResponseForGetAndFinish("User verification required but not available on this device."); + return; + } + + var title = GetString(Resource.String.passkey_user_verification_required_title); + var subtitle = GetString(Resource.String.passkey_user_verification_required_description); + UserVerificationHelper.ShowUserVerification( + this, + title, + subtitle, + onSuccess: () => + { + ReturnPasskeyForEntryAndFinish(requestIntent, userVerified: true); + }, + onCancelOrError: () => + { + var result = new Intent(); + PendingIntentHandler.SetGetCredentialException(result, new GetCredentialCancellationException()); + SetResult(Result.Ok, result); + if (!IsFinishing) Finish(); + }); + } + + /// + /// Builds and returns the passkey assertion response for a specific entry. + /// Database must already be unlocked before calling this. + /// True if the user completed device credential/biometric verification. + /// + private void ReturnPasskeyForEntryAndFinish(Intent requestIntent, bool userVerified) + { + try + { + var getRequest = PendingIntentHandler.RetrieveProviderGetCredentialRequest(requestIntent); + var options = getRequest?.CredentialOptions; + + if (options == null || options.Count == 0) + { + SetUpFailureResponseForGetAndFinish("No credential options found"); + return; + } + + var option = options.FirstOrDefault(o => o is GetPublicKeyCredentialOption); + if (!(option is GetPublicKeyCredentialOption passkeyOption)) + { + SetUpFailureResponseForGetAndFinish("Invalid credential option type"); + return; + } + + var entryId = requestIntent.GetStringExtra(Strings.ExtraEntryId); + var requestJson = passkeyOption.RequestJson; + + if (string.IsNullOrEmpty(entryId) || string.IsNullOrEmpty(requestJson)) + { + SetUpFailureResponseForGetAndFinish("Missing entry ID or request JSON"); + return; + } + + var entryUuid = new PwUuid(MemUtil.HexStringToByteArray(entryId)); + + // Find the entry in open databases + PwEntry? foundEntry = null; + var lastOpenedEntry = App.Kp2a.LastOpenedEntry; + if (lastOpenedEntry != null && entryUuid.Equals(lastOpenedEntry.Uuid)) + { + foundEntry = lastOpenedEntry.Entry; + } + else + { + foreach (Database db in App.Kp2a.OpenDatabases) + { + if (db.EntriesById.TryGetValue(entryUuid, out var resultEntry)) + { + foundEntry = resultEntry; + break; + } + } + } + + if (foundEntry == null) + { + SetUpNoCredentialResponseForGetAndFinish(); + return; + } + + // Retrieve passkey data from entry + var passkey = PasskeyStorage.RetrievePasskey(foundEntry); + if (passkey == null) + { + SetUpFailureResponseForGetAndFinish("Entry does not contain passkey data"); + return; + } + + // Parse request options and delegate to shared assertion builder + var requestOptions = new PublicKeyCredentialRequestOptions(requestJson); + + BuildAndReturnAssertionFromPasskeyAndFinish(passkey, getRequest?.CallingAppInfo, requestOptions, passkeyOption, userVerified); + } + catch (Exception e) + { + Kp2aLog.Log($"Kp2aCredentialLauncherActivity: Error retrieving passkey. {e.Message}"); + SetUpFailureResponseForGetAndFinish($"Passkey retrieval failed: {e.Message}"); + } + } + + + #endregion + } +} diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialProviderService.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialProviderService.cs new file mode 100644 index 000000000..60af23dd3 --- /dev/null +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialProviderService.cs @@ -0,0 +1,298 @@ +// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using Android.Content; +using Android.Graphics; +using Android.Graphics.Drawables; +using Android.OS; +using Android.Util; +using AndroidX.Core.App; +using AndroidX.Credentials.Exceptions; +using AndroidX.Credentials.Provider; +using Java.Interop; +using Java.Time; +using Keepass2android.Pluginsdk; +using KeePassLib; +using Kp2aPasskey.Core; + +namespace keepass2android.services.Kp2aCredentialProvider +{ + [Service( + Enabled = true, + Exported = true, + Permission = "android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE" + )] + [IntentFilter(actions: ["android.service.credentials.CredentialProviderService"])] + [MetaData(name: "android.credentials.provider", Resource = "@xml/credentials_provider")] + [System.Runtime.Versioning.SupportedOSPlatform("android31.0")] + public class Kp2aCredentialProviderService : CredentialProviderService + { + private const string TAG = "Kp2aCredentialProviderService"; + private Icon? _defaultIcon; + private bool _isAutoSelectAllowed = false; + + private Icon DefaultIcon + { + get + { + if (_defaultIcon == null) + { + _defaultIcon = Icon.CreateWithResource(this, AppNames.LauncherIcon); + _defaultIcon?.SetTintBlendMode(BlendMode.Dst); + } + return _defaultIcon!; + } + } + + public override void OnBeginCreateCredentialRequest( + BeginCreateCredentialRequest request, + CancellationSignal cancellationSignal, + IOutcomeReceiver callback + ) + { + try + { + if (request is BeginCreatePasswordCredentialRequest) + { + HandleCreatePasswordRequest(callback); + + } + else if (request is BeginCreatePublicKeyCredentialRequest passkeyRequest) + { + HandleCreatePasskeyRequest(passkeyRequest, callback); + } + else + { + callback.OnError(new CreateCredentialUnsupportedException().JavaCast()); + } + } + catch (Exception e) + { + Kp2aLog.Log($"Kp2aCredentialProviderService: onBeginCreateCredentialRequest error. {e.Message}"); + callback.OnError(new CreateCredentialUnknownException(e.Message).JavaCast()); + } + } + + private void HandleCreatePasswordRequest(IOutcomeReceiver callback) + { + var currentDb = App.Kp2a.CurrentDb; + var accountName = currentDb?.KpDatabase?.Name ?? CreateDb.DefaultDbName; + + callback.OnResult( + new BeginCreateCredentialResponse.Builder() + .AddCreateEntry( + new CreateEntry.Builder( + accountName, + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(Kp2aCredentialLauncherActivity)).PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeCreatePassword + ), + (int)PendingIntentFlags.UpdateCurrent, + true + )! + ) + .SetIcon(DefaultIcon) + .SetDescription( + GetString(Resource.String.credential_provider_password_creation_description) + ) + // Set the last used time to "now" + // so the active account is the default option in the system prompt. + .SetLastUsedTime(currentDb == null ? null : Instant.Now()) + .Build() + ) + .Build() + ); + } + + private void HandleCreatePasskeyRequest( + BeginCreatePublicKeyCredentialRequest request, + IOutcomeReceiver callback + ) + { + var currentDb = App.Kp2a.CurrentDb; + var databaseName = currentDb?.KpDatabase?.Name; + var accountName = string.IsNullOrWhiteSpace(databaseName) + ? GetString(AppNames.AppNameResource) + : databaseName; + + var createEntries = new List(); + + try + { + var creationOptions = new PublicKeyCredentialCreationOptions( + request.RequestJson!, + null // ClientDataHash is not available in BeginCreatePublicKeyCredentialRequest + ); + var relyingPartyId = creationOptions.RelyingPartyEntity.Id; + + + // Search for existing passkeys with this relying party + + if (!App.Kp2a.DatabaseIsUnlocked) + { + // Database is locked, show unlock prompt + createEntries.Add( + new CreateEntry.Builder( + accountName, + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(Kp2aCredentialLauncherActivity)) + .PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeCreatePasskey + ) + .PutExtra(Kp2aCredentialLauncherActivity.ExtraRelyingPartyId, relyingPartyId) + .PutExtra(Kp2aCredentialLauncherActivity.ExtraRequestJson, request.RequestJson), + (int)PendingIntentFlags.UpdateCurrent, + true + )! + ) + .SetIcon(DefaultIcon) + .SetDescription(GetString(Resource.String.credential_provider_locked_database_description)) + .Build() + ); + } + else if (currentDb?.CanWrite == false) + { + // Database is read-only, cannot create passkeys + throw new Exception("Cannot register passkey in read-only database"); + } + else + { + // Database is open and writable, show create entry + createEntries.Add( + new CreateEntry.Builder( + accountName, + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(Kp2aCredentialLauncherActivity)) + .PutExtra( + Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, + Kp2aCredentialLauncherActivity.CredentialRequestTypeCreatePasskey + ) + .PutExtra(Kp2aCredentialLauncherActivity.ExtraRelyingPartyId, relyingPartyId) + .PutExtra(Kp2aCredentialLauncherActivity.ExtraRequestJson, request.RequestJson), + (int)PendingIntentFlags.UpdateCurrent, + true + )! + ) + .SetIcon(DefaultIcon) + .SetDescription(GetString(Resource.String.credential_provider_passkey_creation_description)) + .Build() + ); + } + + var responseBuilder = new BeginCreateCredentialResponse.Builder(); + foreach (var entry in createEntries) + { + responseBuilder.AddCreateEntry(entry); + } + callback.OnResult(responseBuilder.Build()); + } + catch (Exception e) + { + Kp2aLog.Log($"Kp2aCredentialProviderService: Error handling passkey creation request. {e.Message}"); + callback.OnError(new CreateCredentialUnknownException(e.Message).JavaCast()); + } + } + + public override void OnBeginGetCredentialRequest( + BeginGetCredentialRequest request, + CancellationSignal cancellationSignal, + IOutcomeReceiver callback + ) + { + try + { + var appLocked = !App.Kp2a.DatabaseIsUnlocked; + var responseBuilder = new BeginGetCredentialResponse.Builder(); + + if (appLocked) + { + callback.OnResult( + responseBuilder + .AddAuthenticationAction( + GetCredentialHelper.CreateAuthenticationAction(this) + ) + .Build() + ); + return; + } + + var hasKnownOption = false; + foreach (var option in request.BeginGetCredentialOptions) + { + if (option is BeginGetPasswordOption passwordOption) + { + hasKnownOption = true; + var callingPackage = request.CallingAppInfo?.PackageName; + if (callingPackage == null) + { + callback.OnError(new NoCredentialException().JavaCast()); + return; + } + GetCredentialHelper.AddMatchingPasswordEntries(this, callingPackage, passwordOption, responseBuilder); + } + else if (option is BeginGetPublicKeyCredentialOption passkeyOption) + { + hasKnownOption = true; + GetCredentialHelper.AddMatchingPasskeyEntries(this, passkeyOption, responseBuilder, _isAutoSelectAllowed); + } + } + + if (!hasKnownOption) + { + throw new Exception("Unknown type of beginGetCredentialOption"); + } + + responseBuilder.AddAction( + new AndroidX.Credentials.Provider.Action( + GetString(Resource.String.open_app_name, GetString(AppNames.AppNameResource)!), + PendingIntentCompat.GetActivity( + this, + App.Kp2a.RequestCodeForCredentialProvider, + new Intent(this, typeof(KeePass)), + (int)PendingIntentFlags.UpdateCurrent, + true + )!, + GetString(Resource.String.manage_credentials) + ) + ); + + callback.OnResult(responseBuilder.Build()); + } + catch (Exception e) + { + Kp2aLog.Log($"Kp2aCredentialProviderService: onBeginGetCredentialRequest error. {e.Message}"); + callback.OnError(new GetCredentialUnknownException().JavaCast()); + } + } + + + public override void OnClearCredentialStateRequest( + ProviderClearCredentialStateRequest request, + CancellationSignal cancellationSignal, + IOutcomeReceiver callback + ) + { + // ignored + } + } +} diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/PasskeyOptionParsingHelper.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/PasskeyOptionParsingHelper.cs new file mode 100644 index 000000000..9e2342dd4 --- /dev/null +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/PasskeyOptionParsingHelper.cs @@ -0,0 +1,120 @@ +using Android.Content.PM; +using Android.Runtime; +using Android.Util; +using AndroidX.Credentials; +using AndroidX.Credentials.Provider; + +namespace keepass2android.services.Kp2aCredentialProvider +{ + public class PasskeyOptionParsingHelper + { + /// + /// Extract clientDataHash from GetPublicKeyCredentialOption + /// Returns null if not available (non-privileged app) + /// Tries multiple methods: JNI direct call, reflection, property access + /// + public static byte[]? ExtractClientDataHashFromOption(GetPublicKeyCredentialOption passkeyOption) + { + // There is no C# binding available at present. Call Java method directly via JNI + try + { + var handle = passkeyOption.Handle; + if (handle != IntPtr.Zero) + { + var classHandle = passkeyOption.Class.Handle; + var methodId = JNIEnv.GetMethodID( + classHandle, + "getClientDataHash", + "()[B" + ); + + if (methodId != IntPtr.Zero) + { + IntPtr resultPtr = JNIEnv.CallObjectMethod(handle, methodId); + if (resultPtr != IntPtr.Zero) + { + try + { + // Convert Java byte[] to C# byte[] using JNIEnv.GetArray + var javaArray = JNIEnv.GetArray(resultPtr); + if (javaArray is { Length: > 0 }) + { + var clientDataHash = new byte[javaArray.Length]; + Array.Copy(javaArray, clientDataHash, javaArray.Length); + return clientDataHash; + } + } + finally + { + JNIEnv.DeleteLocalRef(resultPtr); + } + } + } + } + } + catch (Exception ex) + { + Kp2aLog.Log($"JNI getClientDataHash() failed: {ex.Message}"); + } + return null; + } + + private const string AppKeyHashStringPrefix = "android:apk-key-hash:"; + + /// + /// Get the origin for a calling app in the format: android:apk-key-hash:base64-sha256 + /// + public static string GetOriginForCallingApp(CallingAppInfo? callingAppInfo) + { + + if (callingAppInfo == null || string.IsNullOrEmpty(callingAppInfo.PackageName)) + { + return AppKeyHashStringPrefix; + } + + try + { + var packageManager = Application.Context.PackageManager; + + var packageInfo = packageManager?.GetPackageInfo( + callingAppInfo.PackageName, + PackageInfoFlags.Signatures + ); + + if (packageInfo?.Signatures == null || packageInfo.Signatures.Count == 0) + { + return AppKeyHashStringPrefix; + } + + // Get the first signature and extract the X.509 certificate + var signature = packageInfo.Signatures[0]; + + // Parse the X.509 certificate from the signature (same as KeePassDX) + var certFactory = Java.Security.Cert.CertificateFactory.GetInstance("X.509"); + var signatureBytes = signature.ToByteArray(); + using var memStream = new MemoryStream(signatureBytes); + var x509Cert = (Java.Security.Cert.X509Certificate)certFactory.GenerateCertificate(memStream); + + // Hash the DER-encoded certificate (not the signature!) + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var certEncoded = x509Cert?.GetEncoded(); + if (certEncoded == null) + { + return AppKeyHashStringPrefix; + } + var hash = sha256.ComputeHash(certEncoded); + var base64Hash = Base64.EncodeToString( + hash, + Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap + ); + + return $"{AppKeyHashStringPrefix}{base64Hash}"; + } + catch (Exception e) + { + Kp2aLog.Log($"Error getting origin for {callingAppInfo.PackageName}: {e.Message}"); + return AppKeyHashStringPrefix; + } + } + } +} \ No newline at end of file diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/UserVerificationHelper.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/UserVerificationHelper.cs new file mode 100644 index 000000000..d89f6bfa5 --- /dev/null +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/UserVerificationHelper.cs @@ -0,0 +1,120 @@ +// Derived from KeePassDX (https://github.com/Kunzisoft/KeePassDX) +// Original work Copyright 2025 Jeremy Jamet / Kunzisoft. +// Licensed under the GNU General Public License v3 or later. +// +// Modifications Copyright 2026 Philipp Crocoll. +// This file is part of Keepass2Android. +// +// Keepass2Android is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Keepass2Android is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Keepass2Android. If not, see . + +using Android.Content; +using Android.OS; +using AndroidX.Biometric; +using AndroidX.Fragment.App; +using Java.Util.Concurrent; +using Keepass2android; + +namespace keepass2android.services.Kp2aCredentialProvider +{ + /// + /// Shows a device credential / biometric prompt for WebAuthn User Verification. + /// No database unlock fallback — only system biometric or device PIN/pattern/password. + /// + public static class UserVerificationHelper + { + /// + /// Returns true if the device can show biometric or device credential authentication. + /// + public static bool CanAuthenticate(Context context) + { + var biometricManager = BiometricManager.From(context); + var result = biometricManager.CanAuthenticate(BiometricManager.Authenticators.BiometricWeak | BiometricManager.Authenticators.DeviceCredential); + return result == BiometricManager.BiometricSuccess; + } + + /// + /// Shows the user verification prompt (biometric or device credential). + /// On success, is invoked on the main thread and then the activity may continue. + /// On cancel or error, is invoked and the caller should finish with failure/cancel. + /// + /// Must be a FragmentActivity (e.g. AppCompatActivity). + /// Dialog title (e.g. "User verification required"). + /// Optional subtitle (e.g. origin or app name). + /// Called when authentication succeeds. + /// Called when user cancels or authentication fails. + public static void ShowUserVerification( + FragmentActivity activity, + string title, + string? subtitle, + Action onSuccess, + Action? onCancelOrError) + { + var executor = Executors.NewSingleThreadExecutor(); + var callback = new BiometricCallback(onSuccess, onCancelOrError); + + var promptInfo = new BiometricPrompt.PromptInfo.Builder() + .SetTitle(title) + .SetSubtitle(subtitle ?? "") + .SetConfirmationRequired(false) + .SetDeviceCredentialAllowed(true) + .Build(); + + var biometricPrompt = new BiometricPrompt(activity, executor, callback); + biometricPrompt.Authenticate(promptInfo); + } + + private sealed class BiometricCallback : BiometricPrompt.AuthenticationCallback + { + private readonly Action _onSuccess; + private readonly Action? _onCancelOrError; + + public BiometricCallback(Action onSuccess, Action? onCancelOrError) + { + _onSuccess = onSuccess; + _onCancelOrError = onCancelOrError; + } + + public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) + { + base.OnAuthenticationSucceeded(result); + RunOnMain(() => _onSuccess()); + } + + public override void OnAuthenticationError(int errorCode, Java.Lang.ICharSequence? errString) + { + base.OnAuthenticationError(errorCode, errString); + if (errorCode != BiometricPrompt.ErrorCanceled && + errorCode != BiometricPrompt.ErrorNegativeButton && + errorCode != BiometricPrompt.ErrorUserCanceled) + { + Kp2aLog.Log("UserVerificationHelper: authentication error " + errorCode + " " + errString); + } + RunOnMain(() => _onCancelOrError?.Invoke()); + } + + public override void OnAuthenticationFailed() + { + // Single attempt failed (e.g. wrong fingerprint) — the prompt stays open for retry. + // Do nothing here; OnAuthenticationError handles final cancellation/lockout. + base.OnAuthenticationFailed(); + } + + private static void RunOnMain(Action action) + { + var handler = new Handler(Looper.MainLooper!); + handler.Post(action); + } + } + } +} diff --git a/src/keepass2android-app/settings/AppSettingsActivity.cs b/src/keepass2android-app/settings/AppSettingsActivity.cs index cc12068e7..cfd21411a 100644 --- a/src/keepass2android-app/settings/AppSettingsActivity.cs +++ b/src/keepass2android-app/settings/AppSettingsActivity.cs @@ -682,6 +682,13 @@ public TotpPreferenceFragment() : base(Resource.Xml.pref_app_traytotp) } } + public class PasskeyPreferenceFragment : PreferenceFragmentWithResource + { + public PasskeyPreferenceFragment() : base(Resource.Xml.pref_app_passkeys) + { + } + } + public class DebugLogPreferenceFragment : PreferenceFragmentWithResource { @@ -1208,7 +1215,7 @@ public void OnBackStackChanged() /// /// Activity to configure the application, without database settings. Does not require an unlocked database, or close when the database is locked /// - [Activity(Label = "@string/app_name", Theme = "@style/Kp2aTheme_BlueActionBar", ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden)] + [Activity(Label = "@string/app_name", Name = "keepass2android.AppSettingsActivity", Theme = "@style/Kp2aTheme_BlueActionBar", ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden, Exported = true)] public class AppSettingsActivity : LockingActivity, PreferenceFragmentCompat.IOnPreferenceStartFragmentCallback, FragmentManager.IOnBackStackChangedListener { private ActivityDesign _design; From 44e04c49541dc7b5c3dae89e057c4862f1967923 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 23 Mar 2026 09:13:54 +0100 Subject: [PATCH 02/18] remove temporary testing project --- src/KeePass.sln | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/KeePass.sln b/src/KeePass.sln index 229c14219..3199a7035 100644 --- a/src/KeePass.sln +++ b/src/KeePass.sln @@ -31,8 +31,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kp2aAutofillParser.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DropboxBinding", "DropboxBinding\DropboxBinding.csproj", "{2FE6E335-E834-4F86-AB83-2C5D225DA929}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kp2aPasskey.Tests", "Kp2aPasskey.Tests\Kp2aPasskey.Tests.csproj", "{093AF66F-C59F-041F-2B79-06532DC19E8F}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kp2aPasskey.Core", "Kp2aPasskey.Core\Kp2aPasskey.Core.csproj", "{6B081853-9DEB-9E9F-FF45-6758B0C357CC}" EndProject Global @@ -399,30 +397,6 @@ Global {2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU {2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU {2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|x64.Build.0 = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Win32.ActiveCfg = Debug|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|Win32.Build.0 = Debug|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|x64.ActiveCfg = Debug|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Debug|x64.Build.0 = Debug|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Any CPU.Build.0 = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Win32.ActiveCfg = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|Win32.Build.0 = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|x64.ActiveCfg = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.Release|x64.Build.0 = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Mixed Platforms.Build.0 = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Win32.ActiveCfg = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU - {093AF66F-C59F-041F-2B79-06532DC19E8F}.ReleaseNoNet|x64.Build.0 = Release|Any CPU {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {6B081853-9DEB-9E9F-FF45-6758B0C357CC}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU From f14d8b009186602ac41e689c39d3518da268b0da Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 23 Mar 2026 09:36:12 +0100 Subject: [PATCH 03/18] prevent changing the UV preference without identification --- .../Resources/values/strings.xml | 1 + .../settings/AppSettingsActivity.cs | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index 0453c244c..75533ca40 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -1365,6 +1365,7 @@ Configure passkey backup behavior Backup Flags User Verification + Confirm your identity to change this setting. passkeys_backup_eligibility_key diff --git a/src/keepass2android-app/settings/AppSettingsActivity.cs b/src/keepass2android-app/settings/AppSettingsActivity.cs index cfd21411a..be0baaa68 100644 --- a/src/keepass2android-app/settings/AppSettingsActivity.cs +++ b/src/keepass2android-app/settings/AppSettingsActivity.cs @@ -51,6 +51,8 @@ You should have received a copy of the GNU General Public License using KeePassLib.Keys; using KeePassLib.Serialization; using FragmentManager = AndroidX.Fragment.App.FragmentManager; +using AndroidX.Fragment.App; +using keepass2android.services.Kp2aCredentialProvider; namespace keepass2android { @@ -687,6 +689,43 @@ public class PasskeyPreferenceFragment : PreferenceFragmentWithResource public PasskeyPreferenceFragment() : base(Resource.Xml.pref_app_passkeys) { } + + public override void OnCreatePreferences(Bundle savedInstanceState, string rootKey) + { + base.OnCreatePreferences(savedInstanceState, rootKey); + + var uvPref = FindPreference(GetString(Resource.String.passkeys_force_user_verification_when_preferred_key)) as SwitchPreferenceCompat; + if (uvPref != null) + uvPref.PreferenceChange += (sender, args) => + { + var context = RequireContext(); + var boolValue = (bool)args.NewValue; + + if (!UserVerificationHelper.CanAuthenticate(context)) + return; // no biometric hardware — allow change directly + + // reject any change for now. If the (async) user verification succeeds, we'll update the value. + args.Handled = false; + + UserVerificationHelper.ShowUserVerification( + RequireActivity(), + GetString(Resource.String.passkey_user_verification_required_title), + GetString(Resource.String.passkeys_uv_setting_change_subtitle), + onSuccess: () => + { + PreferenceManager.GetDefaultSharedPreferences(context) + .Edit() + .PutBoolean(((Preference)sender).Key, boolValue) + .Apply(); + ((SwitchPreferenceCompat)sender).Checked = boolValue; + }, + onCancelOrError: () => + { + } + ); + }; + } + } public class DebugLogPreferenceFragment : PreferenceFragmentWithResource From 1b90cefff26f1fae5107dcebc3035e4948a56ff1 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 27 Apr 2026 11:57:14 +0200 Subject: [PATCH 04/18] simplify code --- src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs | 15 --------------- src/keepass2android-app/EntryEditActivity.cs | 2 +- .../Kp2aCredentialLauncherActivity.cs | 2 +- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs index d32f0acf0..40a75b3fe 100644 --- a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs +++ b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs @@ -412,22 +412,7 @@ private static CBORObject ConvertRsaPublicKeyToMap(IPublicKey rsaPublicKey) } } - /// - /// Convert public key to X.509 SubjectPublicKeyInfo format for JSON response - /// Returns the full encoded public key in SPKI format, not just the EC point - /// Note: Java's GetEncoded() already returns EC points in UNCOMPRESSED format (0x04 || x || y) - /// within the X.509 SPKI structure, so we don't need to explicitly set the point format - /// like BouncyCastle's BCECPublicKey.setPointFormat("UNCOMPRESSED") - /// - public static byte[]? ConvertPublicKey(IPublicKey publicKey, long keyTypeId) - { - if (publicKey == null) - return null; - // Return the full X.509 SubjectPublicKeyInfo (SPKI) format - // This is what GetEncoded() returns by default for Java public keys - return publicKey.GetEncoded(); - } private static byte[]? RemoveLeadingZero(byte[]? bytes) { diff --git a/src/keepass2android-app/EntryEditActivity.cs b/src/keepass2android-app/EntryEditActivity.cs index 7beb3a5a7..1c97a48f9 100644 --- a/src/keepass2android-app/EntryEditActivity.cs +++ b/src/keepass2android-app/EntryEditActivity.cs @@ -1540,7 +1540,7 @@ void UpdateExpires() { PopulateText(Resource.Id.entry_expires, GetString(Resource.String.never)); } - ((CheckBox)FindViewById(Resource.Id.entry_expires_checkbox)).Checked = State.Entry.Expires; + ((CheckBox)FindViewById(Resource.Id.entry_expires_checkbox)).Checked = State.Entry.Expires; FindViewById(Resource.Id.entry_expires).Enabled = State.Entry.Expires; } diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs index 349d7ac46..d49f4e153 100644 --- a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs @@ -564,7 +564,7 @@ PublicKeyCredentialCreationOptions creationOptions var credentialPublicKeyCbor = coseKeyObject.EncodeToBytes(); - var publicKeySpki = PasskeyCryptoHelper.ConvertPublicKey(publicKey, keyTypeId); + var publicKeySpki = publicKey?.GetEncoded(); if (publicKeySpki == null) { throw new InvalidOperationException("Failed to convert public key to X.509 format"); From e91b69b96128ad365d8dfbd5eaf8ce3b163cc8ea Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 27 Apr 2026 12:02:22 +0200 Subject: [PATCH 05/18] remove clientExtensionResults from json --- src/Kp2aPasskey.Core/AuthenticatorResponses.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs index 592d97f85..3a7eb3cf3 100644 --- a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs +++ b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs @@ -424,7 +424,7 @@ public string ToJson() json.Put("type", "public-key"); json.Put("authenticatorAttachment", authenticatorAttachment); json.Put("response", response); - json.Put("clientExtensionResults", new JSONObject()); // TODO credProps + var jsonString = json.ToString(); Log.Debug("FidoPublicKeyCredential", "=== COMPLETE CREDENTIAL JSON ==="); From 1bf33d37e5590c3da6a9e4a7d677c8c8a6583658 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 27 Apr 2026 12:13:20 +0200 Subject: [PATCH 06/18] remove logging calls from development. --- .../AuthenticatorResponses.cs | 59 +------------------ src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs | 24 +------- 2 files changed, 2 insertions(+), 81 deletions(-) diff --git a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs index 3a7eb3cf3..ff06bd541 100644 --- a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs +++ b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs @@ -236,16 +236,6 @@ private byte[] BuildAuthData() attestedCredentialData: true ); - // Log for debugging - Log.Debug("AuthenticatorAttestationResponse", - $"Base authData length: {authData.Length}, RP ID: {requestOptions.RelyingPartyEntity.Id}"); - Log.Debug("AuthenticatorAttestationResponse", - $"AAGUID length: {AaGuid1.Length}, CredentialId length: {credentialId.Length}"); - Log.Debug("AuthenticatorAttestationResponse", - $"CredentialPublicKey length: {credentialPublicKey.Length}"); - Log.Debug("AuthenticatorAttestationResponse", - $"CredentialPublicKey CBOR hex: {BitConverter.ToString(credentialPublicKey)}"); - // Append AAGUID + credIdLen + credentialId + credentialPublicKey var credIdLen = new[] { @@ -260,9 +250,6 @@ private byte[] BuildAuthData() .Concat(credentialPublicKey) .ToArray(); - Log.Debug("AuthenticatorAttestationResponse", - $"Final authData length: {result.Length}"); - return result; } @@ -282,16 +269,6 @@ public JSONObject ToJson() var authData = BuildAuthData(); var attestationObject = BuildAttestationObject(); - // Log hex bytes for debugging - FULL dumps for comparison with KeePassDX - Log.Debug("AuthenticatorAttestationResponse", - $"=== FULL AuthData ({authData.Length} bytes) ==="); - Log.Debug("AuthenticatorAttestationResponse", - BitConverter.ToString(authData)); - Log.Debug("AuthenticatorAttestationResponse", - $"=== FULL AttestationObject ({attestationObject.Length} bytes) ==="); - Log.Debug("AuthenticatorAttestationResponse", - BitConverter.ToString(attestationObject)); - var json = new JSONObject(); json.Put("clientDataJSON", clientDataResponse.BuildResponse()); json.Put("authenticatorData", Base64EncodeUrlSafe(authData)); @@ -357,10 +334,6 @@ IClientDataResponse clientDataResponse { _userHandle = userHandle; - Log.Debug("AuthenticatorAssertionResponse", "=== AUTHENTICATION DEBUG ==="); - Log.Debug("AuthenticatorAssertionResponse", $"RP ID: {requestOptions.RpId}"); - Log.Debug("AuthenticatorAssertionResponse", $"Challenge (base64): {Base64.EncodeToString(requestOptions.Challenge, Base64Flags.UrlSafe | Base64Flags.NoPadding | Base64Flags.NoWrap)}"); - _authenticatorData = AuthenticatorDataBuilder.BuildAuthenticatorData( relyingPartyId: Encoding.UTF8.GetBytes(requestOptions.RpId), userPresent: userPresent, @@ -369,18 +342,11 @@ IClientDataResponse clientDataResponse backupState: backupState ); - Log.Debug("AuthenticatorAssertionResponse", $"AuthenticatorData ({_authenticatorData.Length} bytes): {BitConverter.ToString(_authenticatorData)}"); - var clientDataHash = clientDataResponse.HashData(); - Log.Debug("AuthenticatorAssertionResponse", $"ClientDataHash ({clientDataHash.Length} bytes): {BitConverter.ToString(clientDataHash)}"); - Log.Debug("AuthenticatorAssertionResponse", $"ClientDataJSON: {Encoding.UTF8.GetString(Base64.Decode(clientDataResponse.BuildResponse(), Base64Flags.UrlSafe | Base64Flags.NoPadding))}"); // Sign: authenticatorData || clientDataHash var dataToSign = _authenticatorData.Concat(clientDataHash).ToArray(); - Log.Debug("AuthenticatorAssertionResponse", $"DataToSign ({dataToSign.Length} bytes): {BitConverter.ToString(dataToSign)}"); - _signature = PasskeyCryptoHelper.Sign(privateKeyPem, dataToSign); - Log.Debug("AuthenticatorAssertionResponse", $"Signature ({_signature.Length} bytes): {BitConverter.ToString(_signature)}"); } public JSONObject ToJson(IClientDataResponse clientDataResponse) @@ -395,12 +361,6 @@ public JSONObject ToJson(IClientDataResponse clientDataResponse) json.Put("signature", signatureB64); json.Put("userHandle", _userHandle); - Log.Debug("AuthenticatorAssertionResponse", "=== RESPONSE JSON FIELDS ==="); - Log.Debug("AuthenticatorAssertionResponse", $"clientDataJSON: {clientDataJson}"); - Log.Debug("AuthenticatorAssertionResponse", $"authenticatorData (base64): {authenticatorDataB64}"); - Log.Debug("AuthenticatorAssertionResponse", $"signature (base64): {signatureB64}"); - Log.Debug("AuthenticatorAssertionResponse", $"userHandle: {_userHandle}"); - return json; } @@ -425,24 +385,7 @@ public string ToJson() json.Put("authenticatorAttachment", authenticatorAttachment); json.Put("response", response); - - var jsonString = json.ToString(); - Log.Debug("FidoPublicKeyCredential", "=== COMPLETE CREDENTIAL JSON ==="); - Log.Debug("FidoPublicKeyCredential", $"id: {id}"); - Log.Debug("FidoPublicKeyCredential", $"rawId: {id}"); - Log.Debug("FidoPublicKeyCredential", $"type: public-key"); - Log.Debug("FidoPublicKeyCredential", $"authenticatorAttachment: {authenticatorAttachment}"); - Log.Debug("FidoPublicKeyCredential", $"Full JSON length: {jsonString.Length}"); - - // Log JSON in chunks to avoid truncation - const int chunkSize = 200; - for (int i = 0; i < jsonString.Length; i += chunkSize) - { - int length = Math.Min(chunkSize, jsonString.Length - i); - Log.Debug("FidoPublicKeyCredential", $"Complete JSON chunk {i / chunkSize}: {jsonString.Substring(i, length)}"); - } - - return jsonString; + return json.ToString(); } } } diff --git a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs index 40a75b3fe..77385dd73 100644 --- a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs +++ b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs @@ -101,36 +101,25 @@ public static (AndroidKeyPair keyPair, long algorithmId)? GenerateKeyPair(IEnume { var keyPair = GenerateEd25519KeyPair(); if (keyPair != null) - { - Log.Debug("PasskeyCryptoHelper", $"Generated Ed25519 key pair (algorithm {algorithmId})"); return (keyPair, CoseAlgEd25519); - } break; } case CoseAlgEs256: { var keyPair = GenerateEs256KeyPair(); if (keyPair != null) - { - Log.Debug("PasskeyCryptoHelper", $"Generated ES256 key pair (algorithm {algorithmId})"); return (keyPair, CoseAlgEs256); - } break; } case CoseAlgRs256: { var keyPair = GenerateRs256KeyPair(); if (keyPair != null) - { - Log.Debug("PasskeyCryptoHelper", $"Generated RS256 key pair (algorithm {algorithmId})"); return (keyPair, CoseAlgRs256); - } break; } default: - // Unsupported algorithm, try next one - Log.Debug("PasskeyCryptoHelper", $"Unsupported algorithm {algorithmId}, skipping"); - break; + break; // Unsupported algorithm, try next one } } @@ -186,11 +175,7 @@ public static (AndroidKeyPair keyPair, long algorithmId)? GenerateKeyPair(IEnume { try { - Log.Debug("PasskeyCryptoHelper", $"Attempting to generate Ed25519 key pair on Android {Android.OS.Build.VERSION.SdkInt} (API {(int)Android.OS.Build.VERSION.SdkInt})"); - // Android's Ed25519 KeyPairGenerator requires Keystore, so we use BouncyCastle C# API - Log.Debug("PasskeyCryptoHelper", "Using BouncyCastle C# API to generate Ed25519 key pair"); - var keyPairGenerator = new Ed25519KeyPairGenerator(); keyPairGenerator.Init(new Ed25519KeyGenerationParameters(new Org.BouncyCastle.Security.SecureRandom())); @@ -208,14 +193,11 @@ public static (AndroidKeyPair keyPair, long algorithmId)? GenerateKeyPair(IEnume var publicKeyInfo = Org.BouncyCastle.X509.SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(publicKeyParams); var publicKeyBytes = publicKeyInfo.GetEncoded(); - Log.Debug("PasskeyCryptoHelper", $"Generated Ed25519 keys - Private: {privateKeyBytes.Length} bytes, Public: {publicKeyBytes.Length} bytes"); - // Create wrapper keys that implement Java interfaces var privateKey = new Ed25519PrivateKeyWrapper(privateKeyBytes); var publicKey = new Ed25519PublicKeyWrapper(publicKeyBytes); var keyPair = new AndroidKeyPair(publicKey, privateKey); - Log.Debug("PasskeyCryptoHelper", $"Ed25519 key pair wrapped successfully. Public key algorithm: {keyPair.Public?.Algorithm}, Format: {keyPair.Public?.Format}"); return keyPair; } catch (Exception ex) @@ -277,15 +259,11 @@ public static byte[] Sign(string privateKeyPem, byte[] dataToSign) _ => throw new SecurityException($"Unknown key type: {privateKeyParams.GetType().Name}") }; - Log.Debug("PasskeyCryptoHelper", $"Signing with {algorithmSignature} (key type: {privateKeyParams.GetType().Name})"); - // Sign using BouncyCastle C# API var signer = SignerUtilities.GetSigner(algorithmSignature); signer.Init(true, privateKeyParams); signer.BlockUpdate(dataToSign, 0, dataToSign.Length); var signatureBytes = signer.GenerateSignature(); - - Log.Debug("PasskeyCryptoHelper", $"Signed {dataToSign.Length} bytes → {signatureBytes.Length} byte signature"); return signatureBytes; } catch (Exception ex) From 387972cd1b2fa62e0888789247947ac9442c4a4b Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 27 Apr 2026 12:23:14 +0200 Subject: [PATCH 07/18] avoid building the auth data twice --- src/Kp2aPasskey.Core/AuthenticatorResponses.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs index ff06bd541..ea20fac91 100644 --- a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs +++ b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs @@ -253,13 +253,13 @@ private byte[] BuildAuthData() return result; } - private byte[] BuildAttestationObject() + private byte[] BuildAttestationObject(byte[] authData) { // https://www.w3.org/TR/webauthn-3/#attestation-object var cbor = CBORObject.NewMap() .Add("fmt", "none") .Add("attStmt", CBORObject.NewMap()) - .Add("authData", BuildAuthData()); + .Add("authData", authData); return cbor.EncodeToBytes(); } @@ -267,7 +267,7 @@ private byte[] BuildAttestationObject() public JSONObject ToJson() { var authData = BuildAuthData(); - var attestationObject = BuildAttestationObject(); + var attestationObject = BuildAttestationObject(authData); var json = new JSONObject(); json.Put("clientDataJSON", clientDataResponse.BuildResponse()); From 8addce57b50c279c9217f0690d200ea1f8141b3c Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 27 Apr 2026 12:24:47 +0200 Subject: [PATCH 08/18] make variable const --- src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs index 77385dd73..2b48d8aee 100644 --- a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs +++ b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs @@ -136,8 +136,8 @@ public static (AndroidKeyPair keyPair, long algorithmId)? GenerateKeyPair(IEnume { try { - var es256CurveNameBc = "secp256r1"; - var spec = new ECGenParameterSpec(es256CurveNameBc); + const string es256CurveName = "secp256r1"; + var spec = new ECGenParameterSpec(es256CurveName); var keyPairGenerator = KeyPairGenerator.GetInstance("EC"); keyPairGenerator?.Initialize(spec); return keyPairGenerator?.GenerateKeyPair(); From b2e5cdeaa45f3140425e93f25974fa5079d45bf7 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 27 Apr 2026 12:25:32 +0200 Subject: [PATCH 09/18] rename field --- src/Kp2aPasskey.Core/AuthenticatorResponses.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs index ea20fac91..56ef0680b 100644 --- a/src/Kp2aPasskey.Core/AuthenticatorResponses.cs +++ b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs @@ -37,7 +37,7 @@ namespace keepass2android.services.Kp2aCredentialProvider.Passkey /// - The request came from the correct relying party (via RP ID hash) /// - User presence and verification status /// - Whether the credential is backed up and eligible for sync - /// - For registration: includes attested credential data (AAGUID + credential ID + public key) + /// - For registration: includes attested credential data (AuthenticatorAttestationGuid + credential ID + public key) /// /// Structure per https://www.w3.org/TR/webauthn-3/#table-authData: /// - rpIdHash: 32 bytes (SHA-256 of relying party ID) @@ -94,7 +94,7 @@ public static byte[] BuildAuthenticatorData( flags |= 0x10; // Bit 6: Attested Credential Data (AT) - Credential data present - // Set during registration to indicate AAGUID + credentialId + publicKey follow + // Set during registration to indicate AuthenticatorAttestationGuid + credentialId + publicKey follow if (attestedCredentialData) flags |= 0x40; @@ -194,7 +194,7 @@ private static string Base64EncodeUrlSafe(byte[] data) /// - publicKeyAlgorithm: COSE algorithm identifier (ES256=-7, RS256=-257, EdDSA=-8) /// /// The authenticatorData includes: - /// - AAGUID: Authenticator Attestation GUID identifying keepass2android + /// - AuthenticatorAttestationGuid: Authenticator Attestation GUID identifying keepass2android /// - Credential ID: Unique identifier for this credential /// - Credential Public Key: COSE-encoded public key /// - Flags: AT (attested credential data present), BE/BS (backup eligibility/state), UP/UV @@ -217,9 +217,9 @@ IClientDataResponse clientDataResponse ) { - // AAGUID in RFC 4122 (big-endian) format, not Microsoft's mixed-endian format + // AuthenticatorAttestationGuid in RFC 4122 (big-endian) format, not Microsoft's mixed-endian format - public static byte[] AaGuid1 { get; } = + public static byte[] AuthenticatorAttestationGuid { get; } = [ 0xea, 0xec, 0xde, 0xf2, 0x1c, 0x31, 0x56, 0x34, 0x86, 0x39, 0xf1, 0xcb, 0xd9, 0xc0, 0x0a, 0x08 @@ -236,7 +236,7 @@ private byte[] BuildAuthData() attestedCredentialData: true ); - // Append AAGUID + credIdLen + credentialId + credentialPublicKey + // Append AuthenticatorAttestationGuid + credIdLen + credentialId + credentialPublicKey var credIdLen = new[] { (byte)(credentialId.Length >> 8), @@ -244,7 +244,7 @@ private byte[] BuildAuthData() }; var result = authData - .Concat(AaGuid1) + .Concat(AuthenticatorAttestationGuid) .Concat(credIdLen) .Concat(credentialId) .Concat(credentialPublicKey) From bcb728bc114de0dfcd7f313be6f8f05a569c7356 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 27 Apr 2026 12:27:04 +0200 Subject: [PATCH 10/18] simplify comments --- src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs index 2b48d8aee..3137b7e32 100644 --- a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs +++ b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs @@ -378,8 +378,8 @@ private static CBORObject ConvertRsaPublicKeyToMap(IPublicKey rsaPublicKey) return CBORObject.NewMap() .Add(CoseKeyKeytype, CoseKtyRsa) .Add(CoseKeyAlgorithm, (int)CoseAlgRs256) - .Add(CoseKeyCurve, n!) // n: modulus (reusing CRV label per COSE spec for RSA) - .Add(CoseKeyX, e!); // e: exponent (reusing X label per COSE spec for RSA) + .Add(CoseKeyCurve, n!) // n: modulus + .Add(CoseKeyX, e!); // e: exponent } throw new ArgumentException("Failed to extract RSA key specification"); From f5fa9b9f007e6e5cc5db98b2c921e966286c5cb7 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 27 Apr 2026 13:03:59 +0200 Subject: [PATCH 11/18] changelog and manifest for 1.16-pre0 --- src/keepass2android-app/ChangeLog.cs | 7 ++++++- src/keepass2android-app/Manifests/AndroidManifest_net.xml | 4 ++-- .../Manifests/AndroidManifest_nonet.xml | 4 ++-- src/keepass2android-app/Resources/values/strings.xml | 6 ++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/keepass2android-app/ChangeLog.cs b/src/keepass2android-app/ChangeLog.cs index 189a54304..c3ccd5359 100644 --- a/src/keepass2android-app/ChangeLog.cs +++ b/src/keepass2android-app/ChangeLog.cs @@ -46,7 +46,12 @@ public static void ShowChangeLog(Context ctx, Action onDismiss) MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(ctx); builder.SetTitle(ctx.GetString(Resource.String.ChangeLog_title)); List changeLog = new List{ - BuildChangelogString(ctx, new List{Resource.Array.ChangeLog_1_15, + BuildChangelogString(ctx, new List{ + Resource.Array.ChangeLog_1_16 + },"1.16-pre"), + BuildChangelogString(ctx, new List{ + + Resource.Array.ChangeLog_1_15, #if !NoNet Resource.Array.ChangeLog_1_15_net #endif diff --git a/src/keepass2android-app/Manifests/AndroidManifest_net.xml b/src/keepass2android-app/Manifests/AndroidManifest_net.xml index e94525179..d5d46cbc4 100644 --- a/src/keepass2android-app/Manifests/AndroidManifest_net.xml +++ b/src/keepass2android-app/Manifests/AndroidManifest_net.xml @@ -16,8 +16,8 @@ along with Keepass2Android. If not, see . --> diff --git a/src/keepass2android-app/Manifests/AndroidManifest_nonet.xml b/src/keepass2android-app/Manifests/AndroidManifest_nonet.xml index bb235aff9..e80e757a5 100644 --- a/src/keepass2android-app/Manifests/AndroidManifest_nonet.xml +++ b/src/keepass2android-app/Manifests/AndroidManifest_nonet.xml @@ -16,8 +16,8 @@ along with Keepass2Android. If not, see . --> diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index 75533ca40..5ff67d973 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -769,6 +769,12 @@ Size of chunks when uploading to WebDav servers in bytes. Allow clear-text network traffic in WebDav HTTP connections are not secure. Only enable this if you have other security measures in place. + + + This is a preview for Passkey support in Keepass2Android. + Please note that this is still experimental. Don\'t rely on the functionality to work. + + Improvements to URL handling for WebDav with non-ASCII characters Improvements to OneDrive Authentication From edacebfd7783d8e974ba8eb25068dd4366146d92 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 4 May 2026 13:12:11 +0200 Subject: [PATCH 12/18] store passkey BE/BS flags as 0/1 instead of false/true --- src/Kp2aPasskey.Core/PasskeyStorage.cs | 75 +++++++------------ .../Kp2aCredentialLauncherActivity.cs | 23 +----- 2 files changed, 27 insertions(+), 71 deletions(-) diff --git a/src/Kp2aPasskey.Core/PasskeyStorage.cs b/src/Kp2aPasskey.Core/PasskeyStorage.cs index c11fd4c5a..ed8e0bd32 100644 --- a/src/Kp2aPasskey.Core/PasskeyStorage.cs +++ b/src/Kp2aPasskey.Core/PasskeyStorage.cs @@ -22,6 +22,7 @@ using System.Linq; using KeePassLib; using KeePassLib.Security; +using Org.Json; namespace Kp2aPasskey.Core { @@ -43,51 +44,28 @@ public static class PasskeyStorage public const string FIELD_FLAG_BS = "KPEX_PASSKEY_FLAG_BS"; public const string PASSKEY_TAG = "Passkey"; - /// - /// Store passkey data in a KeePass entry using ExtraFields (Strings) - /// - public static void StorePasskey(PwEntry entry, PasskeyData passkey) - { - if (entry == null || passkey == null) - throw new ArgumentNullException(); - - // Add Passkey tag - if (!entry.Tags.Contains(PASSKEY_TAG)) - { - var tags = new List(entry.Tags); - tags.Add(PASSKEY_TAG); - entry.Tags = tags; - } - - // Store passkey data in ExtraFields (Strings) - entry.Strings.Set(FIELD_USERNAME, new ProtectedString(false, passkey.Username)); - entry.Strings.Set(FIELD_PRIVATE_KEY, new ProtectedString(true, passkey.PrivateKeyPem)); // Protected - entry.Strings.Set(FIELD_CREDENTIAL_ID, new ProtectedString(true, passkey.CredentialId)); // Protected - entry.Strings.Set(FIELD_USER_HANDLE, new ProtectedString(true, passkey.UserHandle)); // Protected - entry.Strings.Set(FIELD_RELYING_PARTY, new ProtectedString(false, passkey.RelyingParty)); + public static JSONObject CreatePasskeyFieldsJson(string relyingParty, string username, PasskeyData passkey) + { + // Build AllFields JSON with passkey data in extra fields (compatible with KeePassDX/KeePassXC) + // Start with standard entry fields + var passkeyFieldsJson = new JSONObject(); + passkeyFieldsJson.Put(PwDefs.TitleField, $"Passkey for {relyingParty}"); + passkeyFieldsJson.Put(PwDefs.UserNameField, username); + passkeyFieldsJson.Put(PwDefs.UrlField, $"passkey:{relyingParty}"); + + // Add passkey extra fields + passkeyFieldsJson.Put(PasskeyStorage.FIELD_USERNAME, passkey.Username); + passkeyFieldsJson.Put(PasskeyStorage.FIELD_PRIVATE_KEY, passkey.PrivateKeyPem); + passkeyFieldsJson.Put(PasskeyStorage.FIELD_CREDENTIAL_ID, passkey.CredentialId); + passkeyFieldsJson.Put(PasskeyStorage.FIELD_USER_HANDLE, passkey.UserHandle); + passkeyFieldsJson.Put(PasskeyStorage.FIELD_RELYING_PARTY, passkey.RelyingParty); if (passkey.BackupEligibility.HasValue) - entry.Strings.Set(FIELD_FLAG_BE, new ProtectedString(false, passkey.BackupEligibility.Value.ToString().ToLower())); - + passkeyFieldsJson.Put(PasskeyStorage.FIELD_FLAG_BE, passkey.BackupEligibility.Value ? "1" : "0"); if (passkey.BackupState.HasValue) - entry.Strings.Set(FIELD_FLAG_BS, new ProtectedString(false, passkey.BackupState.Value.ToString().ToLower())); - - // Also store the username in the standard username field if not already set - if (string.IsNullOrEmpty(entry.Strings.ReadSafe(PwDefs.UserNameField))) - { - entry.Strings.Set(PwDefs.UserNameField, new ProtectedString(false, passkey.Username)); - } - - // Add passkey: URL for searchability - var url = entry.Strings.ReadSafe(PwDefs.UrlField); - var passkeyUrl = $"passkey:{passkey.RelyingParty}"; - if (!url.Contains(passkeyUrl)) - { - var newUrl = string.IsNullOrEmpty(url) ? passkeyUrl : $"{url}\n{passkeyUrl}"; - entry.Strings.Set(PwDefs.UrlField, new ProtectedString(false, newUrl)); - } + passkeyFieldsJson.Put(PasskeyStorage.FIELD_FLAG_BS, passkey.BackupState.Value ? "1" : "0"); + return passkeyFieldsJson; } - /// /// Retrieve passkey data from a KeePass entry /// @@ -130,20 +108,19 @@ public static void StorePasskey(PwEntry entry, PasskeyData passkey) // Parse optional boolean fields var backupEligibleStr = entry.Strings.ReadSafe(FIELD_FLAG_BE); - if (!string.IsNullOrEmpty(backupEligibleStr) && bool.TryParse(backupEligibleStr, out var backupEligible)) - { - passkey.BackupEligibility = backupEligible; - } + if (!string.IsNullOrEmpty(backupEligibleStr)) + passkey.BackupEligibility = ParseBool(backupEligibleStr); var backupStateStr = entry.Strings.ReadSafe(FIELD_FLAG_BS); - if (!string.IsNullOrEmpty(backupStateStr) && bool.TryParse(backupStateStr, out var backupState)) - { - passkey.BackupState = backupState; - } + if (!string.IsNullOrEmpty(backupStateStr)) + passkey.BackupState = ParseBool(backupStateStr); return passkey; } + private static bool ParseBool(string value) => + value == "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + /// /// Check if an entry contains passkey data /// diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs index d49f4e153..499fa6cea 100644 --- a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs @@ -780,7 +780,7 @@ private void PreparePasskeyCreationAndLaunchEntryCreation(Intent requestIntent) ? $"https://{creationOptions.RelyingPartyEntity.Id}" : PasskeyOptionParsingHelper.GetOriginForCallingApp(createRequest.CallingAppInfo); - var passkeyFieldsJson = CreatePasskeyFieldsJson(creationOptions.RelyingPartyEntity.Id, creationOptions.UserEntity.Name, passkey); + var passkeyFieldsJson = PasskeyStorage.CreatePasskeyFieldsJson(creationOptions.RelyingPartyEntity.Id, creationOptions.UserEntity.Name, passkey); var forwardIntent = BuildLaunchIntentToCreatePasskey(passkeyFieldsJson); @@ -828,27 +828,6 @@ private JSONObject CreatePasskeyResponseJson(KeyPair keyPair, long keyTypeId, JS return passkeyResponseJson; } - private static JSONObject CreatePasskeyFieldsJson(string relyingParty, string username, PasskeyData passkey) - { - // Build AllFields JSON with passkey data in extra fields (compatible with KeePassDX/KeePassXC) - // Start with standard entry fields - var passkeyFieldsJson = new JSONObject(); - passkeyFieldsJson.Put(PwDefs.TitleField, $"Passkey for {relyingParty}"); - passkeyFieldsJson.Put(PwDefs.UserNameField, username); - passkeyFieldsJson.Put(PwDefs.UrlField, $"passkey:{relyingParty}"); - - // Add passkey extra fields - passkeyFieldsJson.Put(PasskeyStorage.FIELD_USERNAME, passkey.Username); - passkeyFieldsJson.Put(PasskeyStorage.FIELD_PRIVATE_KEY, passkey.PrivateKeyPem); - passkeyFieldsJson.Put(PasskeyStorage.FIELD_CREDENTIAL_ID, passkey.CredentialId); - passkeyFieldsJson.Put(PasskeyStorage.FIELD_USER_HANDLE, passkey.UserHandle); - passkeyFieldsJson.Put(PasskeyStorage.FIELD_RELYING_PARTY, passkey.RelyingParty); - if (passkey.BackupEligibility.HasValue) - passkeyFieldsJson.Put(PasskeyStorage.FIELD_FLAG_BE, passkey.BackupEligibility.Value.ToString().ToLower()); - if (passkey.BackupState.HasValue) - passkeyFieldsJson.Put(PasskeyStorage.FIELD_FLAG_BS, passkey.BackupState.Value.ToString().ToLower()); - return passkeyFieldsJson; - } #endregion From e1f0876809d046966d0e3c8b1d87a95ab90a8178 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 4 May 2026 13:43:50 +0200 Subject: [PATCH 13/18] improve compatibility with KeepassXC which doesn't work with v1 PEM storage. --- src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs index 3137b7e32..02633e23f 100644 --- a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs +++ b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs @@ -185,8 +185,15 @@ public static (AndroidKeyPair keyPair, long algorithmId)? GenerateKeyPair(IEnume var privateKeyParams = (Ed25519PrivateKeyParameters)bcKeyPair.Private; var publicKeyParams = (Ed25519PublicKeyParameters)bcKeyPair.Public; - // Get PKCS#8 encoded private key - var privateKeyInfo = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(privateKeyParams); + // Build PKCS#8 v0 PrivateKeyInfo (RFC 8410) — KeePassXC/OpenSSL compatible + // BouncyCastle's PrivateKeyInfoFactory generates OneAsymmetricKey v1 which embeds the + // public key in an optional [1] field. This might be rejected for Ed25519. + var algId = new Org.BouncyCastle.Asn1.X509.AlgorithmIdentifier( + Org.BouncyCastle.Asn1.EdEC.EdECObjectIdentifiers.id_Ed25519); + var rawSeed = privateKeyParams.GetEncoded(); // 32-byte private key seed + // RFC 8410 CurvePrivateKey ::= OCTET STRING (seed), stored as the privateKey content + var privateKeyInfo = new Org.BouncyCastle.Asn1.Pkcs.PrivateKeyInfo(algId, + new Org.BouncyCastle.Asn1.DerOctetString(rawSeed)); var privateKeyBytes = privateKeyInfo.GetEncoded(); // Get X.509 encoded public key From ea9f46ee8b029319cff1c1612da999536e0fec56 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 4 May 2026 15:40:49 +0200 Subject: [PATCH 14/18] fix adding tag and marking fields as protected in passkey entries --- src/Kp2aPasskey.Core/PasskeyStorage.cs | 4 ++++ .../Kp2aCredentialLauncherActivity.cs | 13 +++---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Kp2aPasskey.Core/PasskeyStorage.cs b/src/Kp2aPasskey.Core/PasskeyStorage.cs index ed8e0bd32..7a4614c29 100644 --- a/src/Kp2aPasskey.Core/PasskeyStorage.cs +++ b/src/Kp2aPasskey.Core/PasskeyStorage.cs @@ -44,6 +44,10 @@ public static class PasskeyStorage public const string FIELD_FLAG_BS = "KPEX_PASSKEY_FLAG_BS"; public const string PASSKEY_TAG = "Passkey"; + // Fields that must be stored encrypted (ProtectInMemory) in the KeePass entry + public static readonly IReadOnlyList ProtectedFields = + [FIELD_PRIVATE_KEY, FIELD_CREDENTIAL_ID, FIELD_USER_HANDLE]; + public static JSONObject CreatePasskeyFieldsJson(string relyingParty, string username, PasskeyData passkey) { diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs index 499fa6cea..6b10b16c3 100644 --- a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs @@ -801,18 +801,11 @@ private void PreparePasskeyCreationAndLaunchEntryCreation(Intent requestIntent) private Intent BuildLaunchIntentToCreatePasskey(JSONObject passkeyFieldsJson) { - var jsonProtectedFields = new JSONArray(new List - { - PasskeyStorage.FIELD_PRIVATE_KEY, - PasskeyStorage.FIELD_CREDENTIAL_ID, - PasskeyStorage.FIELD_USER_HANDLE - }); - var jsonTags = new JSONArray(new List { PasskeyStorage.PASSKEY_TAG }); - var forwardIntent = new Intent(this, typeof(SelectCurrentDbActivity)); forwardIntent.PutExtra(Strings.ExtraEntryOutputData, passkeyFieldsJson.ToString()); - forwardIntent.PutExtra(Strings.ExtraProtectedFieldsList, jsonProtectedFields.ToString()); - forwardIntent.PutExtra(CreateEntryThenCloseTask.TagsKey, jsonTags.ToString()); + // Must use PutStringArrayListExtra — CreateEntryThenCloseTask.Setup reads these with GetStringArrayList + forwardIntent.PutStringArrayListExtra(Strings.ExtraProtectedFieldsList, PasskeyStorage.ProtectedFields.ToList()); + forwardIntent.PutStringArrayListExtra(CreateEntryThenCloseTask.TagsKey, new List { PasskeyStorage.PASSKEY_TAG }); forwardIntent.PutExtra(AppTask.AppTaskKey, "CreateEntryThenCloseTask"); forwardIntent.PutExtra(CreateEntryThenCloseTask.ShowUserNotificationsKey, "false"); return forwardIntent; From 7c1437a82a009af6dcf3e7aa412b40a0d87fc039 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 4 May 2026 20:37:26 +0200 Subject: [PATCH 15/18] improve robustness and compatibility of ES256 private key storage in password entries --- src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs | 109 ++++++++++++++++---- 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs index 02633e23f..66d95771d 100644 --- a/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs +++ b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs @@ -18,11 +18,17 @@ // You should have received a copy of the GNU General Public License // along with Keepass2Android. If not, see . +using System.Linq; using System.Security.Cryptography; using Android.Util; using Java.Lang; using Java.Security; using Java.Security.Spec; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.Sec; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Asn1.X9; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Parameters; @@ -217,21 +223,25 @@ public static (AndroidKeyPair keyPair, long algorithmId)? GenerateKeyPair(IEnume /// /// Convert a private key to PEM format for storage (PKCS#8). - /// Uses BouncyCastle PemWriter (PKCS#8 DER → base64 with PEM headers). + /// Always writes PKCS#8 "BEGIN PRIVATE KEY" format. + /// Note: PemWriter must NOT be used here — for EC keys it auto-converts to SEC1 + /// ("BEGIN EC PRIVATE KEY") which BouncyCastle's own PemReader cannot reliably + /// parse back due to a curve OID lookup bug in ECDomainParameters.FromX962Parameters. /// public static string ConvertPrivateKeyToPem(IPrivateKey privateKey) { var encoded = privateKey?.GetEncoded() ?? throw new ArgumentException("Cannot encode private key"); - // Parse the PKCS#8 DER bytes into a BouncyCastle PrivateKeyInfo ASN.1 structure - var privateKeyInfo = Org.BouncyCastle.Asn1.Pkcs.PrivateKeyInfo.GetInstance(encoded); - - // Let PemWriter handle headers, base64 and line-wrapping - using var writer = new StringWriter(); - new PemWriter(writer).WriteObject(privateKeyInfo); - return writer.ToString().Trim(); - + // encoded is always PKCS#8 DER on Android (Java GetEncoded() contract). + // Build the PEM manually to guarantee "BEGIN PRIVATE KEY" header regardless of key type. + var b64 = Convert.ToBase64String(encoded); + var sb = new System.Text.StringBuilder(); + sb.AppendLine("-----BEGIN PRIVATE KEY-----"); + for (int i = 0; i < b64.Length; i += 64) + sb.AppendLine(b64.Substring(i, System.Math.Min(64, b64.Length - i))); + sb.Append("-----END PRIVATE KEY-----"); + return sb.ToString(); } /// @@ -244,18 +254,8 @@ public static byte[] Sign(string privateKeyPem, byte[] dataToSign) { try { - // Parse PEM once with BouncyCastle — covers PKCS#8, SEC1 (EC), PKCS#1 (RSA) - AsymmetricKeyParameter privateKeyParams; - using (var reader = new StringReader(privateKeyPem)) - { - var pemObject = new PemReader(reader).ReadObject(); - privateKeyParams = pemObject switch - { - AsymmetricCipherKeyPair kp => kp.Private, - AsymmetricKeyParameter kp => kp, - _ => throw new ArgumentException($"Unsupported PEM object type: {pemObject?.GetType().Name}") - }; - } + // Parse PEM — covers PKCS#8 and, as a fallback for existing entries, SEC1 (BEGIN EC PRIVATE KEY) + var privateKeyParams = ParsePrivateKeyFromPem(privateKeyPem); // Determine signature algorithm directly from key type — no string matching on .Algorithm string algorithmSignature = privateKeyParams switch @@ -281,6 +281,73 @@ public static byte[] Sign(string privateKeyPem, byte[] dataToSign) } + /// + /// Parse an asymmetric private key from a PEM string. + /// Handles PKCS#8 ("BEGIN PRIVATE KEY"), PKCS#1 RSA, and SEC1 EC ("BEGIN EC PRIVATE KEY"). + /// SEC1 fallback is needed because BouncyCastle's PemReader can throw on some SEC1 keys + /// due to a NullReferenceException in ECDomainParameters.FromX962Parameters. + /// + private static AsymmetricKeyParameter ParsePrivateKeyFromPem(string pemText) + { + try + { + using var reader = new StringReader(pemText); + var obj = new PemReader(reader).ReadObject(); + return obj switch + { + AsymmetricCipherKeyPair kp => kp.Private, + AsymmetricKeyParameter k => k, + _ => throw new ArgumentException($"Unsupported PEM object type: {obj?.GetType().Name}") + }; + } + catch when (pemText.Contains("EC PRIVATE KEY")) + { + // BouncyCastle PemReader fails to read some SEC1 keys (NRE in curve OID lookup). + // Convert SEC1 → PKCS#8 PrivateKeyInfo manually so PrivateKeyFactory can handle it. + return ParseSec1EcPrivateKey(ExtractDerBytesFromPem(pemText)); + } + } + + /// + /// Parse a SEC1 EC private key DER blob by wrapping it in a PKCS#8 PrivateKeyInfo + /// and delegating to BouncyCastle's PrivateKeyFactory. + /// + private static ECPrivateKeyParameters ParseSec1EcPrivateKey(byte[] sec1Der) + { + // SEC1 ECPrivateKey ::= SEQUENCE { version, privateKey OCTET STRING, + // [0] ECParameters OPTIONAL, [1] publicKey BIT STRING OPTIONAL } + var sec1Seq = (Asn1Sequence)Asn1Object.FromByteArray(sec1Der); + var sec1 = ECPrivateKeyStructure.GetInstance(sec1Seq); + + // Extract curve OID from the optional [0] context-tagged field + Asn1Object? curveParams = null; + foreach (var element in sec1Seq) + { + if (element is Asn1TaggedObject tagged && tagged.TagNo == 0) + { + curveParams = tagged.GetObject(); + break; + } + } + + if (curveParams == null) + throw new ArgumentException("SEC1 EC key missing curve parameters in [0] field"); + + // Build PKCS#8 PrivateKeyInfo: AlgorithmIdentifier { id-ecPublicKey, curveOID } + SEC1 body + var algId = new AlgorithmIdentifier(X9ObjectIdentifiers.IdECPublicKey, curveParams); + var privKeyInfo = new PrivateKeyInfo(algId, sec1); + return (ECPrivateKeyParameters)PrivateKeyFactory.CreateKey(privKeyInfo); + } + + private static byte[] ExtractDerBytesFromPem(string pemText) + { + var base64 = string.Concat( + pemText.Split('\n') + .Select(l => l.Trim()) + .Where(l => l.Length > 0 && !l.StartsWith("-----"))); + return Convert.FromBase64String(base64); + } + /// /// Convert a public key to CBOR format for COSE encoding /// From ff85a1adf3e6d03e5d52356a19a9e70537cf9646 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 4 May 2026 20:39:20 +0200 Subject: [PATCH 16/18] minor refactoring --- src/keepass2android-app/app/AppTask.cs | 2 +- .../Kp2aCredentialLauncherActivity.cs | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/keepass2android-app/app/AppTask.cs b/src/keepass2android-app/app/AppTask.cs index 6dabded30..e9abcbe37 100644 --- a/src/keepass2android-app/app/AppTask.cs +++ b/src/keepass2android-app/app/AppTask.cs @@ -896,7 +896,7 @@ public override void PrepareNewEntry(PwEntry newEntry) { string key = iter.Next().ToString(); string value = allFields.Get(key).ToString(); - bool isProtected = ((ProtectedFieldsList != null) && (ProtectedFieldsList.Contains(key))) + bool isProtected = ((ProtectedFieldsList != null) && ProtectedFieldsList.Contains(key)) || (key == PwDefs.PasswordField); newEntry.Strings.Set(key, new ProtectedString(isProtected, value)); } diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs index 6b10b16c3..42dd7fbb8 100644 --- a/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs @@ -385,6 +385,9 @@ private CreatePublicKeyCredentialResponse CreatePasskeyResponse(JSONObject? pass // Build public key encodings (CBOR for credential, X.509 SPKI for Android) var (credentialPublicKeyCbor, publicKeySpki) = BuildPublicKeyEncodings(publicKey, keyTypeId); + var beFlag = passkey.BackupEligibility ?? PasskeyPreferences.GetBackupEligibility(this); + var bsFlag = passkey.BackupState ?? PasskeyPreferences.GetBackupState(this); + // Build attestation response var attestationResponse = new AuthenticatorAttestationResponse( requestOptions: creationOptions, @@ -392,8 +395,8 @@ private CreatePublicKeyCredentialResponse CreatePasskeyResponse(JSONObject? pass credentialPublicKey: credentialPublicKeyCbor, userPresent: true, userVerified: _userVerifiedForCreate, - backupEligibility: passkey.BackupEligibility ?? PasskeyPreferences.GetBackupEligibility(this), - backupState: passkey.BackupState ?? PasskeyPreferences.GetBackupState(this), + backupEligibility: beFlag, + backupState: bsFlag, publicKeyTypeId: keyTypeId, publicKeySpki: publicKeySpki, clientDataResponse: clientDataResponse @@ -947,13 +950,16 @@ private void BuildAndReturnAssertionFromPasskeyAndFinish( ); } + var assertionBe = passkey.BackupEligibility ?? PasskeyPreferences.GetBackupEligibility(this); + var assertionBs = passkey.BackupState ?? PasskeyPreferences.GetBackupState(this); + // Build assertion response var assertionResponse = new AuthenticatorAssertionResponse( requestOptions: requestOptions, userPresent: true, userVerified: userVerified, - backupEligibility: passkey.BackupEligibility ?? PasskeyPreferences.GetBackupEligibility(this), - backupState: passkey.BackupState ?? PasskeyPreferences.GetBackupState(this), + backupEligibility: assertionBe, + backupState: assertionBs, userHandle: passkey.UserHandle, privateKeyPem: passkey.PrivateKeyPem, clientDataResponse: clientDataResponse From 861db5b2617a1ffce7705627e309bf5c69df8c34 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 4 May 2026 21:03:48 +0200 Subject: [PATCH 17/18] slightly improve how entries for GetCredentials are built by using unique IDs. --- .../GetCredentialHelper.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs index 1d12de5b3..8b010b63e 100644 --- a/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs @@ -66,7 +66,8 @@ BeginGetCredentialResponse.Builder responseBuilder username, PendingIntentCompat.GetActivity( context, - App.Kp2a.RequestCodeForCredentialProvider, + // Use a random request code to ensure distinct PendingIntents for each entry + System.Security.Cryptography.RandomNumberGenerator.GetInt32(int.MaxValue), new Intent(context, typeof(Kp2aCredentialLauncherActivity)) .PutExtra( Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, @@ -124,16 +125,26 @@ public static void AddMatchingPasskeyEntries( { foreach (var entry in foundEntries) { - var username = entry.Strings.ReadSafe(PwDefs.UserNameField); - if (string.IsNullOrEmpty(username)) username = "Unknown"; - + // NOTE: if multiple entries have the same username, they might not all be shown in the UI. + // This is an Android design decision which we respect (no pseudo-different usernames or so) + + // The passkey username (account name at the RP) is shown as the primary label. + var passkeyUsername = entry.Strings.ReadSafe(PasskeyStorage.FIELD_USERNAME); + if (string.IsNullOrEmpty(passkeyUsername)) + passkeyUsername = entry.Strings.ReadSafe(PwDefs.UserNameField); + if (string.IsNullOrEmpty(passkeyUsername)) passkeyUsername = "Unknown"; + + // Use a random request code per PendingIntent. FLAG_UPDATE_CURRENT only matches a + // prior PendingIntent when the request code is the same, so a random code guarantees + // every entry gets a distinct PendingIntent even when entries share the same username. + var entryRequestCode = System.Security.Cryptography.RandomNumberGenerator.GetInt32(int.MaxValue); responseBuilder.AddCredentialEntry( new PublicKeyCredentialEntry.Builder( context, - username, + passkeyUsername, PendingIntentCompat.GetActivity( context, - App.Kp2a.RequestCodeForCredentialProvider, + entryRequestCode, new Intent(context, typeof(Kp2aCredentialLauncherActivity)) .PutExtra( Kp2aCredentialLauncherActivity.CredentialRequestTypeKey, From 1e14dbd48b30446ba249455fc07bb4f4ebe8e20b Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 4 May 2026 21:33:20 +0200 Subject: [PATCH 18/18] don't store pseudo-urls for passkey entries. Search RelyingParty field instead of URL. --- src/Kp2aBusinessLogic/SearchDbHelper.cs | 22 +++++++++++++++++++ src/Kp2aBusinessLogic/database/Database.cs | 9 ++++++++ src/Kp2aPasskey.Core/PasskeyStorage.cs | 2 +- .../GetCredentialHelper.cs | 20 ++++++++++++----- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/Kp2aBusinessLogic/SearchDbHelper.cs b/src/Kp2aBusinessLogic/SearchDbHelper.cs index 83628adb8..d3b7fd02f 100644 --- a/src/Kp2aBusinessLogic/SearchDbHelper.cs +++ b/src/Kp2aBusinessLogic/SearchDbHelper.cs @@ -119,6 +119,28 @@ private static String ExtractHost(String url) return UrlUtil.GetHost(url.Trim()); } + /// + /// Returns all entries whose KPEX_PASSKEY_RELYING_PARTY extra field exactly matches + /// (case-insensitive). + /// + public PwGroup SearchForRelyingParty(Database database, string relyingParty) + { + string strGroupName = _app.GetResourceString(UiStringKey.search_results); + PwGroup pgResults = new PwGroup(true, true, strGroupName, PwIcon.EMailSearch) { IsVirtual = true }; + + foreach (PwEntry entry in database.EntriesById.Values) + { + if (!entry.GetSearchingEnabled()) + continue; + var storedRp = entry.Strings.ReadSafe("KPEX_PASSKEY_RELYING_PARTY"); + // Log every entry that has the field so mismatches are visible in logcat + if (string.Equals(storedRp, relyingParty, StringComparison.OrdinalIgnoreCase)) + pgResults.AddEntry(entry, false); + } + + return pgResults; + } + public PwGroup SearchForHost(Database database, String url, bool allowSubdomains) { String host = ExtractHost(url); diff --git a/src/Kp2aBusinessLogic/database/Database.cs b/src/Kp2aBusinessLogic/database/Database.cs index 0121f57eb..f8e675afa 100644 --- a/src/Kp2aBusinessLogic/database/Database.cs +++ b/src/Kp2aBusinessLogic/database/Database.cs @@ -224,6 +224,15 @@ public PwGroup SearchForHost(String url, bool allowSubdomains) } + /// + /// Returns all entries whose KPEX_PASSKEY_RELYING_PARTY extra field + /// exactly matches (case-insensitive). + /// + public PwGroup SearchForRelyingParty(string relyingParty) + { + return SearchHelper.SearchForRelyingParty(this, relyingParty); + } + public void SaveData(IFileStorage fileStorage) { diff --git a/src/Kp2aPasskey.Core/PasskeyStorage.cs b/src/Kp2aPasskey.Core/PasskeyStorage.cs index 7a4614c29..4359522b9 100644 --- a/src/Kp2aPasskey.Core/PasskeyStorage.cs +++ b/src/Kp2aPasskey.Core/PasskeyStorage.cs @@ -56,7 +56,7 @@ public static JSONObject CreatePasskeyFieldsJson(string relyingParty, string use var passkeyFieldsJson = new JSONObject(); passkeyFieldsJson.Put(PwDefs.TitleField, $"Passkey for {relyingParty}"); passkeyFieldsJson.Put(PwDefs.UserNameField, username); - passkeyFieldsJson.Put(PwDefs.UrlField, $"passkey:{relyingParty}"); + passkeyFieldsJson.Put(PwDefs.UrlField, relyingParty); // Add passkey extra fields passkeyFieldsJson.Put(PasskeyStorage.FIELD_USERNAME, passkey.Username); diff --git a/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs b/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs index 8b010b63e..7f04763da 100644 --- a/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs +++ b/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs @@ -111,9 +111,16 @@ public static void AddMatchingPasskeyEntries( var relyingPartyId = requestOptions.RpId; var allowCredentials = requestOptions.AllowCredentials; - var query = $"passkey:{relyingPartyId}"; - var searchResults = ShareUrlResults.GetSearchResultsForUrl(query); - var foundEntries = searchResults?.Entries.ToList() ?? new List(); + // Search directly by the KPEX_PASSKEY_RELYING_PARTY extra field — no pseudo-URL needed. + var foundEntries = new List(); + foreach (var db in App.Kp2a.OpenDatabases) + { + var results = db.SearchForRelyingParty(relyingPartyId); + if (results?.Entries != null) + { + foundEntries.AddRange(results.Entries); + } + } // Filter by allowCredentials if specified if (allowCredentials.Count > 0) @@ -213,10 +220,13 @@ List allowCredentials foreach (var entry in entries) { var passkey = Kp2aPasskey.Core.PasskeyStorage.RetrievePasskey(entry); - if (passkey != null && allowedCredentialIds.Contains(passkey.CredentialId)) + if (passkey == null) { - filtered.Add(entry); + continue; } + bool match = allowedCredentialIds.Contains(passkey.CredentialId); + if (match) + filtered.Add(entry); } return filtered;