[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() { 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 | optional
| If this claim should be listed in the credentials metadata. + | 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. _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 ==== 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"; 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 * this configuration property can be used to enforce specific claims to be included in the metadata, if they would
* would normally not and vice versa * normally not and vice versa
*/ */
public static final String INCLUDE_IN_METADATA = "vc.include_in_metadata"; 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 * the actual object that is represented by this scope
@@ -307,6 +326,46 @@ public class CredentialScopeModel implements ClientScopeModel {
clientScope.setAttribute(VC_DISPLAY, vcDisplay); 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 @Override
public String getId() { public String getId() {
return clientScope.getId(); return clientScope.getId();

View File

@@ -41,7 +41,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
import org.keycloak.common.VerificationException; import org.keycloak.common.VerificationException;
import org.keycloak.crypto.KeyUse; 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.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException; import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
import org.keycloak.protocol.oid4vc.model.ErrorType; 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.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired; import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired;
import org.keycloak.protocol.oid4vc.model.SupportedProofTypeData; import org.keycloak.protocol.oid4vc.model.SupportedProofTypeData;
@@ -90,8 +88,8 @@ public class AttestationValidatorUtil {
String attestationJwt, String attestationJwt,
KeycloakSession keycloakSession, KeycloakSession keycloakSession,
VCIssuanceContext vcIssuanceContext, VCIssuanceContext vcIssuanceContext,
AttestationKeyResolver keyResolver) throws IOException, JWSInputException, AttestationKeyResolver keyResolver)
VerificationException{ throws JWSInputException, VerificationException {
if (attestationJwt == null || attestationJwt.split("\\.").length != 3) { if (attestationJwt == null || attestationJwt.split("\\.").length != 3) {
throw new VCIssuerException("Invalid JWT format"); throw new VCIssuerException("Invalid JWT format");
@@ -164,21 +162,7 @@ public class AttestationValidatorUtil {
// Get resistance level requirements from configuration // Get resistance level requirements from configuration
KeyAttestationsRequired attestationRequirements = getAttestationRequirements(vcIssuanceContext); KeyAttestationsRequired attestationRequirements = getAttestationRequirements(vcIssuanceContext);
validateResistanceLevel(attestationBody, attestationRequirements);
// 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");
}
KeycloakContext keycloakContext = keycloakSession.getContext(); KeycloakContext keycloakContext = keycloakSession.getContext();
CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class); CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class);
@@ -230,39 +214,66 @@ public class AttestationValidatorUtil {
return proofTypeData != null ? proofTypeData.getKeyAttestationsRequired() : null; return proofTypeData != null ? proofTypeData.getKeyAttestationsRequired() : null;
} }
private static void validateResistanceLevel( /**
List<String> actualLevels, * validates the configured key_attestations_required attribute against the given attestationBody
List<ISO18045ResistanceLevel> requiredLevels, *
String levelType) throws VCIssuerException { * @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) { * Validates the given key_attestations (key_storage or user_authentication) against the current configuration as
try { * provided by the metadata endpoint.
ISO18045ResistanceLevel.fromValue(level); *
} catch (Exception e) { * @param providedLevels the attestation levels to be validated
throw new VCIssuerException("Invalid " + levelType + " level: " + level); * @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; return;
} }
// Convert required levels to string values for comparison // If both key_storage and user_authentication parameters are absent, the key_attestations_required
Set<String> requiredLevelValues = requiredLevels.stream() // parameter may be empty, indicating a key attestation is needed without additional constraints.
.map(ISO18045ResistanceLevel::getValue) // from: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-12.2.4
.collect(Collectors.toSet()); if (providedLevels == null || providedLevels.isEmpty()) {
throw new VCIssuerException(levelType + " is required but was missing.");
}
// Check each actual level against requirements // Check each provided level against the accepted levels
for (String level : actualLevels) { boolean foundMatch = providedLevels.stream().anyMatch(acceptedLevels::contains);
try { if (!foundMatch) {
ISO18045ResistanceLevel levelEnum = ISO18045ResistanceLevel.fromValue(level);
if (!requiredLevelValues.contains(levelEnum.getValue())) {
throw new VCIssuerException( throw new VCIssuerException(
levelType + " level '" + level + "' is not accepted by credential issuer. " + levelType + " none of the provided levels from '" + providedLevels + "' did match any of the " +
"Allowed values: " + requiredLevelValues); "accepted levels: " + acceptedLevels);
}
} catch (IllegalArgumentException e) {
throw new VCIssuerException("Invalid " + levelType + " level: " + level);
}
} }
} }

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

View File

@@ -41,11 +41,11 @@ public class ProofTypesSupported {
protected Map<String, SupportedProofTypeData> supportedProofTypes = new HashMap<>(); protected Map<String, SupportedProofTypeData> supportedProofTypes = new HashMap<>();
public static ProofTypesSupported parse(KeycloakSession keycloakSession, public static ProofTypesSupported parse(KeycloakSession keycloakSession,
KeyAttestationsRequired keyAttestationsRequired,
List<String> globalSupportedSigningAlgorithms) { List<String> globalSupportedSigningAlgorithms) {
ProofTypesSupported proofTypesSupported = new ProofTypesSupported(); ProofTypesSupported proofTypesSupported = new ProofTypesSupported();
keycloakSession.getAllProviders(ProofValidator.class).forEach(proofValidator -> { keycloakSession.getAllProviders(ProofValidator.class).forEach(proofValidator -> {
String type = proofValidator.getProofType(); String type = proofValidator.getProofType();
KeyAttestationsRequired keyAttestationsRequired = new KeyAttestationsRequired();
SupportedProofTypeData supportedProofTypeData = new SupportedProofTypeData(globalSupportedSigningAlgorithms, SupportedProofTypeData supportedProofTypeData = new SupportedProofTypeData(globalSupportedSigningAlgorithms,
keyAttestationsRequired); keyAttestationsRequired);
proofTypesSupported.getSupportedProofTypes().put(type, supportedProofTypeData); proofTypesSupported.getSupportedProofTypes().put(type, supportedProofTypeData);
@@ -80,6 +80,11 @@ public class ProofTypesSupported {
} }
} }
@Override
public String toString() {
return toJsonString();
}
@Override @Override
public final boolean equals(Object o) { public final boolean equals(Object o) {
if (!(o instanceof ProofTypesSupported that)) { if (!(o instanceof ProofTypesSupported that)) {

View File

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

View File

@@ -5,6 +5,7 @@ import java.util.List;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import org.keycloak.OID4VCConstants;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException; import org.keycloak.common.VerificationException;
import org.keycloak.constants.OID4VCIConstants; 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.issuance.keybinding.ProofValidator;
import org.keycloak.protocol.oid4vc.model.CredentialRequest; import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse; 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.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.Proofs; import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
@@ -271,8 +271,8 @@ public class OID4VCAttestationProofTest extends OID4VCIssuerEndpointTest {
payload.setIat((long) TIME_PROVIDER.currentTimeSeconds()); payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
payload.setNonce(cNonce); payload.setNonce(cNonce);
payload.setAttestedKeys(List.of(proofJwk)); payload.setAttestedKeys(List.of(proofJwk));
payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.setKeyStorage(List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH));
payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.setUserAuthentication(List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH));
String attestationJwt = new JWSBuilder() String attestationJwt = new JWSBuilder()
.type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)

View File

@@ -28,6 +28,7 @@ import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.interfaces.RSAPublicKey; import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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.Response;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
@@ -217,21 +219,24 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
sdJwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName,
sdJwtCredentialVct, sdJwtCredentialVct,
Format.SD_JWT_VC, Format.SD_JWT_VC,
null); null,
List.of(KeyAttestationResistanceLevels.HIGH,
KeyAttestationResistanceLevels.MODERATE));
jwtTypeCredentialClientScope = registerOptionalClientScope(jwtTypeCredentialScopeName, jwtTypeCredentialClientScope = registerOptionalClientScope(jwtTypeCredentialScopeName,
TEST_DID.toString(), TEST_DID.toString(),
jwtTypeCredentialConfigurationIdName, jwtTypeCredentialConfigurationIdName,
jwtTypeCredentialScopeName, jwtTypeCredentialScopeName,
null, null,
Format.JWT_VC, Format.JWT_VC,
TEST_CREDENTIAL_MAPPERS_FILE); TEST_CREDENTIAL_MAPPERS_FILE,
Collections.emptyList());
minimalJwtTypeCredentialClientScope = registerOptionalClientScope("vc-with-minimal-config", 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 -> { List.of(client, namedClient).forEach(client -> {
String clientId = client.getClientId(); String clientId = client.getClientId();
@@ -267,7 +272,8 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
String credentialIdentifier, String credentialIdentifier,
String vct, String vct,
String format, String format,
String protocolMapperReferenceFile) { String protocolMapperReferenceFile,
List<String> acceptedKeyAttestationValues) {
// Check if the client scope already exists // Check if the client scope already exists
List<ClientScopeRepresentation> existingScopes = testRealm().clientScopes().findAll(); List<ClientScopeRepresentation> existingScopes = testRealm().clientScopes().findAll();
for (ClientScopeRepresentation existingScope : existingScopes) { for (ClientScopeRepresentation existingScope : existingScopes) {
@@ -305,6 +311,15 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
} }
addAttribute.accept(CredentialScopeModel.VC_DISPLAY, vcDisplay); 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); clientScope.setAttributes(attributes);
Response res = testRealm().clientScopes().create(clientScope); 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.CredentialResponseEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format; 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.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation;
@@ -537,9 +538,30 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
List<String> signingAlgsSupported = new ArrayList<>(supportedConfig.getCredentialSigningAlgValuesSupported()); List<String> signingAlgsSupported = new ArrayList<>(supportedConfig.getCredentialSigningAlgValuesSupported());
String proofTypesSupportedString = supportedConfig.getProofTypesSupported().toJsonString(); 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 { try {
withCausePropagation(() -> testingClient.server(TEST_REALM_NAME).run((session -> { 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, ProofTypesSupported expectedProofTypesSupported = ProofTypesSupported.parse(session,
keyAttestationsRequired,
List.of(Algorithm.RS256)); List.of(Algorithm.RS256));
assertEquals(expectedProofTypesSupported, assertEquals(expectedProofTypesSupported,
ProofTypesSupported.fromJsonString(proofTypesSupportedString)); ProofTypesSupported.fromJsonString(proofTypesSupportedString));

View File

@@ -27,6 +27,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.TrustManagerFactory;
import org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels;
import org.keycloak.common.util.CertificateUtils; import org.keycloak.common.util.CertificateUtils;
import org.keycloak.crypto.ECDSASignatureSignerContext; import org.keycloak.crypto.ECDSASignatureSignerContext;
import org.keycloak.crypto.KeyType; 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.JwtProofValidator;
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator; import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
import org.keycloak.protocol.oid4vc.issuance.keybinding.StaticAttestationKeyResolver; 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.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired; import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired;
import org.keycloak.protocol.oid4vc.model.Proofs; import org.keycloak.protocol.oid4vc.model.Proofs;
@@ -210,12 +210,12 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest {
payload.setNonce(cNonce); payload.setNonce(cNonce);
payload.setAttestedKeys(List.of(proofJwk)); payload.setAttestedKeys(List.of(proofJwk));
payload.setKeyStorage(List.of( payload.setKeyStorage(List.of(
ISO18045ResistanceLevel.HIGH.getValue(), KeyAttestationResistanceLevels.HIGH,
ISO18045ResistanceLevel.MODERATE.getValue() KeyAttestationResistanceLevels.MODERATE
)); ));
payload.setUserAuthentication(List.of( payload.setUserAuthentication(List.of(
ISO18045ResistanceLevel.ENHANCED_BASIC.getValue(), KeyAttestationResistanceLevels.ENHANCED_BASIC,
ISO18045ResistanceLevel.BASIC.getValue() KeyAttestationResistanceLevels.BASIC
)); ));
String attestationJwt = new JWSBuilder() String attestationJwt = new JWSBuilder()
@@ -228,13 +228,13 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest {
// Set attestation requirements // Set attestation requirements
KeyAttestationsRequired attestationRequirements = new KeyAttestationsRequired(); KeyAttestationsRequired attestationRequirements = new KeyAttestationsRequired();
attestationRequirements.setKeyStorage(List.of( attestationRequirements.setKeyStorage(List.of(
ISO18045ResistanceLevel.HIGH, KeyAttestationResistanceLevels.HIGH,
ISO18045ResistanceLevel.MODERATE, KeyAttestationResistanceLevels.MODERATE,
ISO18045ResistanceLevel.ENHANCED_BASIC KeyAttestationResistanceLevels.ENHANCED_BASIC
)); ));
attestationRequirements.setUserAuthentication(List.of( attestationRequirements.setUserAuthentication(List.of(
ISO18045ResistanceLevel.BASIC, KeyAttestationResistanceLevels.BASIC,
ISO18045ResistanceLevel.ENHANCED_BASIC KeyAttestationResistanceLevels.ENHANCED_BASIC
)); ));
vcIssuanceContext.getCredentialConfig() vcIssuanceContext.getCredentialConfig()
@@ -433,8 +433,8 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest {
payload.setIat((long) TIME_PROVIDER.currentTimeSeconds()); payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
payload.setNonce(cNonce); payload.setNonce(cNonce);
payload.setAttestedKeys(List.of(proofJwk1, proofJwk2)); payload.setAttestedKeys(List.of(proofJwk1, proofJwk2));
payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH));
payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.setUserAuthentication(List.of(KeyAttestationResistanceLevels.HIGH));
String attestationJwt = new JWSBuilder() String attestationJwt = new JWSBuilder()
.type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)
@@ -486,8 +486,8 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest {
payload.setNonce(cNonce); payload.setNonce(cNonce);
payload.setIat((long) TIME_PROVIDER.currentTimeSeconds()); payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
payload.setAttestedKeys(List.of(proofJwk)); payload.setAttestedKeys(List.of(proofJwk));
payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH));
payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.setUserAuthentication(List.of(KeyAttestationResistanceLevels.HIGH));
String attestationJwt = new JWSBuilder() String attestationJwt = new JWSBuilder()
.type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)
@@ -587,8 +587,9 @@ public class OID4VCKeyAttestationTest extends OID4VCIssuerEndpointTest {
validator.validateProof(context); validator.validateProof(context);
fail("Expected VCIssuerException for missing attested_keys"); fail("Expected VCIssuerException for missing attested_keys");
} catch (VCIssuerException e) { } catch (VCIssuerException e) {
assertTrue("Expected error about missing keys but got: " + e.getMessage(), assertEquals("Expected error about missing keys but got: " + e.getMessage(),
e.getMessage().contains("attested_keys")); "key_storage is required but was missing.",
e.getMessage());
} catch (Exception e) { } catch (Exception e) {
fail("Unexpected exception: " + e.getMessage()); 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.Response;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.OID4VCConstants.KeyAttestationResistanceLevels;
import org.keycloak.admin.client.resource.ClientScopeResource; import org.keycloak.admin.client.resource.ClientScopeResource;
import org.keycloak.admin.client.resource.ProtocolMappersResource; import org.keycloak.admin.client.resource.ProtocolMappersResource;
import org.keycloak.common.Profile; 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.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.Format; 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.KeyAttestationJwtBody;
import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired;
import org.keycloak.protocol.oid4vc.model.NonceResponse; import org.keycloak.protocol.oid4vc.model.NonceResponse;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
@@ -652,8 +653,8 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
payload.setIat((long) TIME_PROVIDER.currentTimeSeconds()); payload.setIat((long) TIME_PROVIDER.currentTimeSeconds());
payload.setNonce(cNonce); payload.setNonce(cNonce);
payload.setAttestedKeys(proofJwks); payload.setAttestedKeys(proofJwks);
payload.setKeyStorage(List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH));
payload.setUserAuthentication(List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.setUserAuthentication(List.of(KeyAttestationResistanceLevels.HIGH));
return new JWSBuilder() return new JWSBuilder()
.type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP)
@@ -667,11 +668,14 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
protected static VCIssuanceContext createVCIssuanceContext(KeycloakSession session) { protected static VCIssuanceContext createVCIssuanceContext(KeycloakSession session) {
VCIssuanceContext context = new VCIssuanceContext(); VCIssuanceContext context = new VCIssuanceContext();
KeyAttestationsRequired keyAttestationsRequired = new KeyAttestationsRequired();
keyAttestationsRequired.setKeyStorage(List.of(KeyAttestationResistanceLevels.HIGH,
KeyAttestationResistanceLevels.MODERATE));
SupportedCredentialConfiguration config = new SupportedCredentialConfiguration() SupportedCredentialConfiguration config = new SupportedCredentialConfiguration()
.setFormat(Format.SD_JWT_VC) .setFormat(Format.SD_JWT_VC)
.setVct("https://credentials.example.com/test-credential") .setVct("https://credentials.example.com/test-credential")
.setCryptographicBindingMethodsSupported(List.of("jwk")) .setCryptographicBindingMethodsSupported(List.of("jwk"))
.setProofTypesSupported(ProofTypesSupported.parse(session, List.of("ES256"))); .setProofTypesSupported(ProofTypesSupported.parse(session, keyAttestationsRequired, List.of("ES256")));
context.setCredentialConfig(config) context.setCredentialConfig(config)
.setCredentialRequest(new CredentialRequest()); .setCredentialRequest(new CredentialRequest());
@@ -718,8 +722,8 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
Map<String, Object> payload = new HashMap<>(); Map<String, Object> payload = new HashMap<>();
payload.put("iat", TIME_PROVIDER.currentTimeSeconds()); payload.put("iat", TIME_PROVIDER.currentTimeSeconds());
payload.put("attested_keys", List.of(proofJwk)); payload.put("attested_keys", List.of(proofJwk));
payload.put("key_storage", List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.put("key_storage", List.of(KeyAttestationResistanceLevels.HIGH));
payload.put("user_authentication", List.of(ISO18045ResistanceLevel.HIGH.getValue())); payload.put("user_authentication", List.of(KeyAttestationResistanceLevels.HIGH));
payload.put("nonce", cNonce); payload.put("nonce", cNonce);
return payload; return payload;