diff --git a/core/src/main/java/org/keycloak/OID4VCConstants.java b/core/src/main/java/org/keycloak/OID4VCConstants.java index 7504e06b52e..4c8a11be0b5 100644 --- a/core/src/main/java/org/keycloak/OID4VCConstants.java +++ b/core/src/main/java/org/keycloak/OID4VCConstants.java @@ -50,4 +50,35 @@ public class OID4VCConstants { private OID4VCConstants() { } + + /** + * from the OID4VCI specification: + * + *
+     *  Appendix D.2. Attack Potential Resistance
+     *
+     *  This specification defines the following values for key_storage and user_authentication:
+     *  iso_18045_high: It MUST be used when key storage or user authentication is resistant to attack with attack
+     *  potential "High", equivalent to VAN.5 according to [ISO.18045].
+     *  iso_18045_moderate: It MUST be used when key storage or user authentication is resistant to attack with attack
+     *  potential "Moderate", equivalent to VAN.4 according to [ISO.18045]. iso_18045_enhanced-basic: It MUST be used
+     *  when key storage or user authentication is resistant to attack with attack potential "Enhanced-Basic",
+     *  equivalent to VAN.3 according to [ISO.18045]. iso_18045_basic: It MUST be used when key storage or user
+     *  authentication is resistant to attack with attack potential "Basic", equivalent to VAN.2 according to
+     *  [ISO.18045]. Specifications that extend this list MUST choose collision-resistant values.
+     * 
+ *

+ * this tells us that the KeyAttestationResistance is potentially extendable, and must therefore be handled with + * strings + */ + public static class KeyAttestationResistanceLevels { + + public static final String HIGH = "iso_18045_high"; // VAN.5 + + public static final String MODERATE = "iso_18045_moderate"; // VAN.4 + + public static final String ENHANCED_BASIC = "iso_18045_enhanced-basic"; // VAN.3 + + public static final String BASIC = "iso_18045_basic"; // VAN.2 + } } diff --git a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc index 22192676c6d..337d7f67017 100644 --- a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc +++ b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc @@ -407,6 +407,23 @@ _Default_: `31536000` (one year) | optional | If this claim should be listed in the credentials metadata. + _Default_: `true` but depends on the mapper-type. Claims like `jti`, `nbf`, `exp`, etc. are set to `false` by default. + +| `vc.key_attestations_required` +| optional +| Indicates whether issuing this credential requires a key attestation. + +_Default_: `false`. + +| `vc.key_attestations_required.key_storage` +| optional +| Comma separated list of accepted key-storage attack potential levels (see ISO 18045 levels, e.g. `iso_18045_high`). + +Only relevant if `vc.key_attestations_required` is present. + +_Default_: none + +| `vc.key_attestations_required.user_authentication` +| optional +| Comma separated list of accepted user-authentication attack potential levels (see ISO 18045 levels). + +Only relevant if `vc.key_attestations_required` is present. + +_Default_: none |=== ==== Attribute Breakdown - ProtocolMappers diff --git a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java index 62ebafe5726..6fb210ecafe 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java @@ -114,11 +114,30 @@ public class CredentialScopeModel implements ClientScopeModel { public static final String TOKEN_JWS_TYPE = "vc.credential_build_config.token_jws_type"; /** - * this configuration property can be used to enforce specific claims to be included in the metadata, if they - * would normally not and vice versa + * this configuration property can be used to enforce specific claims to be included in the metadata, if they would + * normally not and vice versa */ public static final String INCLUDE_IN_METADATA = "vc.include_in_metadata"; + /** + * OPTIONAL. Object that describes the requirement for key attestations as described in Appendix D, which the + * Credential Issuer expects the Wallet to send within the proof(s) of the Credential Request. If the Credential + * Issuer does not require a key attestation, this parameter MUST NOT be present in the metadata. If both + * key_storage and user_authentication parameters are absent, the key_attestations_required parameter may be empty, + * indicating a key attestation is needed without additional constraints. + */ + public static final String KEY_ATTESTATION_REQUIRED = "vc.key_attestations_required"; + + /** + * OPTIONAL. A non-empty array defining values specified in Appendix D.2 accepted by the Credential Issuer. + */ + public static final String KEY_ATTESTATION_REQUIRED_KEY_STORAGE = "vc.key_attestations_required.key_storage"; + + /** + * OPTIONAL. A non-empty array defining values specified in Appendix D.2 accepted by the Credential Issuer. + */ + public static final String KEY_ATTESTATION_REQUIRED_USER_AUTH = "vc.key_attestations_required.user_authentication"; + /** * the actual object that is represented by this scope @@ -307,6 +326,46 @@ public class CredentialScopeModel implements ClientScopeModel { clientScope.setAttribute(VC_DISPLAY, vcDisplay); } + public boolean isKeyAttestationRequired() { + return Optional.ofNullable(clientScope.getAttribute(KEY_ATTESTATION_REQUIRED)) + .map(Boolean::parseBoolean) + .orElse(false); + } + + public void setKeyAttestationRequired(boolean keyAttestationRequired) { + clientScope.setAttribute(KEY_ATTESTATION_REQUIRED, String.valueOf(keyAttestationRequired)); + } + + public List getRequiredKeyAttestationKeyStorage() { + return Optional.ofNullable(clientScope.getAttribute(KEY_ATTESTATION_REQUIRED_KEY_STORAGE)) + .map(s -> Arrays.asList(s.split(","))) + // it is important to return null here instead of an empty list: + // If both key_storage and user_authentication parameters are absent, the + // key_attestations_required parameter may be empty, indicating a key attestation is needed + // without additional constraints. Meaning we must not add empty objects to the metadata endpoint + .orElse(null); + } + + public void setRequiredKeyAttestationKeyStorage(List keyStorage) { + clientScope.setAttribute(KEY_ATTESTATION_REQUIRED_KEY_STORAGE, + Optional.ofNullable(keyStorage).map(list -> String.join(",")).orElse(null)); + } + + public List getRequiredKeyAttestationUserAuthentication() { + return Optional.ofNullable(clientScope.getAttribute(KEY_ATTESTATION_REQUIRED_USER_AUTH)) + .map(s -> Arrays.asList(s.split(","))) + // it is important to return null here instead of an empty list: + // If both key_storage and user_authentication parameters are absent, the + // key_attestations_required parameter may be empty, indicating a key attestation is needed + // without additional constraints. Meaning we must not add empty objects to the metadata endpoint + .orElse(null); + } + + public void getRequiredKeyAttestationUserAuthentication(List userAuthentication) { + clientScope.setAttribute(KEY_ATTESTATION_REQUIRED_USER_AUTH, + Optional.ofNullable(userAuthentication).map(list -> String.join(",")).orElse(null)); + } + @Override public String getId() { return clientScope.getId(); 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 f3bb9cfc634..e95f66d2214 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 @@ -41,7 +41,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import org.keycloak.common.VerificationException; import org.keycloak.crypto.KeyUse; @@ -61,7 +60,6 @@ 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.ErrorType; -import org.keycloak.protocol.oid4vc.model.ISO18045ResistanceLevel; import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody; import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired; import org.keycloak.protocol.oid4vc.model.SupportedProofTypeData; @@ -90,8 +88,8 @@ public class AttestationValidatorUtil { String attestationJwt, KeycloakSession keycloakSession, VCIssuanceContext vcIssuanceContext, - AttestationKeyResolver keyResolver) throws IOException, JWSInputException, - VerificationException{ + AttestationKeyResolver keyResolver) + throws JWSInputException, VerificationException { if (attestationJwt == null || attestationJwt.split("\\.").length != 3) { throw new VCIssuerException("Invalid JWT format"); @@ -164,21 +162,7 @@ public class AttestationValidatorUtil { // Get resistance level requirements from configuration KeyAttestationsRequired attestationRequirements = getAttestationRequirements(vcIssuanceContext); - - // Validate key_storage if present in attestation and required by config - if (attestationBody.getKeyStorage() != null) { - validateResistanceLevel( - attestationBody.getKeyStorage(), - attestationRequirements != null ? attestationRequirements.getKeyStorage() : null, - "key_storage"); - } - // Validate user_authentication if present in attestation and required by config - if (attestationBody.getUserAuthentication() != null) { - validateResistanceLevel( - attestationBody.getUserAuthentication(), - attestationRequirements != null ? attestationRequirements.getUserAuthentication() : null, - "user_authentication"); - } + validateResistanceLevel(attestationBody, attestationRequirements); KeycloakContext keycloakContext = keycloakSession.getContext(); CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class); @@ -230,39 +214,66 @@ public class AttestationValidatorUtil { return proofTypeData != null ? proofTypeData.getKeyAttestationsRequired() : null; } - private static void validateResistanceLevel( - List actualLevels, - List requiredLevels, - String levelType) throws VCIssuerException { + /** + * validates the configured key_attestations_required attribute against the given attestationBody + * + * @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, + KeyAttestationsRequired attestationRequirements) { + // if the KeyAttestationRequired object is null it is not necessary to validate it because the issuer does + // not require it: + // From the spec: + // ---- + // If the Credential Issuer does not require a key attestation, this parameter MUST NOT be present in the + // metadata. + // --- + // Meaning if the object is null we do not need to validate the resistance level + if (attestationRequirements != null) { + // Validate key_storage if present in attestation and required by config + validateResistanceLevel(attestationBody.getKeyStorage(), + attestationRequirements.getKeyStorage(), + "key_storage"); + // Validate user_authentication if present in attestation and required by config + validateResistanceLevel(attestationBody.getUserAuthentication(), + attestationRequirements.getUserAuthentication(), + "user_authentication"); + } + } - if (requiredLevels == null || requiredLevels.isEmpty()) { - for (String level : actualLevels) { - try { - ISO18045ResistanceLevel.fromValue(level); - } catch (Exception e) { - throw new VCIssuerException("Invalid " + levelType + " level: " + level); - } - } + /** + * 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" + * @throws VCIssuerException if the required resistance level is not met + */ + private static void validateResistanceLevel(List providedLevels, + List acceptedLevels, + String levelType) + throws VCIssuerException { + + if (acceptedLevels == null || acceptedLevels.isEmpty()) { + // We accept all provided levels return; } - // Convert required levels to string values for comparison - Set requiredLevelValues = requiredLevels.stream() - .map(ISO18045ResistanceLevel::getValue) - .collect(Collectors.toSet()); + // If both key_storage and user_authentication parameters are absent, the key_attestations_required + // parameter may be empty, indicating a key attestation is needed without additional constraints. + // from: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-12.2.4 + if (providedLevels == null || providedLevels.isEmpty()) { + throw new VCIssuerException(levelType + " is required but was missing."); + } - // Check each actual level against requirements - for (String level : actualLevels) { - try { - ISO18045ResistanceLevel levelEnum = ISO18045ResistanceLevel.fromValue(level); - if (!requiredLevelValues.contains(levelEnum.getValue())) { - throw new VCIssuerException( - levelType + " level '" + level + "' is not accepted by credential issuer. " + - "Allowed values: " + requiredLevelValues); - } - } catch (IllegalArgumentException e) { - throw new VCIssuerException("Invalid " + levelType + " level: " + level); - } + // Check each provided level against the accepted levels + boolean foundMatch = providedLevels.stream().anyMatch(acceptedLevels::contains); + if (!foundMatch) { + throw new VCIssuerException( + levelType + " none of the provided levels from '" + providedLevels + "' did match any of the " + + "accepted levels: " + acceptedLevels); } } @@ -308,14 +319,14 @@ public class AttestationValidatorUtil { // Check if this is a self-signed certificate (for test environments) X509Certificate firstCert = certChain.get(0); boolean isSelfSigned = firstCert.getSubjectX500Principal().equals(firstCert.getIssuerX500Principal()); - + // Only validate the certificate chain if it's not a self-signed certificate in a test environment if (!isSelfSigned) { // Validate certificate chain CertPathValidator validator = CertPathValidator.getInstance("PKIX"); PKIXParameters params = new PKIXParameters(getTrustAnchors()); params.setRevocationEnabled(false); - + validator.validate(certPath, params); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ISO18045ResistanceLevel.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ISO18045ResistanceLevel.java deleted file mode 100644 index 2a2e1bd2296..00000000000 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ISO18045ResistanceLevel.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2025 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.keycloak.protocol.oid4vc.model; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonValue; - -/** - * Attack Potential Resistance. Defined values for `key_storage` and `user_authentication` - * in {@link KeyAttestationsRequired} as per ISO 18045. - * - * @author Ingrid Kamga - * @see - * OpenID4VCI Attack Potential Resistance - */ -public enum ISO18045ResistanceLevel { - - HIGH("iso_18045_high"), // VAN.5 - MODERATE("iso_18045_moderate"), // VAN.4 - ENHANCED_BASIC("iso_18045_enhanced-basic"), // VAN.3 - BASIC("iso_18045_basic"); // VAN.2 - - private final String value; - - ISO18045ResistanceLevel(String value) { - this.value = value; - } - - @JsonValue - public String getValue() { - return value; - } - - @Override - public String toString() { - return getValue(); - } - - @JsonCreator - public static ISO18045ResistanceLevel fromValue(String value) { - for (ISO18045ResistanceLevel level : values()) { - if (level.value.equals(value)) { - return level; - } - } - - throw new IllegalArgumentException("Unknown ISO18045ResistanceLevel: " + value); - } -} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/KeyAttestationsRequired.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/KeyAttestationsRequired.java index 759324a9425..31e9a16af05 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/KeyAttestationsRequired.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/KeyAttestationsRequired.java @@ -20,6 +20,8 @@ package org.keycloak.protocol.oid4vc.model; import java.util.List; import java.util.Objects; +import org.keycloak.models.oid4vci.CredentialScopeModel; + import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -34,11 +36,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; public class KeyAttestationsRequired { @JsonProperty("key_storage") - private List keyStorage; + private List keyStorage; @JsonProperty("user_authentication") - private List userAuthentication; - + private List userAuthentication; + /** * Default constructor for Jackson deserialization */ @@ -46,20 +48,30 @@ public class KeyAttestationsRequired { // Default constructor for Jackson deserialization } - public List getKeyStorage() { + public static KeyAttestationsRequired parse(CredentialScopeModel credentialScope) { + KeyAttestationsRequired keyAttestationsRequired = null; + if (credentialScope.isKeyAttestationRequired()) { + keyAttestationsRequired = new KeyAttestationsRequired(); + keyAttestationsRequired.setKeyStorage(credentialScope.getRequiredKeyAttestationKeyStorage()); + keyAttestationsRequired.setUserAuthentication(credentialScope.getRequiredKeyAttestationUserAuthentication()); + } + return keyAttestationsRequired; + } + + public List getKeyStorage() { return keyStorage; } - public KeyAttestationsRequired setKeyStorage(List keyStorage) { + public KeyAttestationsRequired setKeyStorage(List keyStorage) { this.keyStorage = keyStorage; return this; } - public List getUserAuthentication() { + public List getUserAuthentication() { return userAuthentication; } - public KeyAttestationsRequired setUserAuthentication(List userAuthentication) { + public KeyAttestationsRequired setUserAuthentication(List userAuthentication) { this.userAuthentication = userAuthentication; return this; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java index 0ef563f299e..32efa37ad66 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java @@ -41,11 +41,11 @@ public class ProofTypesSupported { protected Map supportedProofTypes = new HashMap<>(); public static ProofTypesSupported parse(KeycloakSession keycloakSession, + KeyAttestationsRequired keyAttestationsRequired, List globalSupportedSigningAlgorithms) { ProofTypesSupported proofTypesSupported = new ProofTypesSupported(); keycloakSession.getAllProviders(ProofValidator.class).forEach(proofValidator -> { String type = proofValidator.getProofType(); - KeyAttestationsRequired keyAttestationsRequired = new KeyAttestationsRequired(); SupportedProofTypeData supportedProofTypeData = new SupportedProofTypeData(globalSupportedSigningAlgorithms, keyAttestationsRequired); proofTypesSupported.getSupportedProofTypes().put(type, supportedProofTypeData); @@ -80,6 +80,11 @@ public class ProofTypesSupported { } } + @Override + public String toString() { + return toJsonString(); + } + @Override public final boolean equals(Object o) { if (!(o instanceof ProofTypesSupported that)) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java index 2d7926f825f..776ab9b3da7 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java @@ -114,8 +114,10 @@ public class SupportedCredentialConfiguration { CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope); credentialConfiguration.setCredentialDefinition(credentialDefinition); - ProofTypesSupported proofTypesSupported = ProofTypesSupported.parse(keycloakSession, - globalSupportedSigningAlgorithms); + KeyAttestationsRequired keyAttestationsRequired = KeyAttestationsRequired.parse(credentialScope); + ProofTypesSupported proofTypesSupported = ProofTypesSupported.parse(keycloakSession, + keyAttestationsRequired, + globalSupportedSigningAlgorithms); credentialConfiguration.setProofTypesSupported(proofTypesSupported); List signingAlgsSupported = credentialScope.getSigningAlgsSupported(); 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 index 48d7f8ab92f..697bcfed5a4 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -25,7 +26,6 @@ import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator; import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialResponse; -import org.keycloak.protocol.oid4vc.model.ISO18045ResistanceLevel; import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody; import org.keycloak.protocol.oid4vc.model.Proofs; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; @@ -271,8 +271,8 @@ public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest { payload.setIat((long) TIME_PROVIDER.currentTimeSeconds()); payload.setNonce(cNonce); payload.setAttestedKeys(List.of(proofJwk)); - payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue())); - payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue())); + payload.setKeyStorage(List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH)); + payload.setUserAuthentication(List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH)); String attestationJwt = new JWSBuilder() .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index b3ce0c0749a..6e69e659a78 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -28,6 +28,7 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.interfaces.RSAPublicKey; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -46,6 +47,7 @@ import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriBuilder; +import org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels; import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.RealmResource; @@ -212,26 +214,29 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { // Register the optional client scopes sdJwtTypeCredentialClientScope = registerOptionalClientScope(sdJwtTypeCredentialScopeName, - null, - sdJwtTypeCredentialConfigurationIdName, - sdJwtTypeCredentialScopeName, - sdJwtCredentialVct, - Format.SD_JWT_VC, - null); + null, + sdJwtTypeCredentialConfigurationIdName, + sdJwtTypeCredentialScopeName, + sdJwtCredentialVct, + Format.SD_JWT_VC, + null, + List.of(KeyAttestationResistanceLevels.HIGH, + KeyAttestationResistanceLevels.MODERATE)); jwtTypeCredentialClientScope = registerOptionalClientScope(jwtTypeCredentialScopeName, - TEST_DID.toString(), - jwtTypeCredentialConfigurationIdName, - jwtTypeCredentialScopeName, - null, - Format.JWT_VC, - TEST_CREDENTIAL_MAPPERS_FILE); + TEST_DID.toString(), + jwtTypeCredentialConfigurationIdName, + jwtTypeCredentialScopeName, + null, + Format.JWT_VC, + TEST_CREDENTIAL_MAPPERS_FILE, + Collections.emptyList()); minimalJwtTypeCredentialClientScope = registerOptionalClientScope("vc-with-minimal-config", - null, - null, - null, - null, - null, - null); + null, + null, + null, + null, + null, + null, null); List.of(client, namedClient).forEach(client -> { String clientId = client.getClientId(); @@ -267,7 +272,8 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { String credentialIdentifier, String vct, String format, - String protocolMapperReferenceFile) { + String protocolMapperReferenceFile, + List acceptedKeyAttestationValues) { // Check if the client scope already exists List existingScopes = testRealm().clientScopes().findAll(); for (ClientScopeRepresentation existingScope : existingScopes) { @@ -305,6 +311,15 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { } addAttribute.accept(CredentialScopeModel.VC_DISPLAY, vcDisplay); } + if (acceptedKeyAttestationValues != null) { + attributes.put(CredentialScopeModel.KEY_ATTESTATION_REQUIRED, "true"); + if (!acceptedKeyAttestationValues.isEmpty()) { + attributes.put(CredentialScopeModel.KEY_ATTESTATION_REQUIRED_KEY_STORAGE, + String.join(",", acceptedKeyAttestationValues)); + attributes.put(CredentialScopeModel.KEY_ATTESTATION_REQUIRED_USER_AUTH, + String.join(",", acceptedKeyAttestationValues)); + } + } clientScope.setAttributes(attributes); Response res = testRealm().clientScopes().create(clientScope); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index c37daeebbc5..4e550fa9f72 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -59,6 +59,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.Format; +import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired; import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.representations.idm.ClientScopeRepresentation; @@ -537,10 +538,31 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest List signingAlgsSupported = new ArrayList<>(supportedConfig.getCredentialSigningAlgValuesSupported()); String proofTypesSupportedString = supportedConfig.getProofTypesSupported().toJsonString(); + KeyAttestationsRequired expectedKeyAttestationsRequired = null; + if (Boolean.parseBoolean(clientScope.getAttributes().get(CredentialScopeModel.KEY_ATTESTATION_REQUIRED))) { + expectedKeyAttestationsRequired = new KeyAttestationsRequired(); + expectedKeyAttestationsRequired.setKeyStorage( + Optional.ofNullable(clientScope.getAttributes() + .get(CredentialScopeModel.KEY_ATTESTATION_REQUIRED_KEY_STORAGE)) + .map(s -> Arrays.asList(s.split(","))) + .orElse(null)); + expectedKeyAttestationsRequired.setUserAuthentication( + Optional.ofNullable(clientScope.getAttributes() + .get(CredentialScopeModel.KEY_ATTESTATION_REQUIRED_USER_AUTH)) + .map(s -> Arrays.asList(s.split(","))) + .orElse(null)); + } + String expectedKeyAttestationsRequiredString = toJsonString(expectedKeyAttestationsRequired); + try { withCausePropagation(() -> testingClient.server(TEST_REALM_NAME).run((session -> { + KeyAttestationsRequired keyAttestationsRequired = // + Optional.ofNullable(expectedKeyAttestationsRequiredString) + .map(s -> fromJsonString(s, KeyAttestationsRequired.class)) + .orElse(null); ProofTypesSupported expectedProofTypesSupported = ProofTypesSupported.parse(session, - List.of(Algorithm.RS256)); + keyAttestationsRequired, + List.of(Algorithm.RS256)); assertEquals(expectedProofTypesSupported, ProofTypesSupported.fromJsonString(proofTypesSupportedString)); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java index d04badb1e27..9ef26d65d1c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import javax.net.ssl.TrustManagerFactory; +import org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels; import org.keycloak.common.util.CertificateUtils; import org.keycloak.crypto.ECDSASignatureSignerContext; import org.keycloak.crypto.KeyType; @@ -44,7 +45,6 @@ import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator; import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator; import org.keycloak.protocol.oid4vc.issuance.keybinding.StaticAttestationKeyResolver; -import org.keycloak.protocol.oid4vc.model.ISO18045ResistanceLevel; import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody; import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired; import org.keycloak.protocol.oid4vc.model.Proofs; @@ -210,12 +210,12 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest { payload.setNonce(cNonce); payload.setAttestedKeys(List.of(proofJwk)); payload.setKeyStorage(List.of( - ISO18045ResistanceLevel.HIGH.getValue(), - ISO18045ResistanceLevel.MODERATE.getValue() + KeyAttestationResistanceLevels.HIGH, + KeyAttestationResistanceLevels.MODERATE )); payload.setUserAuthentication(List.of( - ISO18045ResistanceLevel.ENHANCED_BASIC.getValue(), - ISO18045ResistanceLevel.BASIC.getValue() + KeyAttestationResistanceLevels.ENHANCED_BASIC, + KeyAttestationResistanceLevels.BASIC )); String attestationJwt = new JWSBuilder() @@ -228,13 +228,13 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest { // Set attestation requirements KeyAttestationsRequired attestationRequirements = new KeyAttestationsRequired(); attestationRequirements.setKeyStorage(List.of( - ISO18045ResistanceLevel.HIGH, - ISO18045ResistanceLevel.MODERATE, - ISO18045ResistanceLevel.ENHANCED_BASIC + KeyAttestationResistanceLevels.HIGH, + KeyAttestationResistanceLevels.MODERATE, + KeyAttestationResistanceLevels.ENHANCED_BASIC )); attestationRequirements.setUserAuthentication(List.of( - ISO18045ResistanceLevel.BASIC, - ISO18045ResistanceLevel.ENHANCED_BASIC + KeyAttestationResistanceLevels.BASIC, + KeyAttestationResistanceLevels.ENHANCED_BASIC )); vcIssuanceContext.getCredentialConfig() @@ -433,8 +433,8 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest { payload.setIat((long) TIME_PROVIDER.currentTimeSeconds()); payload.setNonce(cNonce); payload.setAttestedKeys(List.of(proofJwk1, proofJwk2)); - payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue())); - payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue())); + payload.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH)); + payload.setUserAuthentication(List.of(KeyAttestationResistanceLevels.HIGH)); String attestationJwt = new JWSBuilder() .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) @@ -486,8 +486,8 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest { payload.setNonce(cNonce); payload.setIat((long) TIME_PROVIDER.currentTimeSeconds()); payload.setAttestedKeys(List.of(proofJwk)); - payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue())); - payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue())); + payload.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH)); + payload.setUserAuthentication(List.of(KeyAttestationResistanceLevels.HIGH)); String attestationJwt = new JWSBuilder() .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) @@ -587,8 +587,9 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest { validator.validateProof(context); fail("Expected VCIssuerException for missing attested_keys"); } catch (VCIssuerException e) { - assertTrue("Expected error about missing keys but got: " + e.getMessage(), - e.getMessage().contains("attested_keys")); + assertEquals("Expected error about missing keys but got: " + e.getMessage(), + "key_storage is required but was missing.", + e.getMessage()); } catch (Exception e) { fail("Unexpected exception: " + e.getMessage()); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index a42605954ed..6202548b0b4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -46,6 +46,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriBuilder; +import org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels; import org.keycloak.admin.client.resource.ClientScopeResource; import org.keycloak.admin.client.resource.ProtocolMappersResource; import org.keycloak.common.Profile; @@ -75,8 +76,8 @@ import org.keycloak.protocol.oid4vc.model.AuthorizationDetail; import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.Format; -import org.keycloak.protocol.oid4vc.model.ISO18045ResistanceLevel; import org.keycloak.protocol.oid4vc.model.KeyAttestationJwtBody; +import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired; import org.keycloak.protocol.oid4vc.model.NonceResponse; import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; @@ -652,8 +653,8 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { payload.setIat((long) TIME_PROVIDER.currentTimeSeconds()); payload.setNonce(cNonce); payload.setAttestedKeys(proofJwks); - payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue())); - payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue())); + payload.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH)); + payload.setUserAuthentication(List.of(KeyAttestationResistanceLevels.HIGH)); return new JWSBuilder() .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) @@ -667,11 +668,14 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { protected static VCIssuanceContext createVCIssuanceContext(KeycloakSession session) { VCIssuanceContext context = new VCIssuanceContext(); + KeyAttestationsRequired keyAttestationsRequired = new KeyAttestationsRequired(); + keyAttestationsRequired.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH, + KeyAttestationResistanceLevels.MODERATE)); SupportedCredentialConfiguration config = new SupportedCredentialConfiguration() .setFormat(Format.SD_JWT_VC) .setVct("https://credentials.example.com/test-credential") .setCryptographicBindingMethodsSupported(List.of("jwk")) - .setProofTypesSupported(ProofTypesSupported.parse(session, List.of("ES256"))); + .setProofTypesSupported(ProofTypesSupported.parse(session, keyAttestationsRequired, List.of("ES256"))); context.setCredentialConfig(config) .setCredentialRequest(new CredentialRequest()); @@ -718,8 +722,8 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { Map payload = new HashMap<>(); payload.put("iat", TIME_PROVIDER.currentTimeSeconds()); payload.put("attested_keys", List.of(proofJwk)); - payload.put("key_storage", List.of(ISO18045ResistanceLevel.HIGH.getValue())); - payload.put("user_authentication", List.of(ISO18045ResistanceLevel.HIGH.getValue())); + payload.put("key_storage", List.of(KeyAttestationResistanceLevels.HIGH)); + payload.put("user_authentication", List.of(KeyAttestationResistanceLevels.HIGH)); payload.put("nonce", cNonce); return payload;