[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

@@ -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;
@@ -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<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,10 +538,31 @@ 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,
List.of(Algorithm.RS256));
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;