diff --git a/src/KeePass.sln b/src/KeePass.sln
index 4123c9ff1..3199a7035 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,8 @@ 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.Core", "Kp2aPasskey.Core\Kp2aPasskey.Core.csproj", "{6B081853-9DEB-9E9F-FF45-6758B0C357CC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -395,6 +397,30 @@ 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
+ {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/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/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..56ef0680b
--- /dev/null
+++ b/src/Kp2aPasskey.Core/AuthenticatorResponses.cs
@@ -0,0 +1,391 @@
+// 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 (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)
+ /// - 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 AuthenticatorAttestationGuid + 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:
+ /// - 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
+ ///
+ /// 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
+ )
+
+ {
+ // AuthenticatorAttestationGuid in RFC 4122 (big-endian) format, not Microsoft's mixed-endian format
+
+ public static byte[] AuthenticatorAttestationGuid { 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
+ );
+
+ // Append AuthenticatorAttestationGuid + credIdLen + credentialId + credentialPublicKey
+ var credIdLen = new[]
+ {
+ (byte)(credentialId.Length >> 8),
+ (byte)credentialId.Length
+ };
+
+ var result = authData
+ .Concat(AuthenticatorAttestationGuid)
+ .Concat(credIdLen)
+ .Concat(credentialId)
+ .Concat(credentialPublicKey)
+ .ToArray();
+
+ return result;
+ }
+
+ 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", authData);
+
+ return cbor.EncodeToBytes();
+ }
+
+ public JSONObject ToJson()
+ {
+ var authData = BuildAuthData();
+ var attestationObject = BuildAttestationObject(authData);
+
+ 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;
+
+ _authenticatorData = AuthenticatorDataBuilder.BuildAuthenticatorData(
+ relyingPartyId: Encoding.UTF8.GetBytes(requestOptions.RpId),
+ userPresent: userPresent,
+ userVerified: userVerified,
+ backupEligibility: backupEligibility,
+ backupState: backupState
+ );
+
+ var clientDataHash = clientDataResponse.HashData();
+
+ // Sign: authenticatorData || clientDataHash
+ var dataToSign = _authenticatorData.Concat(clientDataHash).ToArray();
+ _signature = PasskeyCryptoHelper.Sign(privateKeyPem, dataToSign);
+ }
+
+ 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);
+
+ 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);
+
+ return json.ToString();
+ }
+ }
+}
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..66d95771d
--- /dev/null
+++ b/src/Kp2aPasskey.Core/PasskeyCryptoHelper.cs
@@ -0,0 +1,494 @@
+// 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.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;
+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)
+ return (keyPair, CoseAlgEd25519);
+ break;
+ }
+ case CoseAlgEs256:
+ {
+ var keyPair = GenerateEs256KeyPair();
+ if (keyPair != null)
+ return (keyPair, CoseAlgEs256);
+ break;
+ }
+ case CoseAlgRs256:
+ {
+ var keyPair = GenerateRs256KeyPair();
+ if (keyPair != null)
+ return (keyPair, CoseAlgRs256);
+ break;
+ }
+ default:
+ break; // Unsupported algorithm, try next one
+ }
+ }
+
+ 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
+ {
+ const string es256CurveName = "secp256r1";
+ var spec = new ECGenParameterSpec(es256CurveName);
+ 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
+ {
+ // Android's Ed25519 KeyPairGenerator requires Keystore, so we use BouncyCastle C# API
+ 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;
+
+ // 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
+ var publicKeyInfo = Org.BouncyCastle.X509.SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(publicKeyParams);
+ var publicKeyBytes = publicKeyInfo.GetEncoded();
+
+ // Create wrapper keys that implement Java interfaces
+ var privateKey = new Ed25519PrivateKeyWrapper(privateKeyBytes);
+ var publicKey = new Ed25519PublicKeyWrapper(publicKeyBytes);
+
+ var keyPair = new AndroidKeyPair(publicKey, privateKey);
+ 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).
+ /// 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");
+
+ // 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();
+ }
+
+ ///
+ /// 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 — 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
+ {
+ ECPrivateKeyParameters => "SHA256withECDSA",
+ RsaPrivateCrtKeyParameters => "SHA256withRSA",
+ Ed25519PrivateKeyParameters => "Ed25519",
+ _ => throw new SecurityException($"Unknown 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();
+ return signatureBytes;
+ }
+ catch (Exception ex)
+ {
+ Log.Error("PasskeyCryptoHelper", $"Failed to sign data: {ex.Message}");
+ throw new Exception("Failed to sign data", ex);
+ }
+ }
+
+
+ ///
+ /// 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
+ ///
+ 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
+ .Add(CoseKeyX, e!); // e: exponent
+ }
+
+ throw new ArgumentException("Failed to extract RSA key specification");
+ }
+ catch (Exception ex)
+ {
+ throw new ArgumentException("Failed to convert RSA public key to map", ex);
+ }
+ }
+
+
+
+ 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..4359522b9
--- /dev/null
+++ b/src/Kp2aPasskey.Core/PasskeyStorage.cs
@@ -0,0 +1,168 @@
+// 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;
+using Org.Json;
+
+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";
+
+ // 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)
+ {
+ // 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, 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 ? "1" : "0");
+ if (passkey.BackupState.HasValue)
+ passkeyFieldsJson.Put(PasskeyStorage.FIELD_FLAG_BS, passkey.BackupState.Value ? "1" : "0");
+ return passkeyFieldsJson;
+ }
+ ///
+ /// 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))
+ passkey.BackupEligibility = ParseBool(backupEligibleStr);
+
+ var backupStateStr = entry.Strings.ReadSafe(FIELD_FLAG_BS);
+ 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
+ ///
+ 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/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/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..5ff67d973 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
@@ -768,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
@@ -1352,5 +1359,34 @@
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
+ Confirm your identity to change this setting.
+
+
+ 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..e9abcbe37 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() };
}
@@ -826,13 +896,37 @@ 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));
}
}
+ 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..7f04763da
--- /dev/null
+++ b/src/keepass2android-app/services/Kp2aCredentialProvider/GetCredentialHelper.cs
@@ -0,0 +1,257 @@
+// 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,
+ // 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,
+ 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;
+
+ // 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)
+ {
+ foundEntries = FilterEntriesByAllowCredentials(foundEntries, allowCredentials);
+ }
+
+ if (foundEntries.Count > 0)
+ {
+ foreach (var entry in foundEntries)
+ {
+ // 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,
+ passkeyUsername,
+ PendingIntentCompat.GetActivity(
+ context,
+ entryRequestCode,
+ 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)
+ {
+ continue;
+ }
+ bool match = allowedCredentialIds.Contains(passkey.CredentialId);
+ if (match)
+ 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..42dd7fbb8
--- /dev/null
+++ b/src/keepass2android-app/services/Kp2aCredentialProvider/Kp2aCredentialLauncherActivity.cs
@@ -0,0 +1,1125 @@
+// 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);
+
+ var beFlag = passkey.BackupEligibility ?? PasskeyPreferences.GetBackupEligibility(this);
+ var bsFlag = passkey.BackupState ?? PasskeyPreferences.GetBackupState(this);
+
+ // Build attestation response
+ var attestationResponse = new AuthenticatorAttestationResponse(
+ requestOptions: creationOptions,
+ credentialId: credentialId,
+ credentialPublicKey: credentialPublicKeyCbor,
+ userPresent: true,
+ userVerified: _userVerifiedForCreate,
+ backupEligibility: beFlag,
+ backupState: bsFlag,
+ 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 = publicKey?.GetEncoded();
+ 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 = PasskeyStorage.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 forwardIntent = new Intent(this, typeof(SelectCurrentDbActivity));
+ forwardIntent.PutExtra(Strings.ExtraEntryOutputData, passkeyFieldsJson.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;
+ }
+
+ 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;
+ }
+
+
+ #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
+ );
+ }
+
+ 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: assertionBe,
+ backupState: assertionBs,
+ 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..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
{
@@ -682,6 +684,50 @@ public TotpPreferenceFragment() : base(Resource.Xml.pref_app_traytotp)
}
}
+ 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
{
@@ -1208,7 +1254,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;