[OID4VCI] Handle key_attestation_required in metadata endpoint (#44471)

fixes #43801


Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de>
Signed-off-by: Pascal Knüppel <captain.p.goldfish@gmx.de>
Signed-off-by: Captain-P-Goldfish <captain.p.goldfish@gmx.de>
Co-authored-by: Ingrid Kamga <xingridkamga@gmail.com>
This commit is contained in:
Pascal Knüppel
2025-12-05 16:00:32 +01:00
committed by GitHub
parent b39231fab8
commit 46e5979b17
13 changed files with 285 additions and 170 deletions

View File

@@ -50,4 +50,35 @@ public class OID4VCConstants {
private OID4VCConstants() {
}
/**
* from the OID4VCI specification:
*
* <pre>
* 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.
* </pre>
* <p>
* 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
}
}

View File

@@ -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

View File

@@ -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<String> 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<String> keyStorage) {
clientScope.setAttribute(KEY_ATTESTATION_REQUIRED_KEY_STORAGE,
Optional.ofNullable(keyStorage).map(list -> String.join(",")).orElse(null));
}
public List<String> 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<String> userAuthentication) {
clientScope.setAttribute(KEY_ATTESTATION_REQUIRED_USER_AUTH,
Optional.ofNullable(userAuthentication).map(list -> String.join(",")).orElse(null));
}
@Override
public String getId() {
return clientScope.getId();

View File

@@ -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<String> actualLevels,
List<ISO18045ResistanceLevel> 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<String> providedLevels,
List<String> 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<String> 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())) {
// Check each provided level against the accepted levels
boolean foundMatch = providedLevels.stream().anyMatch(acceptedLevels::contains);
if (!foundMatch) {
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);
}
levelType + " none of the provided levels from '" + providedLevels + "' did match any of the " +
"accepted levels: " + acceptedLevels);
}
}

View File

@@ -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 <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
* @see <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-15.html#name-attack-potential-resistance">
* OpenID4VCI Attack Potential Resistance</a>
*/
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);
}
}

View File

@@ -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,10 +36,10 @@ import com.fasterxml.jackson.annotation.JsonProperty;
public class KeyAttestationsRequired {
@JsonProperty("key_storage")
private List<ISO18045ResistanceLevel> keyStorage;
private List<String> keyStorage;
@JsonProperty("user_authentication")
private List<ISO18045ResistanceLevel> userAuthentication;
private List<String> userAuthentication;
/**
* Default constructor for Jackson deserialization
@@ -46,20 +48,30 @@ public class KeyAttestationsRequired {
// Default constructor for Jackson deserialization
}
public List<ISO18045ResistanceLevel> 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<String> getKeyStorage() {
return keyStorage;
}
public KeyAttestationsRequired setKeyStorage(List<ISO18045ResistanceLevel> keyStorage) {
public KeyAttestationsRequired setKeyStorage(List<String> keyStorage) {
this.keyStorage = keyStorage;
return this;
}
public List<ISO18045ResistanceLevel> getUserAuthentication() {
public List<String> getUserAuthentication() {
return userAuthentication;
}
public KeyAttestationsRequired setUserAuthentication(List<ISO18045ResistanceLevel> userAuthentication) {
public KeyAttestationsRequired setUserAuthentication(List<String> userAuthentication) {
this.userAuthentication = userAuthentication;
return this;
}

View File

@@ -41,11 +41,11 @@ public class ProofTypesSupported {
protected Map<String, SupportedProofTypeData> supportedProofTypes = new HashMap<>();
public static ProofTypesSupported parse(KeycloakSession keycloakSession,
KeyAttestationsRequired keyAttestationsRequired,
List<String> 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)) {

View File

@@ -114,7 +114,9 @@ public class SupportedCredentialConfiguration {
CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope);
credentialConfiguration.setCredentialDefinition(credentialDefinition);
KeyAttestationsRequired keyAttestationsRequired = KeyAttestationsRequired.parse(credentialScope);
ProofTypesSupported proofTypesSupported = ProofTypesSupported.parse(keycloakSession,
keyAttestationsRequired,
globalSupportedSigningAlgorithms);
credentialConfiguration.setProofTypesSupported(proofTypesSupported);

View File

@@ -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)

View File

@@ -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;
@@ -217,21 +219,24 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
sdJwtTypeCredentialScopeName,
sdJwtCredentialVct,
Format.SD_JWT_VC,
null);
null,
List.of(KeyAttestationResistanceLevels.HIGH,
KeyAttestationResistanceLevels.MODERATE));
jwtTypeCredentialClientScope = registerOptionalClientScope(jwtTypeCredentialScopeName,
TEST_DID.toString(),
jwtTypeCredentialConfigurationIdName,
jwtTypeCredentialScopeName,
null,
Format.JWT_VC,
TEST_CREDENTIAL_MAPPERS_FILE);
TEST_CREDENTIAL_MAPPERS_FILE,
Collections.emptyList());
minimalJwtTypeCredentialClientScope = registerOptionalClientScope("vc-with-minimal-config",
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<String> acceptedKeyAttestationValues) {
// Check if the client scope already exists
List<ClientScopeRepresentation> 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);

View File

@@ -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,9 +538,30 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
List<String> 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,
keyAttestationsRequired,
List.of(Algorithm.RS256));
assertEquals(expectedProofTypesSupported,
ProofTypesSupported.fromJsonString(proofTypesSupportedString));

View File

@@ -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());
}

View File

@@ -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<String, Object> 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;