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;