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): - *
    - *
  1. 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)
  2. - *
  3. Keys from realm attribute 'oid4vc.attestation.trusted_keys': Explicit JWK JSON array
  4. - *
  5. Realm session keys (default): All enabled keys from the realm's key providers (exposed in well-known endpoints)
  6. - *
- * 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); }