From 885ceefb01019ffec6fb679869d0df4f0b7ca0c7 Mon Sep 17 00:00:00 2001
From: forkimenjeckayang
Date: Wed, 22 Apr 2026 08:09:07 +0000
Subject: [PATCH 1/2] Fix JWT proof validation gaps and attestation
verification
closes keycloak#47513
Signed-off-by: forkimenjeckayang
---
.../keybinding/AttestationProofValidator.java | 10 +-
.../AttestationProofValidatorFactory.java | 226 +----
.../keybinding/AttestationValidatorUtil.java | 102 ++-
.../keybinding/JwtProofValidator.java | 283 ++++--
.../keybinding/JwtProofValidatorFactory.java | 5 +-
.../TrustedAttestationKeysLoader.java | 205 +++++
.../oid4vc/OID4VCJWTIssuerEndpointTest.java | 862 ++++++++++++++++++
.../tests/oid4vc/OID4VCProofTestUtils.java | 62 +-
.../signing/OID4VCKeyAttestationTest.java | 705 +++++++++++++-
.../signing/OID4VCAttestationProofTest.java | 407 ---------
10 files changed, 2128 insertions(+), 739 deletions(-)
create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java
delete mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationProofValidator.java
index 1eae00d0aedb..9bf963a8aaa9 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationProofValidator.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationProofValidator.java
@@ -66,7 +66,12 @@ public List validateProof(VCIssuanceContext vcIssuanceContext) throws VCIss
String jwt = extractAttestationProof(vcIssuanceContext);
KeyAttestationJwtBody attestationBody = AttestationValidatorUtil.validateAttestationJwt(
- jwt, keycloakSession, vcIssuanceContext, keyResolver);
+ jwt,
+ keycloakSession,
+ vcIssuanceContext,
+ keyResolver,
+ false,
+ ProofType.ATTESTATION);
if (attestationBody.getAttestedKeys() == null || attestationBody.getAttestedKeys().isEmpty()) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "No valid attested keys found in attestation proof");
@@ -105,7 +110,8 @@ private String extractAttestationProof(VCIssuanceContext vcIssuanceContext)
Proofs proofs = vcIssuanceContext.getCredentialRequest().getProofs();
if (proofs == null || proofs.getAttestation() == null || proofs.getAttestation().isEmpty()) {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "Expected a proof of type attestation: " + ProofType.JWT);
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "Expected a proof of type attestation: " + ProofType.ATTESTATION);
}
if (proofs.getAttestation().size() > 1) {
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationProofValidatorFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationProofValidatorFactory.java
index c04826b875cb..ca7f39d4f712 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationProofValidatorFactory.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationProofValidatorFactory.java
@@ -17,52 +17,19 @@
package org.keycloak.protocol.oid4vc.issuance.keybinding;
-import java.io.IOException;
-import java.security.cert.X509Certificate;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-import org.keycloak.constants.OID4VCIConstants;
-import org.keycloak.crypto.KeyType;
-import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
-import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.models.KeycloakSession;
-import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oid4vc.model.ProofType;
-import org.keycloak.protocol.oidc.utils.JWKSServerUtils;
-import org.keycloak.util.JsonSerialization;
-
-import com.fasterxml.jackson.core.type.TypeReference;
-import org.jboss.logging.Logger;
/**
* Factory for creating AttestationProofValidator instances with configurable trusted keys.
- * Trusted keys are loaded from multiple sources with the following priority (highest to lowest):
- *
- * - Keys by ID from realm attribute 'oid4vc.attestation.trusted_key_ids': Keys referenced by their keyId
- * from the realm's key providers (can include disabled keys, not exposed in well-known endpoints)
- * - Keys from realm attribute 'oid4vc.attestation.trusted_keys': Explicit JWK JSON array
- * - Realm session keys (default): All enabled keys from the realm's key providers (exposed in well-known endpoints)
- *
- * Keys from higher priority sources take precedence when there are conflicts (same kid).
- * This approach allows using realm keys as a default while supporting additional keys via realm attributes,
- * including disabled keys that are not exposed in well-known endpoints.
*
* @author Rodrick Awambeng
*/
public class AttestationProofValidatorFactory implements ProofValidatorFactory {
- private static final Logger logger = Logger.getLogger(AttestationProofValidatorFactory.class);
-
@Override
public String getId() {
return ProofType.ATTESTATION;
@@ -70,199 +37,8 @@ public String getId() {
@Override
public ProofValidator create(KeycloakSession session) {
- Map trustedKeys = loadTrustedKeysFromRealm(session);
+ Map trustedKeys = TrustedAttestationKeysLoader.loadTrustedKeysFromRealm(session);
AttestationKeyResolver resolver = new StaticAttestationKeyResolver(trustedKeys);
return new AttestationProofValidator(session, resolver);
}
-
- /**
- * Loads trusted keys by merging keys from multiple sources with priority:
- * 1. Keys by ID from realm attribute (highest priority, can include disabled keys)
- * 2. Keys from realm attribute JSON (explicit JWK)
- * 3. Enabled keys from session (lowest priority, exposed in well-known endpoints)
- *
- * @param session The Keycloak session
- * @return Map of trusted keys keyed by kid, or empty map if realm is null
- */
- private Map loadTrustedKeysFromRealm(KeycloakSession session) {
- RealmModel realm = session.getContext().getRealm();
- if (realm == null) {
- logger.debugf("No realm available, returning empty trusted keys map");
- return Map.of();
- }
-
- // Load keys from session as default/fallback (lowest priority)
- Map sessionKeys = loadKeysFromSession(session, realm);
-
- // Load keys from realm attribute JSON (medium priority)
- Map attributeKeys = loadKeysFromRealmAttribute(realm);
-
- // Load keys by ID from realm attribute (highest priority, can include disabled keys)
- Map keyIdsKeys = loadKeysByKeyIds(session, realm);
-
- // Merge with priority: keyIdsKeys > attributeKeys > sessionKeys
- Map mergedKeys = new HashMap<>(sessionKeys);
- mergedKeys.putAll(attributeKeys);
- mergedKeys.putAll(keyIdsKeys);
-
- if (mergedKeys.isEmpty()) {
- logger.debugf("No trusted keys found for attestation proof validation");
- } else {
- logger.debugf("Loaded %d trusted keys for attestation proof validation (%d from session, %d from realm attribute JSON, %d from realm attribute key IDs)",
- mergedKeys.size(), sessionKeys.size(), attributeKeys.size(), keyIdsKeys.size());
- }
-
- return Collections.unmodifiableMap(mergedKeys);
- }
-
- /**
- * Loads keys from Keycloak session by reusing JWKSServerUtils.getRealmJwks().
- * This provides a default set of trusted keys from the realm's key providers.
- * Converts the result to a Map keyed by kid for easier lookup and merging.
- */
- private Map loadKeysFromSession(KeycloakSession session, RealmModel realm) {
- try {
- JSONWebKeySet keySet = JWKSServerUtils.getRealmJwks(session, realm);
- if (keySet == null || keySet.getKeys() == null) {
- return Map.of();
- }
-
- return Stream.of(keySet.getKeys())
- .filter(jwk -> jwk != null && jwk.getKeyId() != null)
- .collect(Collectors.toMap(
- JWK::getKeyId,
- jwk -> jwk,
- (existing, replacement) -> existing // Keep first occurrence if duplicate kids
- ));
- } catch (Exception e) {
- logger.warnf(e, "Failed to load keys from session for realm '%s'", realm.getName());
- return Map.of();
- }
- }
-
- /**
- * Loads trusted keys from realm attribute.
- * These keys take precedence over session keys when merged.
- */
- private Map loadKeysFromRealmAttribute(RealmModel realm) {
- String trustedKeysJson = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
- if (trustedKeysJson == null || trustedKeysJson.trim().isEmpty()) {
- return Map.of();
- }
-
- try {
- return parseTrustedKeys(trustedKeysJson);
- } catch (Exception e) {
- logger.warnf(e, "Failed to parse trusted keys from realm attribute '%s'", OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
- return Map.of();
- }
- }
-
- /**
- * Loads trusted keys by key IDs from realm attribute.
- * Keys are looked up from realm's key providers by their keyId, regardless of enabled status.
- * This allows using disabled keys that are not exposed in well-known endpoints.
- *
- * @param session The Keycloak session
- * @param realm The realm
- * @return Map of trusted keys keyed by kid, or empty map if no key IDs are configured
- */
- private Map loadKeysByKeyIds(KeycloakSession session, RealmModel realm) {
- String trustedKeyIds = realm.getAttribute(OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR);
- if (trustedKeyIds == null || trustedKeyIds.trim().isEmpty()) {
- return Map.of();
- }
-
- // Parse comma-separated list of key IDs
- Set keyIds = Arrays.stream(trustedKeyIds.split(","))
- .map(String::trim)
- .filter(id -> !id.isEmpty())
- .collect(Collectors.toSet());
-
- if (keyIds.isEmpty()) {
- return Map.of();
- }
-
- Map keyMap = new HashMap<>();
-
- // Get all keys from realm (including disabled ones) and convert to JWK format
- session.keys().getKeysStream(realm)
- .filter(key -> keyIds.contains(key.getKid()) && key.getPublicKey() != null)
- .forEach(key -> {
- try {
- JWKBuilder builder = JWKBuilder.create()
- .kid(key.getKid())
- .algorithm(key.getAlgorithmOrDefault());
- List certificates = Optional.ofNullable(key.getCertificateChain())
- .filter(certs -> !certs.isEmpty())
- .orElseGet(() -> Optional.ofNullable(key.getCertificate())
- .map(Collections::singletonList)
- .orElseGet(Collections::emptyList));
- JWK jwk = null;
- if (Objects.equals(key.getType(), KeyType.RSA)) {
- jwk = builder.rsa(key.getPublicKey(), certificates, key.getUse());
- } else if (Objects.equals(key.getType(), KeyType.EC)) {
- jwk = builder.ec(key.getPublicKey(), certificates, key.getUse());
- } else if (Objects.equals(key.getType(), KeyType.OKP)) {
- jwk = builder.okp(key.getPublicKey(), key.getUse());
- }
- if (jwk != null) {
- keyMap.put(key.getKid(), jwk);
- } else {
- logger.debugf("Unsupported key type '%s' for key '%s'", key.getType(), key.getKid());
- }
- } catch (Exception e) {
- logger.warnf(e, "Failed to convert key '%s' to JWK format", key.getKid());
- }
- });
-
- // Log any key IDs that were not found
- Set foundKeyIds = keyMap.keySet();
- Set missingKeyIds = keyIds.stream()
- .filter(id -> !foundKeyIds.contains(id))
- .collect(Collectors.toSet());
- if (!missingKeyIds.isEmpty()) {
- logger.warnf("The following key IDs from realm attribute '%s' were not found in realm key providers: %s",
- OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR, missingKeyIds);
- }
-
- if (!keyMap.isEmpty()) {
- logger.debugf("Loaded %d trusted keys by key ID from realm attribute (including potentially disabled keys)", keyMap.size());
- }
-
- return Collections.unmodifiableMap(keyMap);
- }
-
- /**
- * Parses trusted keys from JSON string.
- * Expected format: JSON array of JWK objects, each with a 'kid' field.
- */
- private Map parseTrustedKeys(String json) {
- if (json == null || json.trim().isEmpty()) {
- return Map.of();
- }
-
- try {
- List keys = JsonSerialization.mapper.readValue(json, new TypeReference>() {
- });
- if (keys == null || keys.isEmpty()) {
- return Map.of();
- }
-
- Map keyMap = new HashMap<>();
- for (JWK key : keys) {
- String kid = key.getKeyId();
- if (kid == null || kid.trim().isEmpty()) {
- logger.warnf("Skipping JWK without 'kid' field in trusted keys configuration");
- continue;
- }
- keyMap.put(kid, key);
- }
-
- logger.debugf("Loaded %d trusted keys from realm attribute JSON", keyMap.size());
- return Collections.unmodifiableMap(keyMap);
- } catch (IOException e) {
- throw new IllegalArgumentException("Invalid JSON format for trusted keys: " + e.getMessage(), e);
- }
- }
}
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java
index 02fdc1dcf0d4..c53fa476b364 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java
@@ -43,6 +43,7 @@
import java.util.Set;
import org.keycloak.common.VerificationException;
+import org.keycloak.common.util.Time;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
@@ -62,6 +63,8 @@
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired;
+import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
+import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.SupportedProofTypeData;
import org.keycloak.util.JsonSerialization;
@@ -89,12 +92,22 @@ public class AttestationValidatorUtil {
private static final char[] DEFAULT_TRUSTSTORE_PASSWORD = System.getProperty(
"javax.net.ssl.trustStorePassword", "changeit").toCharArray();
+ /**
+ * @param requireExpForJwtProof OID4VCI D.1: {@code exp} MUST be present when the attestation is used with the
+ * {@code jwt} proof type (embedded {@code key_attestation} header).
+ * @param proofTypeKeyForSigningAlgPolicy {@link org.keycloak.protocol.oid4vc.model.ProofType} value
+ * ({@code jwt} or {@code attestation}) to resolve
+ * {@code proof_signing_alg_values_supported}; if {@code null}, only
+ * FAPI {@code ALLOWED_ALGORITHMS} is enforced.
+ */
public static KeyAttestationJwtBody validateAttestationJwt(
String attestationJwt,
KeycloakSession keycloakSession,
VCIssuanceContext vcIssuanceContext,
- AttestationKeyResolver keyResolver)
- throws JWSInputException, VerificationException {
+ AttestationKeyResolver keyResolver,
+ boolean requireExpForJwtProof,
+ String proofTypeKeyForSigningAlgPolicy)
+ throws JWSInputException, VerificationException {
if (attestationJwt == null || attestationJwt.split("\\.").length != 3) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Invalid JWT format");
@@ -122,11 +135,12 @@ public static KeyAttestationJwtBody validateAttestationJwt(
}
JWSHeader header = jwsInput.getHeader();
- validateJwsHeader(header);
+ validateJwsHeader(header, vcIssuanceContext, proofTypeKeyForSigningAlgPolicy);
// Verify the signature
Map rawHeader = JsonSerialization.mapper.convertValue(
- jwsInput.getHeader(), new TypeReference<>() {});
+ jwsInput.getHeader(), new TypeReference<>() {
+ });
SignatureVerifierContext verifier;
if (header.getX5c() != null && !header.getX5c().isEmpty()) {
@@ -147,7 +161,7 @@ public static KeyAttestationJwtBody validateAttestationJwt(
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Could not verify signature of attestation JWT");
}
- validateAttestationPayload(keycloakSession, vcIssuanceContext, attestationBody);
+ validateAttestationPayload(keycloakSession, vcIssuanceContext, attestationBody, requireExpForJwtProof);
if (attestationBody.getAttestedKeys() == null) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Missing required attested_keys claim in attestation");
@@ -159,12 +173,22 @@ public static KeyAttestationJwtBody validateAttestationJwt(
private static void validateAttestationPayload(
KeycloakSession keycloakSession,
VCIssuanceContext vcIssuanceContext,
- KeyAttestationJwtBody attestationBody) throws VCIssuerException, VerificationException {
+ KeyAttestationJwtBody attestationBody,
+ boolean requireExpForJwtProof) throws VCIssuerException, VerificationException {
if (attestationBody.getIat() == null) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Missing 'iat' claim in attestation");
}
+ long now = Time.currentTime();
+ if (requireExpForJwtProof && attestationBody.getExp() == null) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "Missing 'exp' claim in key attestation (required when used with jwt proof type per OID4VCI D.1)");
+ }
+ if (attestationBody.getExp() != null && attestationBody.getExp() < now) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Key attestation has expired");
+ }
+
// Get resistance level requirements from configuration
KeyAttestationsRequired attestationRequirements = getAttestationRequirements(vcIssuanceContext);
validateResistanceLevel(attestationBody, attestationRequirements);
@@ -222,7 +246,7 @@ private static KeyAttestationsRequired getAttestationRequirements(VCIssuanceCont
/**
* validates the configured key_attestations_required attribute against the given attestationBody
*
- * @param attestationBody the body to be validated
+ * @param attestationBody the body to be validated
* @param attestationRequirements the configuration object that is also displayed in the metadata endpoint
*/
private static void validateResistanceLevel(KeyAttestationJwtBody attestationBody,
@@ -238,12 +262,12 @@ private static void validateResistanceLevel(KeyAttestationJwtBody attestationBod
if (attestationRequirements != null) {
// Validate key_storage if present in attestation and required by config
validateResistanceLevel(attestationBody.getKeyStorage(),
- attestationRequirements.getKeyStorage(),
- "key_storage");
+ attestationRequirements.getKeyStorage(),
+ "key_storage");
// Validate user_authentication if present in attestation and required by config
validateResistanceLevel(attestationBody.getUserAuthentication(),
- attestationRequirements.getUserAuthentication(),
- "user_authentication");
+ attestationRequirements.getUserAuthentication(),
+ "user_authentication");
}
}
@@ -251,15 +275,15 @@ private static void validateResistanceLevel(KeyAttestationJwtBody attestationBod
* Validates the given key_attestations (key_storage or user_authentication) against the current configuration as
* provided by the metadata endpoint.
*
- * @param providedLevels the attestation levels to be validated
- * @param acceptedLevels the attestation levels as exposed by the metadata endpoint
- * @param levelType either "key_storage" or "user_authentication"
+ * @param providedLevels the attestation levels to be validated
+ * @param acceptedLevels the attestation levels as exposed by the metadata endpoint
+ * @param levelType either "key_storage" or "user_authentication"
* @throws VCIssuerException if the required resistance level is not met
*/
private static void validateResistanceLevel(List providedLevels,
List acceptedLevels,
String levelType)
- throws VCIssuerException {
+ throws VCIssuerException {
if (acceptedLevels == null || acceptedLevels.isEmpty()) {
// We accept all provided levels
@@ -278,11 +302,27 @@ private static void validateResistanceLevel(List providedLevels,
if (!foundMatch) {
throw new VCIssuerException(ErrorType.INVALID_PROOF,
levelType + " none of the provided levels from '" + providedLevels + "' did match any of the " +
- "accepted levels: " + acceptedLevels);
+ "accepted levels: " + acceptedLevels);
}
}
- private static void validateJwsHeader(JWSHeader header) {
+ /**
+ * Credential Issuer metadata {@code proof_signing_alg_values_supported} for the given proof type, if configured.
+ */
+ private static Optional> resolveProofSigningAlgorithms(
+ VCIssuanceContext vcIssuanceContext, String proofTypeKey) {
+ return Optional.ofNullable(vcIssuanceContext)
+ .filter(context -> proofTypeKey != null)
+ .map(VCIssuanceContext::getCredentialConfig)
+ .map(SupportedCredentialConfiguration::getProofTypesSupported)
+ .map(ProofTypesSupported::getSupportedProofTypes)
+ .map(supportedProofTypes -> supportedProofTypes.get(proofTypeKey))
+ .map(SupportedProofTypeData::getSigningAlgorithmsSupported)
+ .filter(algs -> !algs.isEmpty());
+ }
+
+ private static void validateJwsHeader(JWSHeader header, VCIssuanceContext vcIssuanceContext,
+ String proofTypeKeyForSigningAlgPolicy) {
String alg = Optional.ofNullable(header.getAlgorithm())
.map(Algorithm::name)
.orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "Missing algorithm in JWS header"));
@@ -291,7 +331,18 @@ private static void validateJwsHeader(JWSHeader header) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "'none' algorithm is not allowed");
}
- if (!ALLOWED_ALGORITHMS.contains(alg)) {
+ if (alg.startsWith("HS")) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Symmetric algorithms are not allowed for key attestation");
+ }
+
+ Optional> metadataAlgs = resolveProofSigningAlgorithms(vcIssuanceContext, proofTypeKeyForSigningAlgPolicy);
+ if (metadataAlgs.isPresent() && !metadataAlgs.get().isEmpty()) {
+ if (!metadataAlgs.get().contains(alg)) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "Attestation alg must match proof_signing_alg_values_supported for proof type "
+ + proofTypeKeyForSigningAlgPolicy + ": " + metadataAlgs.get());
+ }
+ } else if (!ALLOWED_ALGORITHMS.contains(alg)) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Unsupported algorithm: " + alg +
". Allowed algorithms: " + ALLOWED_ALGORITHMS);
}
@@ -312,7 +363,16 @@ private static void validateJwsHeader(JWSHeader header) {
private static SignatureVerifierContext verifierFromX5CChain(
List x5cList,
String alg,
- KeycloakSession keycloakSession) throws VCIssuerException {
+ KeycloakSession keycloakSession) throws VCIssuerException, VerificationException {
+ JWK certJwk = resolveJwkFromValidatedX5c(x5cList, alg);
+ return verifierFromResolvedJWK(certJwk, alg, keycloakSession);
+ }
+
+ /**
+ * Validates x5c certificate chain and converts leaf certificate key to JWK.
+ * Can be reused by proof validators that accept x5c as proof key source.
+ */
+ static JWK resolveJwkFromValidatedX5c(List x5cList, String alg) throws VCIssuerException {
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
@@ -345,9 +405,7 @@ private static SignatureVerifierContext verifierFromX5CChain(
// Get public key from first certificate
PublicKey publicKey = certChain.get(0).getPublicKey();
- JWK certJwk = convertPublicKeyToJWK(publicKey, alg, certChain);
-
- return verifierFromResolvedJWK(certJwk, alg, keycloakSession);
+ return convertPublicKeyToJWK(publicKey, alg, certChain);
} catch (Exception e) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Failed to validate x5c certificate chain", e);
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java
index 6bf57563c198..296ee4d21860 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java
@@ -19,16 +19,20 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.security.PublicKey;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
import org.keycloak.common.VerificationException;
+import org.keycloak.common.util.Time;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JWK;
+import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
@@ -37,6 +41,7 @@
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
+import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.ProofType;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
@@ -44,6 +49,7 @@
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.SupportedProofTypeData;
import org.keycloak.representations.AccessToken;
+import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
@@ -61,6 +67,10 @@ public class JwtProofValidator extends AbstractProofValidator {
public static final String PROOF_JWT_TYP = "openid4vci-proof+jwt";
private static final String CRYPTOGRAPHIC_BINDING_METHOD_JWK = "jwk";
private static final String KEY_ATTESTATION_CLAIM = "key_attestation";
+ // JOSE private JWK parameters across RSA/EC/OKP/oct key types.
+ private static final Set JWK_PRIVATE_KEY_CLAIMS = Set.of("d", "p", "q", "dp", "dq", "qi", "oth", "k");
+ private static final int PROOF_MAX_AGE_SECONDS = 30;
+ private static final int PROOF_FUTURE_SKEW_SECONDS = 10;
private final AttestationKeyResolver keyResolver;
public JwtProofValidator(KeycloakSession keycloakSession, AttestationKeyResolver keyResolver) {
@@ -133,36 +143,48 @@ private JWK validateSingleJwtProof(VCIssuanceContext vcIssuanceContext, String j
JWSHeader jwsHeader = jwsInput.getHeader();
validateJwsHeader(vcIssuanceContext, jwsHeader);
+ // Parse raw JOSE header claims so we can resolve optional key_attestation consistently.
+ Map headerClaims = JsonSerialization.mapper.convertValue(jwsHeader,
+ new TypeReference<>() {
+ });
+ validateNoPrivateKeyInHeaderClaims(headerClaims);
+ KeyAttestationInfo attestationInfo = resolveHeaderAttestation(vcIssuanceContext, headerClaims);
+
// Handle both JWK and kid cases for the proof key
JWK jwk;
if (jwsHeader.getKey() != null) {
jwk = jwsHeader.getKey();
} else if (jwsHeader.getKeyId() != null) {
- // For kid case, we need to parse the raw header to check for key_attestation
- Map headerClaims = JsonSerialization.mapper.convertValue(jwsHeader,
- new TypeReference<>() {
- });
-
- if (!headerClaims.containsKey(KEY_ATTESTATION_CLAIM)) {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "Key ID provided but no key_attestation in header to resolve it");
+ if (attestationInfo.isPresent()) {
+ List attestedKeys = attestationInfo.attestedKeys();
+
+ // Resolve key from attestation using kid
+ jwk = attestedKeys.stream()
+ .filter(k -> jwsHeader.getKeyId().equals(k.getKeyId()))
+ .findFirst()
+ .orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF,
+ "No attested key found matching kid: " + jwsHeader.getKeyId()));
+ } else {
+ jwk = keyResolver.resolveKey(jwsHeader.getKeyId(), headerClaims, Map.of());
+ if (jwk == null) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "No trusted key found matching kid: " + jwsHeader.getKeyId());
+ }
}
+ } else if (jwsHeader.getX5c() != null && !jwsHeader.getX5c().isEmpty()) {
+ jwk = AttestationValidatorUtil.resolveJwkFromValidatedX5c(jwsHeader.getX5c(), jwsHeader.getAlgorithm().name());
+ } else {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Missing binding key. JWT must contain either jwk, kid, or x5c in header.");
+ }
- Object keyAttestation = headerClaims.get(KEY_ATTESTATION_CLAIM);
- if (keyAttestation == null) {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "The 'key_attestation' claim is present in JWT header but is null.");
+ // If a key attestation is present, proof key must be one of attested_keys.
+ if (attestationInfo.isPresent()) {
+ boolean attested = attestationInfo.attestedKeys().stream()
+ .anyMatch(attestedKey -> jwkMaterialEquals(attestedKey, jwk));
+ if (!attested) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "JWT proof key is not included in attested_keys");
}
-
- List attestedKeys = AttestationValidatorUtil.validateAttestationJwt(
- keyAttestation.toString(), keycloakSession, vcIssuanceContext, keyResolver).getAttestedKeys();
-
- // Resolve key from attestation using kid
- jwk = attestedKeys.stream()
- .filter(k -> jwsHeader.getKeyId().equals(k.getKeyId()))
- .findFirst()
- .orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF,
- "No attested key found matching kid: " + jwsHeader.getKeyId()));
- } else {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "Missing binding key. JWT must contain either jwk or kid in header.");
}
// Rest of the validation
@@ -198,37 +220,40 @@ private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext) {
}
private Optional> getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException {
- return Optional.ofNullable(vcIssuanceContext.getCredentialConfig())
- .map(SupportedCredentialConfiguration::getProofTypesSupported)
- .flatMap(proofTypesSupported -> {
- Proofs proofs = vcIssuanceContext.getCredentialRequest().getProofs();
-
- // If no proof types are configured for this credential configuration, cryptographic binding is
- // not required and we must not enforce presence of proofs. However, if a JWT proof is supplied,
- // reject it explicitly rather than silently ignoring an unconfigured proof input.
- if (proofTypesSupported == null ||
- proofTypesSupported.getSupportedProofTypes() == null ||
- proofTypesSupported.getSupportedProofTypes().isEmpty()) {
- if (proofs != null && proofs.getJwt() != null && !proofs.getJwt().isEmpty()) {
- throw new VCIssuerException(
- ErrorType.INVALID_PROOF,
- "Proof type " + ProofType.JWT + " is not supported for this credential configuration"
- );
- }
- return Optional.>empty();
- }
-
- Map supportedProofTypes = proofTypesSupported.getSupportedProofTypes();
- Optional.ofNullable(supportedProofTypes.get(ProofType.JWT))
- .orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "SD-JWT supports only jwt proof type."));
-
- // At this point, JWT is an explicitly supported proof type and must be enforced.
- if (proofs == null || proofs.getJwt() == null || proofs.getJwt().isEmpty()) {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "Credential configuration requires a proof of type: " + ProofType.JWT);
- }
-
- return Optional.of(proofs.getJwt());
- });
+ SupportedCredentialConfiguration config = vcIssuanceContext.getCredentialConfig();
+ if (config == null) {
+ return Optional.empty();
+ }
+ ProofTypesSupported proofTypesSupported = config.getProofTypesSupported();
+ CredentialRequest credentialRequest = vcIssuanceContext.getCredentialRequest();
+ Proofs proofs = credentialRequest != null ? credentialRequest.getProofs() : null;
+
+ // If no proof types are configured for this credential configuration, cryptographic binding is
+ // not required and we must not enforce presence of proofs. However, if a JWT proof is supplied,
+ // reject it explicitly rather than silently ignoring an unconfigured proof input.
+ // Note: do not use Optional.map(getProofTypesSupported): a null ProofTypesSupported must still run this logic.
+ if (proofTypesSupported == null
+ || proofTypesSupported.getSupportedProofTypes() == null
+ || proofTypesSupported.getSupportedProofTypes().isEmpty()) {
+ if (proofs != null && proofs.getJwt() != null && !proofs.getJwt().isEmpty()) {
+ throw new VCIssuerException(
+ ErrorType.INVALID_PROOF,
+ "Proof type " + ProofType.JWT + " is not supported for this credential configuration"
+ );
+ }
+ return Optional.empty();
+ }
+
+ Map supportedProofTypes = proofTypesSupported.getSupportedProofTypes();
+ Optional.ofNullable(supportedProofTypes.get(ProofType.JWT))
+ .orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "SD-JWT supports only jwt proof type."));
+
+ // At this point, JWT is an explicitly supported proof type and must be enforced.
+ if (proofs == null || proofs.getJwt() == null || proofs.getJwt().isEmpty()) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Credential configuration requires a proof of type: " + ProofType.JWT);
+ }
+
+ return Optional.of(proofs.getJwt());
}
private JWSInput getJwsInput(String jwt) throws JWSInputException {
@@ -244,8 +269,12 @@ private JWSInput getJwsInput(String jwt) throws JWSInputException {
* @throws VCIssuerException
*/
private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jwsHeader) throws VCIssuerException {
- Optional.ofNullable(jwsHeader.getAlgorithm())
+ String alg = Optional.ofNullable(jwsHeader.getAlgorithm())
+ .map(algorithm -> algorithm.name())
.orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "Missing jwsHeader claim alg"));
+ if ("none".equalsIgnoreCase(alg) || alg.startsWith("HS")) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Proof signature algorithm not supported: " + alg);
+ }
// As we limit accepted algorithm to the ones listed by the server, we can omit checking for "none"
// The Algorithm enum class does not list the none value anyway.
@@ -254,46 +283,152 @@ private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jw
.map(ProofTypesSupported::getSupportedProofTypes)
.map(proofTypeData -> proofTypeData.get("jwt"))
.map(SupportedProofTypeData::getSigningAlgorithmsSupported)
- .filter(supportedAlgs -> supportedAlgs.contains(jwsHeader.getAlgorithm().name()))
- .orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "Proof signature algorithm not supported: " + jwsHeader.getAlgorithm().name()));
+ .filter(supportedAlgs -> supportedAlgs.contains(alg))
+ .orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "Proof signature algorithm not supported: " + alg));
Optional.ofNullable(jwsHeader.getType())
.filter(type -> Objects.equals(PROOF_JWT_TYP, type))
.orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "JWT type must be: " + PROOF_JWT_TYP));
- // KeyId shall not be present alongside the jwk.
- Optional.ofNullable(jwsHeader.getKeyId())
- .ifPresent(keyId -> {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "KeyId not expected in this JWT. Use the jwk claim instead.");
- });
+ boolean hasJwk = jwsHeader.getKey() != null;
+ boolean hasKid = jwsHeader.getKeyId() != null;
+ boolean hasX5c = jwsHeader.getX5c() != null && !jwsHeader.getX5c().isEmpty();
+
+ int presentKeyHeaders = (hasJwk ? 1 : 0) + (hasKid ? 1 : 0) + (hasX5c ? 1 : 0);
+ if (presentKeyHeaders > 1) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Header claims kid, jwk, and x5c are mutually exclusive");
+ }
+
+ // OID4VCI F.1: trust_chain is not implemented (OpenID Federation verification); reject explicitly.
+ if (jwsHeader.getOtherClaims() != null && jwsHeader.getOtherClaims().get("trust_chain") != null) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "trust_chain JOSE header is not supported");
+ }
+
+ }
+
+ private KeyAttestationInfo resolveHeaderAttestation(VCIssuanceContext vcIssuanceContext, Map headerClaims)
+ throws JWSInputException, VerificationException {
+ if (!headerClaims.containsKey(KEY_ATTESTATION_CLAIM)) {
+ return KeyAttestationInfo.absent();
+ }
+
+ Object keyAttestation = headerClaims.get(KEY_ATTESTATION_CLAIM);
+ if (keyAttestation == null) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "The 'key_attestation' claim is present in JWT header but is null.");
+ }
+
+ List attestedKeys = AttestationValidatorUtil.validateAttestationJwt(
+ keyAttestation.toString(),
+ keycloakSession,
+ vcIssuanceContext,
+ keyResolver,
+ true,
+ ProofType.JWT).getAttestedKeys();
+ if (attestedKeys == null || attestedKeys.isEmpty()) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "key_attestation does not contain attested keys");
+ }
+
+ return new KeyAttestationInfo(attestedKeys);
+ }
+
+ private record KeyAttestationInfo(List attestedKeys) {
+
+ static KeyAttestationInfo absent() {
+ return new KeyAttestationInfo(List.of());
+ }
+
+ boolean isPresent() {
+ return !attestedKeys.isEmpty();
+ }
+ }
+
+ /**
+ * Compare key material instead of object identity so we can correctly match keys even when kid is absent.
+ */
+ private boolean jwkMaterialEquals(JWK left, JWK right) {
+ if (left == null || right == null) {
+ return false;
+ }
+ if (!Objects.equals(left.getKeyType(), right.getKeyType())) {
+ return false;
+ }
+
+ try {
+ PublicKey leftPublicKey = JWKParser.create(left).toPublicKey();
+ PublicKey rightPublicKey = JWKParser.create(right).toPublicKey();
+ return Objects.equals(leftPublicKey.getAlgorithm(), rightPublicKey.getAlgorithm())
+ && Arrays.equals(leftPublicKey.getEncoded(), rightPublicKey.getEncoded());
+ } catch (RuntimeException e) {
+ // If one key cannot be parsed into a public key, treat as non-match and let caller fail with INVALID_PROOF.
+ return false;
+ }
+ }
+
+ private void validateNoPrivateKeyInHeaderClaims(Map headerClaims) {
+ Object jwkClaim = headerClaims.get("jwk");
+ if (!(jwkClaim instanceof Map, ?> jwkMap)) {
+ return;
+ }
+ for (String privateClaim : JWK_PRIVATE_KEY_CLAIMS) {
+ if (jwkMap.containsKey(privateClaim)) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "JWK header must not contain private key material claim: " + privateClaim);
+ }
+ }
}
private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload)
throws VCIssuerException, VerificationException {
- // azp is the id of the client, as mentioned in the access token used to request the credential.
- // Token provided from user is obtained with a clientId that support the oidc login protocol.
- // oid4vci client doesn't. But it is the client needed at the credential endpoint.
- // String azp = vcIssuanceContext.getAuthResult().getToken().getIssuedFor();
- // Optional.ofNullable(proofPayload.getIssuer())
- // .filter(proofIssuer -> Objects.equals(azp, proofIssuer))
- // .orElseThrow(() -> new VCIssuerException("Issuer claim must be null for preauthorized code else the clientId of the client making the request: " + azp));
+ AuthenticationManager.AuthResult authResult = vcIssuanceContext.getAuthResult();
+ AccessToken requestToken = authResult != null ? authResult.getToken() : null;
+ String expectedClientId = requestToken != null ? requestToken.getIssuedFor() : null;
+ String proofIssuer = proofPayload.getIssuer();
+
+ // OID4VCI F.1: For client-bound flows, iss is optional, but if present it must match requesting client_id.
+ // For anonymous flows, iss must be omitted.
+ if (expectedClientId == null || expectedClientId.isBlank()) {
+ if (proofIssuer != null) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Issuer claim must be omitted for anonymous flow");
+ }
+ } else if (proofIssuer != null && !Objects.equals(expectedClientId, proofIssuer)) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "Issuer claim must be the client_id of the request: " + expectedClientId);
+ }
// The audience of the proof MUST be the Credential Issuer Identifier.
// https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-jwt-proof-type
String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(keycloakSession.getContext());
- Optional.ofNullable(proofPayload.getAudience()) // Ensure null-safety with Optional
- .map(Arrays::asList) // Convert to List
- .filter(audiences -> audiences.contains(credentialIssuer)) // Check if the issuer is in the audience list
+ String[] audiences = Optional.ofNullable(proofPayload.getAudience())
.orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF,
- "Proof not produced for this audience. Audience claim must be: " + credentialIssuer + " but are " + Arrays.asList(proofPayload.getAudience())));
+ "Proof not produced for this audience. Audience claim must be: " + credentialIssuer + " but is missing"));
+ if (audiences.length != 1 || !Objects.equals(credentialIssuer, audiences[0])) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF,
+ "Proof not produced for this audience. Audience claim must be single value: " + credentialIssuer + " but are " + Arrays.asList(audiences));
+ }
// Validate mandatory iat.
- // I do not understand the rationale behind requiring an issue time if we are not checking expiration.
- Optional.ofNullable(proofPayload.getIat())
+ Long iat = Optional.ofNullable(proofPayload.getIat())
.orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "Missing proof issuing time. iat claim must be provided."));
+ long now = Time.currentTime();
+ if (iat < now - PROOF_MAX_AGE_SECONDS) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Proof iat is too old");
+ }
+ if (iat > now + PROOF_FUTURE_SKEW_SECONDS) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Proof iat is in the future beyond allowed clock skew");
+ }
+ if (proofPayload.getExp() != null && proofPayload.getExp() < now) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Proof has expired");
+ }
+ if (proofPayload.getNbf() != null && proofPayload.getNbf() > now + PROOF_FUTURE_SKEW_SECONDS) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Proof is not yet valid");
+ }
KeycloakContext keycloakContext = keycloakSession.getContext();
CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class);
+ if (cNonceHandler == null) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "CNonce handler not configured");
+ }
try {
cNonceHandler.verifyCNonce(proofPayload.getNonce(),
List.of(OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(keycloakContext)),
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidatorFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidatorFactory.java
index b33df337fef8..501cf78e8422 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidatorFactory.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidatorFactory.java
@@ -19,6 +19,7 @@
import java.util.Map;
+import org.keycloak.jose.jwk.JWK;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.model.ProofType;
@@ -31,8 +32,8 @@ public String getId() {
@Override
public ProofValidator create(KeycloakSession session) {
- // TODO: Load trusted keys from config, DB, or env
- AttestationKeyResolver keyResolver = new StaticAttestationKeyResolver(Map.of());
+ Map trustedKeys = TrustedAttestationKeysLoader.loadTrustedKeysFromRealm(session);
+ AttestationKeyResolver keyResolver = new StaticAttestationKeyResolver(trustedKeys);
return new JwtProofValidator(session, keyResolver);
}
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java
new file mode 100644
index 000000000000..b71cb4b948dc
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java
@@ -0,0 +1,205 @@
+package org.keycloak.protocol.oid4vc.issuance.keybinding;
+
+import java.io.IOException;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.keycloak.constants.OID4VCIConstants;
+import org.keycloak.crypto.KeyType;
+import org.keycloak.jose.jwk.JSONWebKeySet;
+import org.keycloak.jose.jwk.JWK;
+import org.keycloak.jose.jwk.JWKBuilder;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.protocol.oidc.utils.JWKSServerUtils;
+import org.keycloak.util.JsonSerialization;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.jboss.logging.Logger;
+
+/**
+ * Shared trusted-key loader for attestation-aware proof validators.
+ */
+public final class TrustedAttestationKeysLoader {
+
+ private static final Logger logger = Logger.getLogger(TrustedAttestationKeysLoader.class);
+
+ private TrustedAttestationKeysLoader() {
+ }
+
+ /**
+ * Merges trusted keys from realm JWKS, {@code oid4vc.attestation.trusted_keys} JSON, and {@code trusted_key_ids}.
+ */
+ public static Map loadTrustedKeysFromRealm(KeycloakSession session) {
+ RealmModel contextRealm = session.getContext().getRealm();
+ if (contextRealm == null) {
+ logger.debugf("No realm available, returning empty trusted keys map");
+ return Map.of();
+ }
+ // Prefer RealmProvider resolution for JWKS; fall back to context realm so we never skip attribute-based keys
+ // when getRealm(id) is unavailable in the current session.
+ RealmModel realm = session.realms().getRealm(contextRealm.getId());
+ if (realm == null) {
+ realm = contextRealm;
+ }
+
+ // Load keys from session as default/fallback (lowest priority)
+ Map sessionKeys = loadKeysFromSession(session, realm);
+
+ // oid4vc.attestation.* strings are read from the context realm so in-memory updates on that instance
+ // (tests, same-request mutations) are visible even when getRealm(id) returns a separately cached copy.
+ Map attributeKeys = loadKeysFromRealmAttribute(contextRealm);
+
+ // Load keys by ID from realm attribute (highest priority, can include disabled keys)
+ Map keyIdsKeys = loadKeysByKeyIds(session, contextRealm);
+
+ // Merge with priority: keyIdsKeys > attributeKeys > sessionKeys
+ Map mergedKeys = new HashMap<>(sessionKeys);
+ mergedKeys.putAll(attributeKeys);
+ mergedKeys.putAll(keyIdsKeys);
+
+ if (mergedKeys.isEmpty()) {
+ logger.debugf("No trusted keys found for attestation-aware proof validation");
+ } else {
+ logger.debugf("Loaded %d trusted keys for attestation-aware proof validation (%d from session, %d from realm attribute JSON, %d from realm attribute key IDs)",
+ mergedKeys.size(), sessionKeys.size(), attributeKeys.size(), keyIdsKeys.size());
+ }
+
+ return Collections.unmodifiableMap(mergedKeys);
+ }
+
+ private static Map loadKeysFromSession(KeycloakSession session, RealmModel realm) {
+ try {
+ JSONWebKeySet keySet = JWKSServerUtils.getRealmJwks(session, realm);
+ if (keySet == null || keySet.getKeys() == null) {
+ return Map.of();
+ }
+
+ return Stream.of(keySet.getKeys())
+ .filter(jwk -> jwk != null && jwk.getKeyId() != null)
+ .collect(Collectors.toMap(
+ JWK::getKeyId,
+ jwk -> jwk,
+ (existing, replacement) -> existing
+ ));
+ } catch (Exception e) {
+ logger.warnf(e, "Failed to load keys from session for realm '%s'", realm.getName());
+ return Map.of();
+ }
+ }
+
+ private static Map loadKeysFromRealmAttribute(RealmModel realm) {
+ String trustedKeysJson = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ if (trustedKeysJson == null || trustedKeysJson.trim().isEmpty()) {
+ return Map.of();
+ }
+
+ try {
+ return parseTrustedKeys(trustedKeysJson);
+ } catch (Exception e) {
+ logger.warnf(e, "Failed to parse trusted keys from realm attribute '%s'", OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ return Map.of();
+ }
+ }
+
+ private static Map loadKeysByKeyIds(KeycloakSession session, RealmModel realm) {
+ String trustedKeyIds = realm.getAttribute(OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR);
+ if (trustedKeyIds == null || trustedKeyIds.trim().isEmpty()) {
+ return Map.of();
+ }
+
+ Set keyIds = Arrays.stream(trustedKeyIds.split(","))
+ .map(String::trim)
+ .filter(id -> !id.isEmpty())
+ .collect(Collectors.toSet());
+
+ if (keyIds.isEmpty()) {
+ return Map.of();
+ }
+
+ Map keyMap = new HashMap<>();
+
+ session.keys().getKeysStream(realm)
+ .filter(key -> keyIds.contains(key.getKid()) && key.getPublicKey() != null)
+ .forEach(key -> {
+ try {
+ JWKBuilder builder = JWKBuilder.create()
+ .kid(key.getKid())
+ .algorithm(key.getAlgorithmOrDefault());
+ List certificates = Optional.ofNullable(key.getCertificateChain())
+ .filter(certs -> !certs.isEmpty())
+ .orElseGet(() -> Optional.ofNullable(key.getCertificate())
+ .map(Collections::singletonList)
+ .orElseGet(Collections::emptyList));
+ JWK jwk = null;
+ if (Objects.equals(key.getType(), KeyType.RSA)) {
+ jwk = builder.rsa(key.getPublicKey(), certificates, key.getUse());
+ } else if (Objects.equals(key.getType(), KeyType.EC)) {
+ jwk = builder.ec(key.getPublicKey(), certificates, key.getUse());
+ } else if (Objects.equals(key.getType(), KeyType.OKP)) {
+ jwk = builder.okp(key.getPublicKey(), key.getUse());
+ }
+ if (jwk != null) {
+ keyMap.put(key.getKid(), jwk);
+ } else {
+ logger.debugf("Unsupported key type '%s' for key '%s'", key.getType(), key.getKid());
+ }
+ } catch (Exception e) {
+ logger.warnf(e, "Failed to convert key '%s' to JWK format", key.getKid());
+ }
+ });
+
+ Set foundKeyIds = keyMap.keySet();
+ Set missingKeyIds = keyIds.stream()
+ .filter(id -> !foundKeyIds.contains(id))
+ .collect(Collectors.toSet());
+ if (!missingKeyIds.isEmpty()) {
+ logger.warnf("The following key IDs from realm attribute '%s' were not found in realm key providers: %s",
+ OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR, missingKeyIds);
+ }
+
+ if (!keyMap.isEmpty()) {
+ logger.debugf("Loaded %d trusted keys by key ID from realm attribute (including potentially disabled keys)", keyMap.size());
+ }
+
+ return Collections.unmodifiableMap(keyMap);
+ }
+
+ private static Map parseTrustedKeys(String json) {
+ if (json == null || json.trim().isEmpty()) {
+ return Map.of();
+ }
+
+ try {
+ List keys = JsonSerialization.mapper.readValue(json, new TypeReference>() {
+ });
+ if (keys == null || keys.isEmpty()) {
+ return Map.of();
+ }
+
+ Map keyMap = new HashMap<>();
+ for (JWK key : keys) {
+ String kid = key.getKeyId();
+ if (kid == null || kid.trim().isEmpty()) {
+ logger.warnf("Skipping JWK without 'kid' field in trusted keys configuration");
+ continue;
+ }
+ keyMap.put(kid, key);
+ }
+
+ logger.debugf("Loaded %d trusted keys from realm attribute JSON", keyMap.size());
+ return Collections.unmodifiableMap(keyMap);
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Invalid JSON format for trusted keys: " + e.getMessage(), e);
+ }
+ }
+}
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
index b3a5cdfc2b46..0b22d9680497 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
@@ -18,6 +18,7 @@
import java.io.IOException;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@@ -40,8 +41,15 @@
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.common.VerificationException;
+import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.Time;
+import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.Algorithm;
+import org.keycloak.crypto.ECDSASignatureSignerContext;
+import org.keycloak.crypto.KeyWrapper;
+import org.keycloak.jose.jwk.JWK;
+import org.keycloak.jose.jwk.JWKBuilder;
+import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.models.Constants;
import org.keycloak.models.RealmModel;
@@ -51,6 +59,7 @@
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
+import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.protocol.oid4vc.model.ClaimDisplay;
import org.keycloak.protocol.oid4vc.model.Claims;
@@ -62,6 +71,7 @@
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.JwtProof;
+import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.NonceResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant;
@@ -69,6 +79,7 @@
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
+import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
@@ -86,6 +97,7 @@
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
import org.keycloak.util.JsonSerialization;
+import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.http.HttpStatus;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -94,6 +106,8 @@
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER;
import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.generateJwtProof;
+import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.generateJwtProofWithClaims;
+import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.generateJwtProofWithKidNoAttestation;
import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.jwtProofs;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -765,6 +779,740 @@ public void testRequestMultipleCredentialsWithProofs() {
});
}
+ @Test
+ public void testRequestCredentialWithKidProofWithoutKeyAttestation() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String kidOnlyJwtProof = generateJwtProofWithKidNoAttestation(issuer, cNonce);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(kidOnlyJwtProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithJwkKeyAttestationAccepted() {
+ Map requestContext = prepareJwtCredentialRequestContext();
+ String token = requestContext.get("token");
+ String credentialIdentifier = requestContext.get("credentialIdentifier");
+ String cNonce = requestContext.get("cNonce");
+
+ runOnServer.run(session -> {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationSigner = OID4VCProofTestUtils.newEcSigningKey("endpoint-attestation-jwk");
+ JWK trustedAttestationJwk = JWKBuilder.create().ec(attestationSigner.getPublicKey());
+ trustedAttestationJwk.setKeyId(attestationSigner.getKid());
+ trustedAttestationJwk.setAlgorithm(attestationSigner.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(trustedAttestationJwk)));
+
+ KeyWrapper proofKey = OID4VCProofTestUtils.newEcSigningKey("endpoint-proof-jwk");
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+ String attestationJwt = OID4VCProofTestUtils.generateAttestationProof(
+ attestationSigner, cNonce, List.of(proofJwk), List.of("iso_18045_high"),
+ List.of("iso_18045_high"), null);
+
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String jwtProof = generateJwtProofWithEmbeddedAttestation(
+ proofKey, attestationJwt, cNonce, issuer, false);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(jwtProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ Response response = endpoint.requestCredential(requestPayload);
+ assertSingleCredentialResponse(response);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithKidKeyAttestationAccepted() {
+ Map requestContext = prepareJwtCredentialRequestContext();
+ String token = requestContext.get("token");
+ String credentialIdentifier = requestContext.get("credentialIdentifier");
+ String cNonce = requestContext.get("cNonce");
+
+ runOnServer.run(session -> {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationSigner = OID4VCProofTestUtils.newEcSigningKey("endpoint-attestation-kid");
+ JWK trustedAttestationJwk = JWKBuilder.create().ec(attestationSigner.getPublicKey());
+ trustedAttestationJwk.setKeyId(attestationSigner.getKid());
+ trustedAttestationJwk.setAlgorithm(attestationSigner.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(trustedAttestationJwk)));
+
+ KeyWrapper proofKey = OID4VCProofTestUtils.newEcSigningKey("endpoint-proof-kid");
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+ String attestationJwt = OID4VCProofTestUtils.generateAttestationProof(
+ attestationSigner, cNonce, List.of(proofJwk), List.of("iso_18045_high"),
+ List.of("iso_18045_high"), null);
+
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String jwtProof = generateJwtProofWithEmbeddedAttestation(
+ proofKey, attestationJwt, cNonce, issuer, true);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(jwtProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ Response response = endpoint.requestCredential(requestPayload);
+ assertSingleCredentialResponse(response);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithAttestationProofAccepted() {
+ Map requestContext = prepareJwtCredentialRequestContext();
+ String token = requestContext.get("token");
+ String credentialIdentifier = requestContext.get("credentialIdentifier");
+ String cNonce = requestContext.get("cNonce");
+
+ runOnServer.run(session -> {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationSigner = OID4VCProofTestUtils.newEcSigningKey("endpoint-attestation-proof-type");
+ JWK trustedAttestationJwk = JWKBuilder.create().ec(attestationSigner.getPublicKey());
+ trustedAttestationJwk.setKeyId(attestationSigner.getKid());
+ trustedAttestationJwk.setAlgorithm(attestationSigner.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(trustedAttestationJwk)));
+
+ KeyWrapper proofKey = OID4VCProofTestUtils.newEcSigningKey("endpoint-proof-attestation-proof-type");
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+ String attestationJwt = OID4VCProofTestUtils.generateAttestationProof(
+ attestationSigner, cNonce, List.of(proofJwk), List.of("iso_18045_high"),
+ List.of("iso_18045_high"), null);
+
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setAttestation(List.of(attestationJwt)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ Response response = endpoint.requestCredential(requestPayload);
+ assertSingleCredentialResponse(response);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithKeyAttestationMismatchedProofKeyRejected() {
+ Map requestContext = prepareJwtCredentialRequestContext();
+ String token = requestContext.get("token");
+ String credentialIdentifier = requestContext.get("credentialIdentifier");
+ String cNonce = requestContext.get("cNonce");
+
+ runOnServer.run(session -> {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationSigner = OID4VCProofTestUtils.newEcSigningKey("endpoint-attestation-mismatch");
+ JWK trustedAttestationJwk = JWKBuilder.create().ec(attestationSigner.getPublicKey());
+ trustedAttestationJwk.setKeyId(attestationSigner.getKid());
+ trustedAttestationJwk.setAlgorithm(attestationSigner.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(trustedAttestationJwk)));
+
+ KeyWrapper proofKey = OID4VCProofTestUtils.newEcSigningKey("endpoint-proof-used");
+ KeyWrapper otherKey = OID4VCProofTestUtils.newEcSigningKey("endpoint-proof-attested");
+ JWK otherJwk = JWKBuilder.create().ec(otherKey.getPublicKey());
+ otherJwk.setKeyId(otherKey.getKid());
+ otherJwk.setAlgorithm(otherKey.getAlgorithm());
+ String attestationJwt = OID4VCProofTestUtils.generateAttestationProof(
+ attestationSigner, cNonce, List.of(otherJwk), List.of("iso_18045_high"),
+ List.of("iso_18045_high"), null);
+
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String jwtProof = generateJwtProofWithEmbeddedAttestation(
+ proofKey, attestationJwt, cNonce, issuer, false);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(jwtProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("attested_keys"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithKeyAttestationMissingExpRejected() {
+ Map requestContext = prepareJwtCredentialRequestContext();
+ String token = requestContext.get("token");
+ String credentialIdentifier = requestContext.get("credentialIdentifier");
+ String cNonce = requestContext.get("cNonce");
+
+ runOnServer.run(session -> {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationSigner = OID4VCProofTestUtils.newEcSigningKey("endpoint-attestation-no-exp");
+ JWK trustedAttestationJwk = JWKBuilder.create().ec(attestationSigner.getPublicKey());
+ trustedAttestationJwk.setKeyId(attestationSigner.getKid());
+ trustedAttestationJwk.setAlgorithm(attestationSigner.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(trustedAttestationJwk)));
+
+ KeyWrapper proofKey = OID4VCProofTestUtils.newEcSigningKey("endpoint-proof-no-exp");
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+ String attestationJwtWithoutExp = generateAttestationProofWithoutExp(
+ attestationSigner, cNonce, List.of(proofJwk));
+
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String jwtProof = generateJwtProofWithEmbeddedAttestation(
+ proofKey, attestationJwtWithoutExp, cNonce, issuer, false);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(jwtProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("Missing 'exp' claim"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithHs256JwtProofRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String validJwtProof = generateJwtProof(issuer, cNonce);
+ String hs256JwtProof = withModifiedHeaderClaim(validJwtProof, "alg", "HS256");
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(hs256JwtProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("Proof signature algorithm not supported"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithPrivateJwkMaterialInHeaderRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String validJwtProof = generateJwtProof(issuer, cNonce);
+ String proofWithPrivateJwkMaterial = withPrivateJwkMaterialInHeader(validJwtProof);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(proofWithPrivateJwkMaterial)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithMissingIssuerInClientBoundFlowAllowed() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String missingIssuerProof = generateJwtProofWithClaims(List.of(issuer), cNonce, null, null, null, null);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(missingIssuerProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ Response response = endpoint.requestCredential(requestPayload);
+ assertEquals(Response.Status.OK.getStatusCode(), response.getStatus(), "Response status should be OK");
+ CredentialResponse credentialResponse = JsonSerialization.mapper
+ .convertValue(response.getEntity(), CredentialResponse.class);
+ assertNotNull(credentialResponse, "Credential response should not be null");
+ assertNotNull(credentialResponse.getCredentials(), "Credentials should not be null");
+ assertEquals(1, credentialResponse.getCredentials().size(), "Expected exactly one credential");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithWrongIssuerInClientBoundFlowRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String wrongIssuerProof = generateJwtProofWithClaims(
+ List.of(issuer), cNonce, "wrong-client-id", null, null, null);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(wrongIssuerProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("Issuer claim must be the client_id"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithMultipleAudiencesRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String multiAudProof = generateJwtProofWithClaims(
+ List.of(issuer, "https://unrelated.example"),
+ cNonce,
+ OID4VCI_CLIENT_ID,
+ null,
+ null,
+ null);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(multiAudProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("Audience claim must be single value"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithFutureIatRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ long now = System.currentTimeMillis() / 1000L;
+ String futureIatProof = generateJwtProofWithClaims(
+ List.of(issuer),
+ cNonce,
+ OID4VCI_CLIENT_ID,
+ now + 120,
+ null,
+ null);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(futureIatProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("Proof iat is in the future"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithExpiredExpRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ long now = System.currentTimeMillis() / 1000L;
+ String expiredExpProof = generateJwtProofWithClaims(
+ List.of(issuer),
+ cNonce,
+ OID4VCI_CLIENT_ID,
+ now,
+ now - 1,
+ null);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(expiredExpProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("Proof has expired"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithFutureNbfRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ long now = System.currentTimeMillis() / 1000L;
+ String futureNbfProof = generateJwtProofWithClaims(
+ List.of(issuer),
+ cNonce,
+ OID4VCI_CLIENT_ID,
+ now,
+ null,
+ now + 120);
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(futureNbfProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("Proof is not yet valid"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithTrustChainHeaderRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String validJwtProof = generateJwtProof(issuer, cNonce);
+ String trustChainProof = withModifiedHeaderClaim(validJwtProof, "trust_chain", List.of("dummy-trust-chain-entry"));
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(trustChainProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("trust_chain"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ public void testRequestCredentialWithKidAndJwkHeadersRejected() {
+ final String scopeName = jwtTypeCredentialScope.getName();
+ String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credConfigId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String token = tokenResponse.getAccessToken();
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ String cNonce = getCNonce();
+
+ runOnServer.run(session -> {
+ try {
+ BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
+ authenticator.setTokenString(token);
+ String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String validJwtProof = generateJwtProof(issuer, cNonce);
+ String kidAndJwkProof = withModifiedHeaderClaim(validJwtProof, "kid", "some-kid");
+
+ CredentialRequest request = new CredentialRequest()
+ .setCredentialIdentifier(credentialIdentifier)
+ .setProofs(new Proofs().setJwt(List.of(kidAndJwkProof)));
+ String requestPayload = JsonSerialization.writeValueAsString(request);
+
+ OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
+ ErrorResponseException ex = assertThrows(ErrorResponseException.class,
+ () -> endpoint.requestCredential(requestPayload));
+ assertEquals(ErrorType.INVALID_PROOF.getValue(), ex.getError());
+ assertTrue(ex.getErrorDescription().contains("mutually exclusive"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
@Test
public void testGetJwtVcConfigFromMetadata() {
final String scopeName = jwtTypeCredentialScope.getName();
@@ -1496,4 +2244,118 @@ private static String parseResponse(Response response) {
throw new RuntimeException(e);
}
}
+
+ private static String withModifiedHeaderClaim(String jwt, String claim, Object value) {
+ try {
+ String[] parts = jwt.split("\\.");
+ Map header = JsonSerialization.readValue(Base64Url.decode(parts[0]), new TypeReference<>() {
+ });
+ header.put(claim, value);
+ parts[0] = Base64Url.encode(JsonSerialization.writeValueAsString(header).getBytes(StandardCharsets.UTF_8));
+ return String.join(".", parts);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static String withPrivateJwkMaterialInHeader(String jwt) {
+ try {
+ String[] parts = jwt.split("\\.");
+ Map header = JsonSerialization.readValue(Base64Url.decode(parts[0]), new TypeReference<>() {
+ });
+ Map jwk = JsonSerialization.mapper.convertValue(header.get("jwk"), new TypeReference<>() {
+ });
+ jwk.put("d", "fake-private-material");
+ header.put("jwk", jwk);
+ parts[0] = Base64Url.encode(JsonSerialization.writeValueAsString(header).getBytes(StandardCharsets.UTF_8));
+ return String.join(".", parts);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static String generateJwtProofWithEmbeddedAttestation(
+ KeyWrapper proofKey,
+ String attestationJwt,
+ String cNonce,
+ String audience,
+ boolean useKidHeader
+ ) {
+ try {
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+
+ AccessToken token = new AccessToken();
+ token.addAudience(audience);
+ token.setNonce(cNonce);
+ token.issuedNow();
+
+ Map header = Map.of(
+ "alg", proofKey.getAlgorithm(),
+ "typ", "openid4vci-proof+jwt",
+ "key_attestation", attestationJwt,
+ useKidHeader ? "kid" : "jwk",
+ useKidHeader ? proofKey.getKid() : proofJwk
+ );
+
+ return new JWSBuilder() {
+ @Override
+ protected String encodeHeader(String sigAlgName) {
+ try {
+ return Base64Url.encode(JsonSerialization.writeValueAsBytes(header));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to encode JWT proof header", e);
+ }
+ }
+ }.jsonContent(token).sign(new ECDSASignatureSignerContext(proofKey));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to generate JWT proof with key_attestation", e);
+ }
+ }
+
+ private static String generateAttestationProofWithoutExp(KeyWrapper attestationKey, String nonce, List attestedKeys) {
+ KeyAttestationJwtBody body = new KeyAttestationJwtBody();
+ body.setIat(System.currentTimeMillis() / 1000L);
+ body.setNonce(nonce);
+ body.setAttestedKeys(attestedKeys);
+ body.setKeyStorage(List.of("iso_18045_high"));
+ body.setUserAuthentication(List.of("iso_18045_high"));
+
+ return new JWSBuilder()
+ .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)
+ .kid(attestationKey.getKid())
+ .jsonContent(body)
+ .sign(new ECDSASignatureSignerContext(attestationKey));
+ }
+
+ private Map prepareJwtCredentialRequestContext() {
+ String scopeName = jwtTypeCredentialScope.getName();
+ String credentialConfigurationId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
+ CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
+
+ OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
+ authDetail.setType(OPENID_CREDENTIAL);
+ authDetail.setCredentialConfigurationId(credentialConfigurationId);
+ authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
+
+ String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
+ AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
+ String credentialIdentifier = tokenResponse.getOID4VCAuthorizationDetails().get(0).getCredentialIdentifiers().get(0);
+ return Map.of(
+ "token", tokenResponse.getAccessToken(),
+ "credentialIdentifier", credentialIdentifier,
+ "cNonce", getCNonce()
+ );
+ }
+
+ private static void assertSingleCredentialResponse(Response response) {
+ assertEquals(Response.Status.OK.getStatusCode(), response.getStatus(), "Response status should be OK");
+ CredentialResponse credentialResponse = JsonSerialization.mapper
+ .convertValue(response.getEntity(), CredentialResponse.class);
+ assertNotNull(credentialResponse);
+ assertNotNull(credentialResponse.getCredentials());
+ assertEquals(1, credentialResponse.getCredentials().size());
+ }
+
}
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java
index 043c2b11ea5a..9800b331f043 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java
@@ -11,6 +11,7 @@
import java.util.Date;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import org.keycloak.common.util.BouncyIntegration;
import org.keycloak.crypto.ECDSASignatureSignerContext;
@@ -43,13 +44,58 @@ public static Proofs jwtProofs(String audience, String nonce) {
}
public static String generateJwtProof(String audience, String nonce) {
- return generateJwtProof(audience, createEcKeyPair("proof-key"), nonce);
+ return generateJwtProofWithClaims(List.of(audience), nonce, null, null, null, null, createEcKeyPair("proof-key"));
}
public static String generateJwtProof(String audience, KeyWrapper keyWrapper, String nonce) {
+ return generateJwtProofWithClaims(List.of(audience), nonce, null, null, null, null, keyWrapper);
+ }
+
+ public static String generateJwtProofWithClaims(
+ List audiences,
+ String nonce,
+ String issuer,
+ Long iat,
+ Long exp,
+ Long nbf
+ ) {
+ KeyWrapper keyWrapper = createEcKeyPair();
+ return generateJwtProofWithClaims(audiences, nonce, issuer, iat, exp, nbf, keyWrapper);
+ }
+
+ private static String generateJwtProofWithClaims(
+ List audiences,
+ String nonce,
+ String issuer,
+ Long iat,
+ Long exp,
+ Long nbf,
+ KeyWrapper keyWrapper
+ ) {
keyWrapper.setKid(null);
JWK jwk = JWKBuilder.create().ec(keyWrapper.getPublicKey());
+ AccessToken token = new AccessToken();
+ List resolvedAudiences = audiences != null ? audiences : List.of();
+ for (String audience : resolvedAudiences) {
+ token.addAudience(audience);
+ }
+ token.setNonce(nonce);
+ Optional.ofNullable(issuer).ifPresent(token::issuer);
+ Optional.ofNullable(iat).ifPresentOrElse(token::iat, token::issuedNow);
+ Optional.ofNullable(exp).ifPresent(token::exp);
+ Optional.ofNullable(nbf).ifPresent(token::nbf);
+
+ return new JWSBuilder()
+ .type(JwtProofValidator.PROOF_JWT_TYP)
+ .jwk(jwk)
+ .jsonContent(token)
+ .sign(new ECDSASignatureSignerContext(keyWrapper));
+ }
+
+ public static String generateJwtProofWithKidNoAttestation(String audience, String nonce) {
+ KeyWrapper keyWrapper = createEcKeyPair();
+
AccessToken token = new AccessToken();
token.addAudience(audience);
token.setNonce(nonce);
@@ -57,7 +103,7 @@ public static String generateJwtProof(String audience, KeyWrapper keyWrapper, St
return new JWSBuilder()
.type(JwtProofValidator.PROOF_JWT_TYP)
- .jwk(jwk)
+ .kid(keyWrapper.getKid())
.jsonContent(token)
.sign(new ECDSASignatureSignerContext(keyWrapper));
}
@@ -85,7 +131,9 @@ public static String generateAttestationProof(
String certification
) {
KeyAttestationJwtBody body = new KeyAttestationJwtBody();
- body.setIat(System.currentTimeMillis() / 1000);
+ long iatSeconds = System.currentTimeMillis() / 1000;
+ body.setIat(iatSeconds);
+ body.setExp(iatSeconds + 3600);
body.setNonce(nonce);
body.setAttestedKeys(attestedKeys);
body.setKeyStorage(keyStorage);
@@ -100,6 +148,14 @@ public static String generateAttestationProof(
.sign(new ECDSASignatureSignerContext(attestationKey));
}
+ public static KeyWrapper createEcKeyPair() {
+ return createEcKeyPair("proof-key");
+ }
+
+ public static KeyWrapper newEcSigningKey(String keyId) {
+ return createEcKeyPair(keyId);
+ }
+
public static KeyWrapper createEcKeyPair(String keyId) {
try {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", BouncyIntegration.PROVIDER);
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java
index 8109cd4680df..17219d7a263a 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java
@@ -14,6 +14,7 @@
import org.keycloak.VCFormat;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.CertificateUtils;
+import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.ECDSASignatureSignerContext;
import org.keycloak.crypto.KeyType;
import org.keycloak.crypto.KeyWrapper;
@@ -31,6 +32,7 @@
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidatorFactory;
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator;
+import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidatorFactory;
import org.keycloak.protocol.oid4vc.issuance.keybinding.StaticAttestationKeyResolver;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
@@ -38,7 +40,9 @@
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
+import org.keycloak.protocol.oid4vc.model.SupportedProofTypeData;
import org.keycloak.representations.AccessToken;
+import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
@@ -49,6 +53,7 @@
import org.jboss.logging.Logger;
import org.junit.jupiter.api.Test;
+import static org.keycloak.protocol.oid4vc.model.ProofType.ATTESTATION;
import static org.keycloak.protocol.oid4vc.model.ProofType.JWT;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -110,6 +115,42 @@ public void testInvalidJwtProofWithKeyAttestation() {
});
}
+ @Test
+ public void testValidKidJwtProofWithKeyAttestation() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runValidKidJwtProofWithKeyAttestationTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofWithKeyAttestationMustContainProofKey() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofWithKeyAttestationMustContainProofKeyTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofWithJwkAndKidHeadersIsRejected() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofWithJwkAndKidHeadersIsRejectedTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testValidX5cJwtProofWithoutAttestation() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runValidX5cJwtProofWithoutAttestationTest(session, cNonce);
+ });
+ }
+
@Test
public void testAttestationProofType() {
runOnServer.run(session -> {
@@ -122,6 +163,28 @@ public void testAttestationProofType() {
});
}
+ @Test
+ public void testJwtProofValidatorFactoryProofType() {
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ JwtProofValidatorFactory factory = new JwtProofValidatorFactory();
+ var validator = factory.create(session);
+ assertEquals(JWT, validator.getProofType(), "The proof type should be 'jwt'.");
+ });
+ }
+
+ /**
+ * Kid-only JWT proof: trusted public JWK is configured on the realm and resolved via JwtProofValidatorFactory.
+ */
+ @Test
+ public void testValidJwtProofWithKidOnly() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runValidJwtProofWithKidOnlyTest(session, cNonce);
+ });
+ }
+
@Test
public void testInvalidAttestationSignature() {
String cNonce = getCNonce();
@@ -191,6 +254,118 @@ public void testAttestationWithValidResistanceLevels() {
});
}
+ @Test
+ public void testAttestationProofAcceptsLegacyTyp() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runAttestationProofAcceptsLegacyTypTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testAttestationProofWithRealmAttributeTrustedKeys() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runAttestationProofWithRealmAttributeTrustedKeysTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testAttestationProofWithInvalidTrustedKey() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ VCIssuerException e = assertThrows(VCIssuerException.class,
+ () -> runAttestationProofWithInvalidTrustedKeyTest(session, cNonce));
+ assertTrue(e.getMessage().contains("not found in trusted key registry"),
+ "Expected trusted key registry resolution error but got: " + e.getMessage());
+ });
+ }
+
+ @Test
+ public void testAttestationProofExtractsAttestedKeysFromPayload() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runAttestationProofExtractsAttestedKeysFromPayloadTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testAttestationProofWithMultipleTrustedKeys() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runAttestationProofWithMultipleTrustedKeysTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofMissingIssuerForClientBoundFlowAllowed() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofMissingIssuerForClientBoundFlowAllowedTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofWithWrongIssuerForClientBoundFlowRejected() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofWithWrongIssuerForClientBoundFlowRejectedTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofWithIssuerInAnonymousFlowRejected() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofWithIssuerInAnonymousFlowRejectedTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofWithMultipleAudiencesRejected() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofWithMultipleAudiencesRejectedTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofWithFutureIatRejected() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofWithFutureIatRejectedTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofWithExpiredExpRejected() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofWithExpiredExpRejectedTest(session, cNonce);
+ });
+ }
+
+ @Test
+ public void testJwtProofWithFutureNbfRejected() {
+ String cNonce = getCNonce();
+ runOnServer.run(session -> {
+ setupSessionContext(session);
+ runJwtProofWithFutureNbfRejectedTest(session, cNonce);
+ });
+ }
+
+
private String getCNonce() {
return oauth.oid4vc().nonceRequest().send().getNonce();
}
@@ -204,11 +379,16 @@ private static VCIssuanceContext createVCIssuanceContext(KeycloakSession session
KeyAttestationsRequired keyAttestationsRequired = new KeyAttestationsRequired();
keyAttestationsRequired.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH,
KeyAttestationResistanceLevels.MODERATE));
+ ProofTypesSupported proofTypesSupported = ProofTypesSupported.parse(session, keyAttestationsRequired, List.of("ES256"));
+ SupportedProofTypeData defaultJwtData = new SupportedProofTypeData(List.of("ES256"), keyAttestationsRequired);
+ proofTypesSupported.getSupportedProofTypes().putIfAbsent(JWT, defaultJwtData);
+ proofTypesSupported.getSupportedProofTypes().putIfAbsent(ATTESTATION, defaultJwtData);
+
SupportedCredentialConfiguration config = new SupportedCredentialConfiguration()
.setFormat(VCFormat.SD_JWT_VC)
.setVct("https://credentials.example.com/test-credential")
.setCryptographicBindingMethodsSupported(List.of("jwk"))
- .setProofTypesSupported(ProofTypesSupported.parse(session, keyAttestationsRequired, List.of("ES256")));
+ .setProofTypesSupported(proofTypesSupported);
context.setCredentialConfig(config)
.setCredentialRequest(new CredentialRequest());
@@ -241,7 +421,9 @@ private static String createValidAttestationJwt(KeycloakSession session,
// Keep support for non-default typ variants used by dedicated compatibility tests.
KeyAttestationJwtBody payload = new KeyAttestationJwtBody();
- payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
+ long iat = System.currentTimeMillis() / 1000L;
+ payload.setIat(iat);
+ payload.setExp(iat + 3600);
payload.setNonce(cNonce);
payload.setAttestedKeys(proofJwks);
payload.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH));
@@ -258,6 +440,14 @@ private static String generateJwtProofWithKeyAttestation(KeycloakSession session
KeyWrapper proofKey,
String attestationJwt,
String cNonce) {
+ return generateJwtProofWithKeyAttestation(session, proofKey, attestationJwt, cNonce, false);
+ }
+
+ private static String generateJwtProofWithKeyAttestation(KeycloakSession session,
+ KeyWrapper proofKey,
+ String attestationJwt,
+ String cNonce,
+ boolean useKidHeader) {
try {
JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
proofJwk.setKeyId(proofKey.getKid());
@@ -272,7 +462,11 @@ private static String generateJwtProofWithKeyAttestation(KeycloakSession session
Map header = new HashMap<>();
header.put("alg", proofKey.getAlgorithm());
header.put("typ", JwtProofValidator.PROOF_JWT_TYP);
- header.put("jwk", proofJwk);
+ if (useKidHeader) {
+ header.put("kid", proofKey.getKid());
+ } else {
+ header.put("jwk", proofJwk);
+ }
header.put("key_attestation", attestationJwt);
return new JWSBuilder() {
@@ -290,9 +484,70 @@ protected String encodeHeader(String sigAlgName) {
}
}
+ private static String generateJwtProofWithJwkAndKid(KeycloakSession session, KeyWrapper proofKey, String cNonce) {
+ try {
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+
+ AccessToken token = new AccessToken();
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ token.addAudience(credentialIssuer);
+ token.setNonce(cNonce);
+ token.issuedNow();
+
+ return new JWSBuilder()
+ .type(JwtProofValidator.PROOF_JWT_TYP)
+ .kid(proofKey.getKid())
+ .jwk(proofJwk)
+ .jsonContent(token)
+ .sign(new ECDSASignatureSignerContext(proofKey));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to generate JWT proof with both jwk and kid", e);
+ }
+ }
+
+ private static String generateJwtProofWithKidNoAttestation(KeycloakSession session, KeyWrapper proofKey, String cNonce) {
+ try {
+ AccessToken token = new AccessToken();
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ token.addAudience(credentialIssuer);
+ token.setNonce(cNonce);
+ token.issuedNow();
+
+ return new JWSBuilder()
+ .type(JwtProofValidator.PROOF_JWT_TYP)
+ .kid(proofKey.getKid())
+ .jsonContent(token)
+ .sign(new ECDSASignatureSignerContext(proofKey));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to generate kid-only JWT proof", e);
+ }
+ }
+
+ private static String generateJwtProofWithX5c(KeycloakSession session, KeyWrapper proofKey, X509Certificate cert, String cNonce) {
+ try {
+ AccessToken token = new AccessToken();
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ token.addAudience(credentialIssuer);
+ token.setNonce(cNonce);
+ token.issuedNow();
+
+ return new JWSBuilder()
+ .type(JwtProofValidator.PROOF_JWT_TYP)
+ .x5c(List.of(cert))
+ .jsonContent(token)
+ .sign(new ECDSASignatureSignerContext(proofKey));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to generate JWT proof with x5c", e);
+ }
+ }
+
private static KeyAttestationJwtBody createAttestationPayload(JWK proofJwk, String cNonce) {
KeyAttestationJwtBody payload = new KeyAttestationJwtBody();
- payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
+ long iat = TIME_PROVIDER.currentTimeSeconds();
+ payload.setIat(iat);
+ payload.setExp(iat + 3600);
payload.setNonce(cNonce);
payload.setAttestedKeys(List.of(proofJwk));
payload.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH));
@@ -427,6 +682,8 @@ private static void runValidJwtProofWithKeyAttestationTest(KeycloakSession sessi
KeyWrapper attestationKey = getECKey("attestationKey");
KeyWrapper proofKey = getECKey("proofKey");
JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
String jwtProof = generateJwtProofWithKeyAttestation(session, proofKey, attestationJwt, cNonce);
@@ -458,6 +715,246 @@ private static void runInvalidJwtProofWithKeyAttestationTest(KeycloakSession ses
validator.validateProof(vcIssuanceContext);
}
+ private static void runValidKidJwtProofWithKeyAttestationTest(KeycloakSession session, String cNonce) {
+ KeyWrapper attestationKey = getECKey("attestationKey");
+ KeyWrapper proofKey = getECKey("proofKey");
+
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+
+ String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
+ String jwtProof = generateJwtProofWithKeyAttestation(session, proofKey, attestationJwt, cNonce, true);
+
+ VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
+ vcIssuanceContext.getCredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof)));
+
+ AttestationKeyResolver keyResolver = new StaticAttestationKeyResolver(
+ Map.of(attestationKey.getKid(), JWKBuilder.create().ec(attestationKey.getPublicKey()))
+ );
+ JwtProofValidator validator = new JwtProofValidator(session, keyResolver);
+
+ List validatedKeys = validator.validateProof(vcIssuanceContext);
+ assertNotNull(validatedKeys);
+ assertEquals(1, validatedKeys.size());
+ assertEquals(proofKey.getKid(), validatedKeys.get(0).getKeyId());
+ }
+
+ private static void runValidJwtProofWithKidOnlyTest(KeycloakSession session, String cNonce) {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ String previousTrustedKeyIds = realm.getAttribute(OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR);
+ try {
+ KeyWrapper walletKey = getECKey("kidOnlyTrustedKeysE2e");
+ JWK trustedPublicJwk = JWKBuilder.create().ec(walletKey.getPublicKey());
+ trustedPublicJwk.setKeyId(walletKey.getKid());
+ trustedPublicJwk.setAlgorithm(walletKey.getAlgorithm());
+
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(trustedPublicJwk)));
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR);
+ session.getContext().setRealm(realm);
+
+ JwtProofValidator validator = (JwtProofValidator) new JwtProofValidatorFactory().create(session);
+
+ String jwtProof = generateJwtProofWithKidNoAttestation(session, walletKey, cNonce);
+ VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
+ vcIssuanceContext.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
+
+ List validatedKeys = validator.validateProof(vcIssuanceContext);
+ assertNotNull(validatedKeys);
+ assertEquals(1, validatedKeys.size());
+ assertEquals(walletKey.getKid(), validatedKeys.get(0).getKeyId());
+ } catch (Exception e) {
+ throw new RuntimeException("Kid-only JWT proof with realm trusted_keys failed", e);
+ } finally {
+ RealmModel toRestore = session.realms().getRealm(realm.getId());
+ toRestore.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ if (previousTrustedKeyIds != null) {
+ toRestore.setAttribute(OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR, previousTrustedKeyIds);
+ } else {
+ toRestore.removeAttribute(OID4VCIConstants.TRUSTED_KEY_IDS_REALM_ATTR);
+ }
+ session.getContext().setRealm(toRestore);
+ }
+ }
+
+ private static void runJwtProofWithKeyAttestationMustContainProofKeyTest(KeycloakSession session, String cNonce) {
+ KeyWrapper attestationKey = getECKey("attestationKey");
+ KeyWrapper proofKey = getECKey("proofKey");
+ KeyWrapper differentKey = getECKey("differentKey");
+
+ JWK differentJwk = JWKBuilder.create().ec(differentKey.getPublicKey());
+ differentJwk.setKeyId(differentKey.getKid());
+ differentJwk.setAlgorithm(differentKey.getAlgorithm());
+
+ String attestationJwt = createValidAttestationJwt(session, attestationKey, differentJwk, cNonce);
+ String jwtProof = generateJwtProofWithKeyAttestation(session, proofKey, attestationJwt, cNonce);
+
+ VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
+ vcIssuanceContext.getCredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof)));
+
+ AttestationKeyResolver keyResolver = new StaticAttestationKeyResolver(
+ Map.of(attestationKey.getKid(), JWKBuilder.create().ec(attestationKey.getPublicKey()))
+ );
+ JwtProofValidator validator = new JwtProofValidator(session, keyResolver);
+
+ assertThrows(VCIssuerException.class, () -> validator.validateProof(vcIssuanceContext),
+ "Expected proof key mismatch against attested_keys to fail");
+ }
+
+ private static void runJwtProofWithJwkAndKidHeadersIsRejectedTest(KeycloakSession session, String cNonce) {
+ KeyWrapper attestationKey = getECKey("attestationKey");
+ KeyWrapper proofKey = getECKey("proofKey");
+
+ String jwtProof = generateJwtProofWithJwkAndKid(session, proofKey, cNonce);
+
+ VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
+ vcIssuanceContext.getCredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof)));
+
+ AttestationKeyResolver keyResolver = new StaticAttestationKeyResolver(
+ Map.of(attestationKey.getKid(), JWKBuilder.create().ec(attestationKey.getPublicKey()))
+ );
+ JwtProofValidator validator = new JwtProofValidator(session, keyResolver);
+
+ VCIssuerException e = assertThrows(VCIssuerException.class, () -> validator.validateProof(vcIssuanceContext));
+ assertTrue(e.getMessage().contains("mutually exclusive"),
+ "Expected mutual exclusivity validation error but got: " + e.getMessage());
+ }
+
+ private static void runValidX5cJwtProofWithoutAttestationTest(KeycloakSession session, String cNonce) {
+ try {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
+ keyGen.initialize(new ECGenParameterSpec("secp256r1"));
+ KeyPair keyPair = keyGen.generateKeyPair();
+ X509Certificate cert = CertificateUtils.generateV1SelfSignedCertificate(keyPair, "Proof Certificate");
+
+ KeyWrapper proofKey = new KeyWrapper();
+ proofKey.setPrivateKey(keyPair.getPrivate());
+ proofKey.setPublicKey(keyPair.getPublic());
+ proofKey.setAlgorithm("ES256");
+ proofKey.setType(KeyType.EC);
+ // Keep kid unset for this test so header contains only x5c (mutual exclusivity with kid/jwk).
+ proofKey.setKid(null);
+
+ String jwtProof = generateJwtProofWithX5c(session, proofKey, cert, cNonce);
+
+ VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
+ vcIssuanceContext.getCredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof)));
+
+ JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
+ List validatedKeys = validator.validateProof(vcIssuanceContext);
+
+ assertNotNull(validatedKeys, "Validated keys should not be null");
+ assertEquals(1, validatedKeys.size(), "Expected single validated key");
+ } catch (Exception e) {
+ throw new RuntimeException("x5c JWT proof validation failed", e);
+ }
+ }
+
+ private static void runJwtProofMissingIssuerForClientBoundFlowAllowedTest(KeycloakSession session, String cNonce) {
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce, null,
+ null, null, null);
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
+ context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
+ new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
+
+ JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
+ List validatedKeys = validator.validateProof(context);
+ assertNotNull(validatedKeys);
+ assertEquals(1, validatedKeys.size());
+ }
+
+ private static void runJwtProofWithWrongIssuerForClientBoundFlowRejectedTest(KeycloakSession session, String cNonce) {
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
+ "wrong-client-id", null, null, null);
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
+ context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
+ new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
+
+ JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
+ assertThrows(VCIssuerException.class, () -> validator.validateProof(context));
+ }
+
+ private static void runJwtProofWithIssuerInAnonymousFlowRejectedTest(KeycloakSession session, String cNonce) {
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
+ OID4VCIssuerTestBase.OID4VCI_CLIENT_ID, null, null, null);
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
+ context.setAuthResult(null);
+
+ JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
+ assertThrows(VCIssuerException.class, () -> validator.validateProof(context));
+ }
+
+ private static void runJwtProofWithMultipleAudiencesRejectedTest(KeycloakSession session, String cNonce) {
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(
+ List.of(credentialIssuer, "https://unrelated.example"), cNonce, OID4VCIssuerTestBase.OID4VCI_CLIENT_ID,
+ null, null, null);
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
+ context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
+ new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
+
+ JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
+ assertThrows(VCIssuerException.class, () -> validator.validateProof(context));
+ }
+
+ private static void runJwtProofWithFutureIatRejectedTest(KeycloakSession session, String cNonce) {
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ long now = System.currentTimeMillis() / 1000L;
+ String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
+ OID4VCIssuerTestBase.OID4VCI_CLIENT_ID, now + 120, null, null);
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
+ context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
+ new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
+
+ JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
+ assertThrows(VCIssuerException.class, () -> validator.validateProof(context));
+ }
+
+ private static void runJwtProofWithExpiredExpRejectedTest(KeycloakSession session, String cNonce) {
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ long now = System.currentTimeMillis() / 1000L;
+ String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
+ OID4VCIssuerTestBase.OID4VCI_CLIENT_ID, now, now - 1, null);
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
+ context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
+ new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
+
+ JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
+ assertThrows(VCIssuerException.class, () -> validator.validateProof(context));
+ }
+
+ private static void runJwtProofWithFutureNbfRejectedTest(KeycloakSession session, String cNonce) {
+ String credentialIssuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
+ long now = System.currentTimeMillis() / 1000L;
+ String jwtProof = OID4VCProofTestUtils.generateJwtProofWithClaims(List.of(credentialIssuer), cNonce,
+ OID4VCIssuerTestBase.OID4VCI_CLIENT_ID, now, null, now + 120);
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.setCredentialRequest(new CredentialRequest().setProofs(new Proofs().setJwt(List.of(jwtProof))));
+ context.setAuthResult(new AuthenticationManager.AuthResult(null, null,
+ new AccessToken().issuedFor(OID4VCIssuerTestBase.OID4VCI_CLIENT_ID), null));
+
+ JwtProofValidator validator = new JwtProofValidator(session, new StaticAttestationKeyResolver(Map.of()));
+ assertThrows(VCIssuerException.class, () -> validator.validateProof(context));
+ }
+
private static void runInvalidAttestationSignatureTest(KeycloakSession session, String cNonce) throws Exception {
KeyWrapper attestationKey = getECKey("attestationKey");
KeyWrapper proofKey = getECKey("proofKey");
@@ -673,4 +1170,204 @@ private static void runAttestationWithMissingAttestedKeys(KeycloakSession sessio
fail("Unexpected exception: " + e.getMessage());
}
}
+
+ private static void runAttestationProofAcceptsLegacyTypTest(KeycloakSession session, String cNonce) {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationKey = getECKey("legacyAttestationKey");
+ KeyWrapper proofKey = getECKey("legacyProofKey");
+
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+
+ String attestationJwt = createValidAttestationJwt(
+ session,
+ attestationKey,
+ List.of(proofJwk),
+ cNonce,
+ AttestationValidatorUtil.LEGACY_ATTESTATION_JWT_TYP);
+
+ JWK attestationJwk = JWKBuilder.create().ec(attestationKey.getPublicKey());
+ attestationJwk.setKeyId(attestationKey.getKid());
+ attestationJwk.setAlgorithm(attestationKey.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(attestationJwk)));
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.getCredentialRequest().setProofs(new Proofs().setAttestation(List.of(attestationJwt)));
+
+ AttestationProofValidator validator = (AttestationProofValidator) new AttestationProofValidatorFactory().create(session);
+ List attestedKeys = validator.validateProof(context);
+
+ assertNotNull(attestedKeys);
+ assertEquals(1, attestedKeys.size());
+ assertEquals(proofKey.getKid(), attestedKeys.get(0).getKeyId());
+ } catch (Exception e) {
+ throw new RuntimeException("Legacy typ attestation proof should be accepted", e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ }
+
+ private static void runAttestationProofWithRealmAttributeTrustedKeysTest(KeycloakSession session, String cNonce) {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationKey = getECKey("attestationKey");
+ KeyWrapper proofKey = getECKey("proofKey");
+
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+ String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
+
+ JWK attestationJwk = JWKBuilder.create().ec(attestationKey.getPublicKey());
+ attestationJwk.setKeyId(attestationKey.getKid());
+ attestationJwk.setAlgorithm(attestationKey.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(attestationJwk)));
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.getCredentialRequest().setProofs(new Proofs().setAttestation(List.of(attestationJwt)));
+
+ AttestationProofValidator validator = (AttestationProofValidator) new AttestationProofValidatorFactory().create(session);
+ List attestedKeys = validator.validateProof(context);
+
+ assertNotNull(attestedKeys);
+ assertEquals(1, attestedKeys.size());
+ assertEquals(proofKey.getKid(), attestedKeys.get(0).getKeyId());
+ } catch (Exception e) {
+ throw new RuntimeException("Attestation proof with realm trusted keys failed", e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ }
+
+ private static void runAttestationProofWithInvalidTrustedKeyTest(KeycloakSession session, String cNonce) throws VCIssuerException {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationKey = getECKey("attestationKey");
+ KeyWrapper proofKey = getECKey("proofKey");
+ KeyWrapper unrelatedKey = getECKey("unrelatedKey");
+
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+ String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
+
+ JWK unrelatedJwk = JWKBuilder.create().ec(unrelatedKey.getPublicKey());
+ unrelatedJwk.setKeyId(unrelatedKey.getKid());
+ unrelatedJwk.setAlgorithm(unrelatedKey.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(unrelatedJwk)));
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.getCredentialRequest().setProofs(new Proofs().setAttestation(List.of(attestationJwt)));
+
+ AttestationProofValidator validator = (AttestationProofValidator) new AttestationProofValidatorFactory().create(session);
+ validator.validateProof(context);
+ } catch (VCIssuerException e) {
+ throw e;
+ } catch (Exception e) {
+ fail("Unexpected exception: " + e.getMessage());
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ }
+
+ private static void runAttestationProofExtractsAttestedKeysFromPayloadTest(KeycloakSession session, String cNonce) {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationKey = getECKey("attestationKey");
+ KeyWrapper proofKey = getECKey("proofKey");
+
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+
+ String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
+
+ JWK attestationJwk = JWKBuilder.create().ec(attestationKey.getPublicKey());
+ attestationJwk.setKeyId(attestationKey.getKid());
+ attestationJwk.setAlgorithm(attestationKey.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(attestationJwk)));
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.getCredentialRequest().setProofs(new Proofs().setAttestation(List.of(attestationJwt)));
+
+ AttestationProofValidator validator = (AttestationProofValidator) new AttestationProofValidatorFactory().create(session);
+ List attestedKeys = validator.validateProof(context);
+
+ assertNotNull(attestedKeys);
+ assertEquals(1, attestedKeys.size());
+ assertEquals(proofKey.getKid(), attestedKeys.get(0).getKeyId());
+ } catch (Exception e) {
+ throw new RuntimeException("Attested keys should be extracted from payload", e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ }
+
+ private static void runAttestationProofWithMultipleTrustedKeysTest(KeycloakSession session, String cNonce) {
+ RealmModel realm = session.getContext().getRealm();
+ String previousTrustedKeys = realm.getAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ try {
+ KeyWrapper attestationKey1 = getECKey("attestationKey1");
+ KeyWrapper attestationKey2 = getECKey("attestationKey2");
+ KeyWrapper proofKey = getECKey("proofKey");
+
+ JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
+ proofJwk.setKeyId(proofKey.getKid());
+ proofJwk.setAlgorithm(proofKey.getAlgorithm());
+ String attestationJwt = createValidAttestationJwt(session, attestationKey1, proofJwk, cNonce);
+
+ JWK attestationJwk1 = JWKBuilder.create().ec(attestationKey1.getPublicKey());
+ attestationJwk1.setKeyId(attestationKey1.getKid());
+ attestationJwk1.setAlgorithm(attestationKey1.getAlgorithm());
+ JWK attestationJwk2 = JWKBuilder.create().ec(attestationKey2.getPublicKey());
+ attestationJwk2.setKeyId(attestationKey2.getKid());
+ attestationJwk2.setAlgorithm(attestationKey2.getAlgorithm());
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR,
+ JsonSerialization.writeValueAsString(List.of(attestationJwk1, attestationJwk2)));
+
+ VCIssuanceContext context = createVCIssuanceContext(session);
+ context.getCredentialRequest().setProofs(new Proofs().setAttestation(List.of(attestationJwt)));
+
+ AttestationProofValidator validator = (AttestationProofValidator) new AttestationProofValidatorFactory().create(session);
+ List attestedKeys = validator.validateProof(context);
+
+ assertNotNull(attestedKeys);
+ assertEquals(1, attestedKeys.size());
+ assertEquals(proofKey.getKid(), attestedKeys.get(0).getKeyId());
+ } catch (Exception e) {
+ throw new RuntimeException("Attestation should validate with multiple trusted keys configured", e);
+ } finally {
+ if (previousTrustedKeys != null) {
+ realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, previousTrustedKeys);
+ } else {
+ realm.removeAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR);
+ }
+ }
+ }
}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java
deleted file mode 100644
index 1471bc4e118e..000000000000
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCAttestationProofTest.java
+++ /dev/null
@@ -1,407 +0,0 @@
-package org.keycloak.testsuite.oid4vc.issuance.signing;
-
-import java.io.IOException;
-import java.util.List;
-
-import jakarta.ws.rs.core.Response;
-
-import org.keycloak.OID4VCConstants;
-import org.keycloak.TokenVerifier;
-import org.keycloak.common.VerificationException;
-import org.keycloak.constants.OID4VCIConstants;
-import org.keycloak.crypto.ECDSASignatureSignerContext;
-import org.keycloak.crypto.KeyWrapper;
-import org.keycloak.jose.jwk.JWK;
-import org.keycloak.jose.jwk.JWKBuilder;
-import org.keycloak.jose.jws.JWSBuilder;
-import org.keycloak.models.KeycloakSession;
-import org.keycloak.models.RealmModel;
-import org.keycloak.models.oid4vci.CredentialScopeModel;
-import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
-import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
-import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
-import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidator;
-import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationProofValidatorFactory;
-import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
-import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
-import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
-import org.keycloak.protocol.oid4vc.model.CredentialRequest;
-import org.keycloak.protocol.oid4vc.model.CredentialResponse;
-import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody;
-import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
-import org.keycloak.protocol.oid4vc.model.Proofs;
-import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
-import org.keycloak.representations.JsonWebToken;
-import org.keycloak.services.managers.AppAuthManager;
-import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
-import org.keycloak.util.JsonSerialization;
-
-import org.jboss.logging.Logger;
-import org.junit.Test;
-
-import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Assertions.fail;
-
-/**
- * Test class for verifying Attestation Proof functionality with trusted keys configuration.
- * Tests both component-based and realm attribute-based configuration.
- */
-public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
-
- private static final Logger LOGGER = Logger.getLogger(OID4VCAttestationProofTest.class);
-
- @Test
- public void testAttestationProofWithRealmAttributeTrustedKeys() {
- String cNonce = getCNonce();
- testingClient.server(TEST_REALM_NAME).run(session -> {
- runAttestationProofWithRealmAttributeTrustedKeys(session, cNonce);
- });
- }
-
- @Test
- public void testAttestationProofAcceptsLegacyTyp() {
- String cNonce = getCNonce();
- testingClient.server(TEST_REALM_NAME).run(session -> {
- try {
- KeyWrapper attestationKey = createECKey("legacyAttestationKey");
- KeyWrapper proofKey = createECKey("legacyProofKey");
-
- JWK proofJwk = createJWK(proofKey);
- String attestationJwt = createValidAttestationJwt(
- session,
- attestationKey,
- List.of(proofJwk),
- cNonce,
- AttestationValidatorUtil.LEGACY_ATTESTATION_JWT_TYP);
-
- configureTrustedKeysInRealm(session, List.of(createJWK(attestationKey)));
-
- VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
-
- AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
- AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
-
- validateProofAndAssert(validator, vcIssuanceContext, proofKey);
- } catch (Exception e) {
- LOGGER.error("Legacy typ test failed with exception", e);
- fail("Legacy typ attestation proof should be accepted: " + e.getMessage());
- }
- });
- }
-
- @Test
- public void testAttestationProofExtractsAttestedKeysFromPayload() {
- String cNonce = getCNonce();
- testingClient.server(TEST_REALM_NAME).run(session -> {
- runAttestationProofExtractsAttestedKeysFromPayload(session, cNonce);
- });
- }
-
- @Test
- public void testAttestationProofWithInvalidTrustedKey() {
- String cNonce = getCNonce();
- testingClient.server(TEST_REALM_NAME).run(session -> {
- try {
- runAttestationProofWithInvalidTrustedKey(session, cNonce);
- fail("Expected VCIssuerException to be thrown");
- } catch (VCIssuerException e) {
- assertTrue(e.getMessage().contains("not found in trusted key registry"),
- "Expected error about key not found in trusted key registry but got: " + e.getMessage());
- }
- });
- }
-
- @Test
- public void testAttestationProofValidatorFactoryConfiguration() {
- testingClient.server(TEST_REALM_NAME).run(session -> {
- AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
-
- assertEquals("attestation", factory.getId(), "Factory ID should be 'attestation'");
-
- ProofValidator validator = factory.create(session);
- assertNotNull(validator, "Factory should create validator");
- assertEquals("attestation", validator.getProofType(), "Validator proof type should be 'attestation'");
- });
- }
-
- @Test
- public void testAttestationProofWithMultipleTrustedKeys() {
- String cNonce = getCNonce();
- testingClient.server(TEST_REALM_NAME).run(session -> {
- runAttestationProofWithMultipleTrustedKeys(session, cNonce);
- });
- }
-
- @Test
- public void testCredentialIssuanceWithAttestationProof() {
- final String scopeName = jwtTypeCredentialClientScope.getName();
- String configIdFromScope = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
- final String credConfigId = configIdFromScope != null ? configIdFromScope : scopeName;
- CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
- OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
- authDetail.setType(OPENID_CREDENTIAL);
- authDetail.setCredentialConfigurationId(credConfigId);
- authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer()));
-
- String authCode = getAuthorizationCode(oauth, client, "john", scopeName);
- AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail);
- String token = tokenResponse.getAccessToken();
- List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails();
- assertNotNull(authDetailsResponse, "authorization_details should be present in the response");
- assertFalse(authDetailsResponse.isEmpty(), "authorization_details should not be empty");
- String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
- assertNotNull(credentialIdentifier, "credential_identifier should be present");
- String cNonce = getCNonce();
-
- testingClient.server(TEST_REALM_NAME).run(session -> {
- try {
- // Configure trusted keys via realm attribute
- KeyWrapper attestationKey = createECKey("attestationKey");
- JWK attestationJwk = createJWK(attestationKey);
- configureTrustedKeysInRealm(session, List.of(attestationJwk));
-
- // Create attestation JWT
- KeyWrapper proofKey = createECKey("proofKey");
- JWK proofJwk = createJWK(proofKey);
- String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
-
- // Create credential request with attestation proof
- AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
- authenticator.setTokenString(token);
-
- Proofs proofs = new Proofs().setAttestation(List.of(attestationJwt));
-
- CredentialRequest request = new CredentialRequest()
- .setCredentialIdentifier(credentialIdentifier)
- .setProofs(proofs);
-
- OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
-
- String requestPayload = JsonSerialization.writeValueAsString(request);
-
- Response response = endpoint.requestCredential(requestPayload);
- assertEquals(Response.Status.OK.getStatusCode(), response.getStatus(), "Response status should be OK");
-
- CredentialResponse credentialResponse = JsonSerialization.mapper
- .convertValue(response.getEntity(), CredentialResponse.class);
- assertNotNull(credentialResponse, "Credential response should not be null");
- assertNotNull(credentialResponse.getCredentials(), "Credentials array should not be null");
- assertEquals(1, credentialResponse.getCredentials().size(), "Should return 1 credential");
-
- // Validate the credential
- Object credentialObj = credentialResponse.getCredentials().get(0).getCredential();
- assertNotNull(credentialObj, "Credential should not be null");
- assertTrue(credentialObj instanceof String, "Credential should be a string");
-
- String credentialString = (String) credentialObj;
- JsonWebToken jsonWebToken;
- try {
- jsonWebToken = TokenVerifier.create(credentialString, JsonWebToken.class).getToken();
- } catch (VerificationException e) {
- fail("Failed to verify JWT credential: " + e.getMessage());
- return;
- }
-
- assertNotNull(jsonWebToken, "A valid credential JWT should be returned");
- assertNotNull(jsonWebToken.getOtherClaims().get("vc"), "The credentials should include the vc claim");
-
- VerifiableCredential vc = JsonSerialization.mapper.convertValue(
- jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
- assertNotNull(vc, "VerifiableCredential should not be null");
- assertNotNull(vc.getCredentialSubject(), "Credential subject should not be null");
- } catch (Exception e) {
- LOGGER.error("Test failed with exception", e);
- fail("Test should not throw exception: " + e.getMessage());
- }
- });
- }
-
- /**
- * Creates and configures an EC key with the given key ID.
- */
- private static KeyWrapper createECKey(String keyId) {
- KeyWrapper key = getECKey(keyId);
- key.setKid(keyId);
- key.setAlgorithm("ES256");
- return key;
- }
-
- /**
- * Creates a JWK from a KeyWrapper.
- */
- private static JWK createJWK(KeyWrapper keyWrapper) {
- JWK jwk = JWKBuilder.create().ec(keyWrapper.getPublicKey());
- jwk.setKeyId(keyWrapper.getKid());
- jwk.setAlgorithm(keyWrapper.getAlgorithm());
- return jwk;
- }
-
- /**
- * Configures trusted keys in realm attribute.
- */
- private static void configureTrustedKeysInRealm(KeycloakSession session, List trustedKeys) throws IOException {
- RealmModel realm = session.getContext().getRealm();
- String trustedKeysJson = JsonSerialization.writeValueAsString(trustedKeys);
- realm.setAttribute(OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR, trustedKeysJson);
- }
-
- /**
- * Creates a VCIssuanceContext with an attestation proof.
- */
- private static VCIssuanceContext createVCIssuanceContextWithAttestationProof(KeycloakSession session, String attestationJwt) {
- VCIssuanceContext vcIssuanceContext = createVCIssuanceContext(session);
- vcIssuanceContext.getCredentialRequest().setProofs(new Proofs().setAttestation(List.of(attestationJwt)));
- return vcIssuanceContext;
- }
-
- /**
- * Validates proof and returns attested keys, with common assertions.
- */
- private static List validateProofAndAssert(AttestationProofValidator validator,
- VCIssuanceContext vcIssuanceContext,
- KeyWrapper expectedProofKey) {
- List attestedKeys = validator.validateProof(vcIssuanceContext);
- assertNotNull(attestedKeys, "Attested keys should not be null");
- assertEquals(1, attestedKeys.size(), "Should contain exactly one attested key");
- assertEquals(expectedProofKey.getKid(), attestedKeys.get(0).getKeyId(), "Attested key ID should match proof key ID");
- return attestedKeys;
- }
-
- private static void runAttestationProofWithRealmAttributeTrustedKeys(KeycloakSession session, String cNonce) {
- try {
- KeyWrapper attestationKey = createECKey("attestationKey");
- KeyWrapper proofKey = createECKey("proofKey");
-
- // Create attestation JWT
- JWK proofJwk = createJWK(proofKey);
- String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
-
- // Configure trusted keys via realm attribute
- JWK attestationJwk = createJWK(attestationKey);
- configureTrustedKeysInRealm(session, List.of(attestationJwk));
-
- // Create VCIssuanceContext with attestation proof
- VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
-
- // Create validator using factory (should load from realm attribute)
- AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
- AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
-
- // Validate proof
- validateProofAndAssert(validator, vcIssuanceContext, proofKey);
- } catch (Exception e) {
- LOGGER.error("Test failed with exception", e);
- fail("Test should not throw exception: " + e.getMessage());
- }
- }
-
- /**
- * Tests that the validator correctly extracts attested_keys from the attestation payload
- * after verifying the attestation JWT signature with a trusted key.
- */
- private static void runAttestationProofExtractsAttestedKeysFromPayload(KeycloakSession session, String cNonce) {
- try {
- KeyWrapper attestationKey = createECKey("attestationKey");
- KeyWrapper proofKey = createECKey("proofKey");
-
- // Create attestation JWT with attested_keys in payload
- JWK proofJwk = createJWK(proofKey);
-
- KeyAttestationJwtBody payload = new KeyAttestationJwtBody();
- payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
- payload.setNonce(cNonce);
- payload.setAttestedKeys(List.of(proofJwk));
- payload.setKeyStorage(List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH));
- payload.setUserAuthentication(List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH));
-
- String attestationJwt = new JWSBuilder()
- .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)
- .kid(attestationKey.getKid())
- .jsonContent(payload)
- .sign(new ECDSASignatureSignerContext(attestationKey));
-
- // Configure trusted key for verifying the attestation signature
- // According to spec, the signature must verify with a trusted key from the header
- JWK attestationJwk = createJWK(attestationKey);
- configureTrustedKeysInRealm(session, List.of(attestationJwk));
-
- // Create VCIssuanceContext with attestation proof
- VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
-
- // Create validator with trusted keys configured
- AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
- AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
-
- // Validate proof - should verify signature with trusted key and extract attested_keys from payload
- validateProofAndAssert(validator, vcIssuanceContext, proofKey);
- } catch (Exception e) {
- LOGGER.error("Test failed with exception", e);
- fail("Test should not throw exception: " + e.getMessage());
- }
- }
-
- private static void runAttestationProofWithInvalidTrustedKey(KeycloakSession session, String cNonce) throws IOException {
- KeyWrapper attestationKey = createECKey("attestationKey");
- KeyWrapper proofKey = createECKey("proofKey");
- KeyWrapper unrelatedKey = createECKey("unrelatedKey");
-
- // Create attestation JWT
- JWK proofJwk = createJWK(proofKey);
- String attestationJwt = createValidAttestationJwt(session, attestationKey, proofJwk, cNonce);
-
- // Configure trusted keys with wrong key (unrelatedKey instead of attestationKey)
- JWK unrelatedJwk = createJWK(unrelatedKey);
- configureTrustedKeysInRealm(session, List.of(unrelatedJwk));
-
- try {
- // Create VCIssuanceContext with attestation proof
- VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
-
- // Create validator using factory
- AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
- AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
-
- // Validate proof - should fail because trusted key doesn't match
- validator.validateProof(vcIssuanceContext);
- } catch (VCIssuerException e) {
- throw e;
- } catch (Exception e) {
- fail("Unexpected exception: " + e.getMessage());
- }
- }
-
- private static void runAttestationProofWithMultipleTrustedKeys(KeycloakSession session, String cNonce) {
- try {
- KeyWrapper attestationKey1 = createECKey("attestationKey1");
- KeyWrapper attestationKey2 = createECKey("attestationKey2");
- KeyWrapper proofKey = createECKey("proofKey");
-
- // Create attestation JWT with first attestation key
- JWK proofJwk = createJWK(proofKey);
- String attestationJwt = createValidAttestationJwt(session, attestationKey1, proofJwk, cNonce);
-
- // Configure multiple trusted keys via realm attribute
- JWK attestationJwk1 = createJWK(attestationKey1);
- JWK attestationJwk2 = createJWK(attestationKey2);
- configureTrustedKeysInRealm(session, List.of(attestationJwk1, attestationJwk2));
-
- // Create VCIssuanceContext with attestation proof
- VCIssuanceContext vcIssuanceContext = createVCIssuanceContextWithAttestationProof(session, attestationJwt);
-
- // Create validator using factory
- AttestationProofValidatorFactory factory = new AttestationProofValidatorFactory();
- AttestationProofValidator validator = (AttestationProofValidator) factory.create(session);
-
- // Validate proof - should succeed because attestationKey1 is in trusted keys
- validateProofAndAssert(validator, vcIssuanceContext, proofKey);
- } catch (Exception e) {
- LOGGER.error("Test failed with exception", e);
- fail("Test should not throw exception: " + e.getMessage());
- }
- }
-}
From 477bdbeb991bcfa97bf5b9c30b2c804d1579159d Mon Sep 17 00:00:00 2001
From: mposolda
Date: Thu, 23 Apr 2026 09:33:33 +0200
Subject: [PATCH 2/2] CnonceHandler is always present. Small improve checking
for allowed algorithms
Signed-off-by: mposolda
---
.../keybinding/AttestationValidatorUtil.java | 23 ++++++-------------
.../keybinding/JwtProofValidator.java | 5 ++--
2 files changed, 10 insertions(+), 18 deletions(-)
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java
index c53fa476b364..4b41aee94e6c 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/AttestationValidatorUtil.java
@@ -44,6 +44,7 @@
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
+import org.keycloak.crypto.CryptoUtils;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
@@ -135,7 +136,7 @@ public static KeyAttestationJwtBody validateAttestationJwt(
}
JWSHeader header = jwsInput.getHeader();
- validateJwsHeader(header, vcIssuanceContext, proofTypeKeyForSigningAlgPolicy);
+ validateJwsHeader(keycloakSession, header, vcIssuanceContext, proofTypeKeyForSigningAlgPolicy);
// Verify the signature
Map rawHeader = JsonSerialization.mapper.convertValue(
@@ -196,19 +197,12 @@ private static void validateAttestationPayload(
KeycloakContext keycloakContext = keycloakSession.getContext();
CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class);
- // If CNonceHandler is available, nonce endpoint exists and nonce is required
- boolean nonceRequired = cNonceHandler != null;
-
- if (nonceRequired && attestationBody.getNonce() == null) {
+ if (attestationBody.getNonce() == null) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Missing 'nonce' in attestation");
}
// Validate nonce if present. If provided, it must correspond to a nonce value provided by Keycloak.
if (attestationBody.getNonce() != null) {
- if (cNonceHandler == null) {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "No CNonceHandler available");
- }
-
try {
cNonceHandler.verifyCNonce(
attestationBody.getNonce(),
@@ -321,18 +315,15 @@ private static Optional> resolveProofSigningAlgorithms(
.filter(algs -> !algs.isEmpty());
}
- private static void validateJwsHeader(JWSHeader header, VCIssuanceContext vcIssuanceContext,
+ private static void validateJwsHeader(KeycloakSession session, JWSHeader header, VCIssuanceContext vcIssuanceContext,
String proofTypeKeyForSigningAlgPolicy) {
String alg = Optional.ofNullable(header.getAlgorithm())
.map(Algorithm::name)
.orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "Missing algorithm in JWS header"));
- if ("none".equalsIgnoreCase(alg)) {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "'none' algorithm is not allowed");
- }
-
- if (alg.startsWith("HS")) {
- throw new VCIssuerException(ErrorType.INVALID_PROOF, "Symmetric algorithms are not allowed for key attestation");
+ List supportedAsymmetricAlgs = CryptoUtils.getSupportedAsymmetricSignatureAlgorithms(session);
+ if (!supportedAsymmetricAlgs.contains(alg)) {
+ throw new VCIssuerException(ErrorType.INVALID_PROOF, "Unsupported algorithm for key attestation. Only asymmetric algorithms are allowed");
}
Optional> metadataAlgs = resolveProofSigningAlgorithms(vcIssuanceContext, proofTypeKeyForSigningAlgPolicy);
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java
index 296ee4d21860..046a62a5de23 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java
@@ -30,6 +30,7 @@
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
+import org.keycloak.crypto.CryptoUtils;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKParser;
@@ -67,7 +68,7 @@ public class JwtProofValidator extends AbstractProofValidator {
public static final String PROOF_JWT_TYP = "openid4vci-proof+jwt";
private static final String CRYPTOGRAPHIC_BINDING_METHOD_JWK = "jwk";
private static final String KEY_ATTESTATION_CLAIM = "key_attestation";
- // JOSE private JWK parameters across RSA/EC/OKP/oct key types.
+ // JOSE private JWK parameters across RSA/EC/OKP/oct key types. TODO: This is not very reliable and should be either removed or improved to cover the cases when other algorithms are introduced in the future
private static final Set JWK_PRIVATE_KEY_CLAIMS = Set.of("d", "p", "q", "dp", "dq", "qi", "oth", "k");
private static final int PROOF_MAX_AGE_SECONDS = 30;
private static final int PROOF_FUTURE_SKEW_SECONDS = 10;
@@ -272,7 +273,7 @@ private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jw
String alg = Optional.ofNullable(jwsHeader.getAlgorithm())
.map(algorithm -> algorithm.name())
.orElseThrow(() -> new VCIssuerException(ErrorType.INVALID_PROOF, "Missing jwsHeader claim alg"));
- if ("none".equalsIgnoreCase(alg) || alg.startsWith("HS")) {
+ if (!CryptoUtils.getSupportedAsymmetricSignatureAlgorithms(keycloakSession).contains(alg)) {
throw new VCIssuerException(ErrorType.INVALID_PROOF, "Proof signature algorithm not supported: " + alg);
}