diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtCredentialSigner.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtCredentialSigner.java index fc820700f0c..c74aa3ccd4b 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtCredentialSigner.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtCredentialSigner.java @@ -78,12 +78,43 @@ public class SdJwtCredentialSigner extends AbstractCredentialSigner { * @param sdJwtCredentialBody The SD-JWT credential body to add x5c to * @param signer The signer context containing the certificate(s) for the signing key */ - private void addX5cHeader(SdJwtCredentialBody sdJwtCredentialBody, SignatureSignerContext signer) { + private void addX5cHeader(SdJwtCredentialBody sdJwtCredentialBody, SignatureSignerContext signer) throws CredentialSignerException { List certificateChain = signer.getCertificateChain(); if (certificateChain != null && !certificateChain.isEmpty()) { - List x5cList = certificateChain.stream() + // Copy and remove any trailing self-signed certificate(s) (trust anchors) to satisfy HAIP-6.1.1, + // which requires that the trust anchor is NOT included in the x5c chain. + List filteredChain = certificateChain.stream() .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // Per HAIP-6.1.1: "The X.509 certificate signing the request MUST NOT be self-signed." + // Check if the first certificate (signing certificate) is self-signed + if (!filteredChain.isEmpty()) { + X509Certificate signingCert = filteredChain.get(0); + if (signingCert.getSubjectX500Principal().equals(signingCert.getIssuerX500Principal())) { + throw new CredentialSignerException("HAIP-6.1.1 violation: signing certificate MUST NOT be self-signed."); + } + } + + // Remove trailing self-signed certificates (trust anchors) from the chain + // Per HAIP-6.1.1: "The X.509 certificate of the trust anchor MUST NOT be included in the x5c JOSE header" + while (!filteredChain.isEmpty()) { + X509Certificate last = filteredChain.get(filteredChain.size() - 1); + if (last.getSubjectX500Principal().equals(last.getIssuerX500Principal())) { + // Last certificate is self-signed (trust anchor) -> drop it from x5c + filteredChain.remove(filteredChain.size() - 1); + } else { + break; + } + } + + // If all certificates were self-signed (trust anchors), issuance is not HAIP-compliant + if (filteredChain.isEmpty()) { + throw new CredentialSignerException("HAIP-6.1.1 violation: x5c chain is empty after removing trust anchor certificates."); + } + + List x5cList = filteredChain.stream() .map(cert -> { try { return Base64.getEncoder().encodeToString(cert.getEncoded()); @@ -96,10 +127,10 @@ public class SdJwtCredentialSigner extends AbstractCredentialSigner { if (!x5cList.isEmpty()) { sdJwtCredentialBody.getIssuerSignedJWT().getJwsHeader().setX5c(x5cList); } else { - LOGGER.debugf("No valid certificates found in certificate chain for x5c header in SD-JWT credential."); + throw new CredentialSignerException("HAIP-6.1.1 violation: no valid certificates available for x5c header."); } } else { - LOGGER.debugf("No certificate or certificate chain available for x5c header in SD-JWT credential."); + throw new CredentialSignerException("HAIP-6.1.1 violation: no certificate chain available for SD-JWT x5c header."); } } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java index f775f9feeec..c9af969aece 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java @@ -9,6 +9,7 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.cert.Certificate; +import java.security.cert.X509Certificate; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; @@ -250,6 +251,7 @@ public abstract class OID4VCIssuerTestBase { oauth.client(client.getClientId(), client.getSecret()); enableVerifiableCredentialEvents(); + ensureHaipCompliantSdJwtSigningConfiguration(); wallet = new OID4VCBasicWallet(keycloak, oauth); } @@ -464,6 +466,20 @@ public abstract class OID4VCIssuerTestBase { clientScopeResource.update(clientScope); } + /** + * Persistently configure SD-JWT scopes to use a dedicated signing key with a + * non-self-signed leaf certificate, so HAIP-6.1.1 is satisfied across requests. + */ + protected void ensureHaipCompliantSdJwtSigningConfiguration() { + KeyWrapper signingKey = getRsaKey(KeyUse.SIG, Algorithm.RS256, "haip-sdjwt-signing-key"); + ComponentRepresentation provider = createRsaKeyProviderComponentWithNonSelfSignedLeaf( + signingKey, + "haip-sdjwt-signing-key-provider", + 200 + ); + testRealm.admin().components().add(provider).close(); + } + protected ClientPolicyRepresentation getClientPolicy(String policyName) { ClientPoliciesPoliciesResource clientPoliciesResource = testRealm.admin().clientPoliciesPoliciesResource(); ClientPoliciesRepresentation policies = clientPoliciesResource.getPolicies(); @@ -508,6 +524,45 @@ public abstract class OID4VCIssuerTestBase { return component; } + private ComponentRepresentation createRsaKeyProviderComponentWithNonSelfSignedLeaf(KeyWrapper keyWrapper, String name, int priority) { + ComponentRepresentation component = new ComponentRepresentation(); + component.setProviderType(KeyProvider.class.getName()); + component.setName(name); + component.setId(UUID.randomUUID().toString()); + component.setProviderId("rsa"); + + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair caKeyPair = kpg.generateKeyPair(); + X509Certificate caCert = CertificateUtils.generateV1SelfSignedCertificate(caKeyPair, "Test CA"); + + KeyPair leafKeyPair = new KeyPair( + (PublicKey) keyWrapper.getPublicKey(), + (PrivateKey) keyWrapper.getPrivateKey() + ); + X509Certificate leafCert = CertificateUtils.generateV3Certificate( + leafKeyPair, + caKeyPair.getPrivate(), + caCert, + "TestKey" + ); + + component.setConfig(new MultivaluedHashMap<>(Map.of( + "privateKey", List.of(PemUtils.encodeKey(keyWrapper.getPrivateKey())), + "certificate", List.of(PemUtils.encodeCertificate(leafCert)), + "active", List.of("true"), + "priority", List.of(String.valueOf(priority)), + "enabled", List.of("true"), + "algorithm", List.of(keyWrapper.getAlgorithm()), + "keyUse", List.of(keyWrapper.getUse().name()) + ))); + return component; + } catch (Exception e) { + throw new RuntimeException("Failed to create HAIP-compliant SD-JWT signing key provider", e); + } + } + private void enableVerifiableCredentialEvents() { RealmEventsConfigRepresentation realmEventsConfig = testRealm.admin().getRealmEventsConfig(); List enabledEventTypes = realmEventsConfig.getEnabledEventTypes(); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/CredentialBuilderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/CredentialBuilderTest.java index 13b646c84a5..b68f89774ac 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/CredentialBuilderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/CredentialBuilderTest.java @@ -22,25 +22,34 @@ import org.keycloak.crypto.AsymmetricSignatureVerifierContext; import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.crypto.SignatureVerifierContext; -import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; +import org.keycloak.tests.oid4vc.issuance.signing.OID4VCTest; -/** - * @author Ingrid Kamga - */ -public abstract class CredentialBuilderTest extends OID4VCIssuerTestBase { +public abstract class CredentialBuilderTest extends OID4VCTest { - private final KeyWrapper keyWrapper; + private static final KeyWrapper KEY_WRAPPER = createRsaKey(); - CredentialBuilderTest() { - keyWrapper = getRsaKey_Default(); + protected static SignatureSignerContext exampleSigner() { + return new AsymmetricSignatureSignerContext(KEY_WRAPPER); } - protected SignatureSignerContext exampleSigner() { - return new AsymmetricSignatureSignerContext(keyWrapper); + protected static SignatureVerifierContext exampleVerifier() { + return new AsymmetricSignatureVerifierContext(KEY_WRAPPER); } - protected SignatureVerifierContext exampleVerifier() { - return new AsymmetricSignatureVerifierContext(keyWrapper); + private static KeyWrapper createRsaKey() { + try { + var kpg = java.security.KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + var kp = kpg.generateKeyPair(); + KeyWrapper kw = new KeyWrapper(); + kw.setPrivateKey(kp.getPrivate()); + kw.setPublicKey(kp.getPublic()); + kw.setKid(java.util.UUID.randomUUID().toString()); + kw.setType("RSA"); + kw.setAlgorithm("RS256"); + return kw; + } catch (Exception e) { + throw new RuntimeException(e); + } } - } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java index 31b3ca61ca5..e8da9dc2eac 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java @@ -55,16 +55,13 @@ public class JwtCredentialBuilderTest extends CredentialBuilderTest { VerifiableCredential verifiableCredential = getTestCredential(exampleCredentialClaims()); CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig().setTokenJwsType("JWT"); - // Build JwtCredentialBody jwtCredentialBody = builder .buildCredentialBody(verifiableCredential, credentialBuildConfig); - // Sign and parse JWS string String jws = jwtCredentialBody.sign(exampleSigner()); JWSInput jwsInput = new JWSInput(jws); JsonNode credentialSubject = parseCredentialSubject(jwsInput); - // Assert assertEquals("JWT", jwsInput.getHeader().getType()); assertEquals(10, credentialSubject.get("issuanceDate").asInt()); assertEquals("randomValue", credentialSubject.get("randomKey").asText()); @@ -77,16 +74,13 @@ public class JwtCredentialBuilderTest extends CredentialBuilderTest { VerifiableCredential verifiableCredential = getTestCredential(exampleCredentialClaimsWithoutIssuanceDate()); CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig().setTokenJwsType("JWT"); - // Build JwtCredentialBody jwtCredentialBody = builderWithoutSession .buildCredentialBody(verifiableCredential, credentialBuildConfig); - // Sign and parse JWS string String jws = jwtCredentialBody.sign(exampleSigner()); JWSInput jwsInput = new JWSInput(jws); JsonNode payload = jwsInput.readJsonContent(JsonNode.class); - // Assert that nbf is set to the normalized (minute-truncated) current time when no issuance date is supplied long expectedNormalizedNbf = Instant.ofEpochSecond(timeProvider.currentTimeSeconds()) .truncatedTo(ChronoUnit.MINUTES) .getEpochSecond(); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java index 8623d7ac403..ec25f47365f 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java @@ -133,22 +133,21 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest { List disclosed = sdJwt.getDisclosures().values().stream().toList(); assertEquals(disclosed.size() + (decoys == 0 ? SdJwt.DEFAULT_NUMBER_OF_DECOYS : decoys), - sdArrayNode == null ? 0 : sdArrayNode.size(), - "All undisclosed claims and decoys should be provided."); + sdArrayNode == null ? 0 : sdArrayNode.size(), + "All undisclosed claims and decoys should be provided."); visibleClaims.forEach(vc -> assertTrue(jwt.getPayload().has(vc), "The visible claims should be present within the token.") ); - // Will check disclosure conformity sdJwt.getSdJwtVerificationContext() - .verifyIssuance(List.of(exampleVerifier()), - IssuerSignedJwtVerificationOpts.builder() - .withIatCheck(true) - .withNbfCheck(true) - .withExpCheck(true) - .build(), - null); + .verifyIssuance(List.of(exampleVerifier()), + IssuerSignedJwtVerificationOpts.builder() + .withIatCheck(true) + .withNbfCheck(true) + .withExpCheck(true) + .build(), + null); } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/JwtCredentialSignerTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/JwtCredentialSignerTest.java index 49c8ef6ebbf..98fc03cea73 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/JwtCredentialSignerTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/JwtCredentialSignerTest.java @@ -25,9 +25,7 @@ import java.util.Optional; import java.util.UUID; import org.keycloak.TokenVerifier; -import org.keycloak.admin.client.resource.ComponentsResource; import org.keycloak.common.VerificationException; -import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.AsymmetricSignatureVerifierContext; import org.keycloak.crypto.KeyWrapper; @@ -44,13 +42,11 @@ import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; -import org.keycloak.testframework.annotations.TestSetup; import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; import org.keycloak.testframework.remote.runonserver.RunOnServerClient; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; import org.keycloak.util.JsonSerialization; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -58,124 +54,68 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; - @KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class) -public class JwtCredentialSignerTest extends OID4VCIssuerTestBase { +public class JwtCredentialSignerTest extends OID4VCTest { @InjectRunOnServer - RunOnServerClient runOnServer; - - @BeforeEach - public void setup() { - CryptoIntegration.init(this.getClass().getClassLoader()); - } - - @TestSetup - public void configureTestRealm() { - super.configureTestRealm(); - ComponentsResource components = testRealm.admin().components(); - components.add(getRsaKeyProvider(getRsaKey_Default())).close(); - } - + private RunOnServerClient runOnServer; @Test public void testUnsupportedCredentialBody() throws Throwable { - runOnServer.run(session -> { - assertThrows( - CredentialSignerException.class, - () -> { - new JwtCredentialSigner(session).signCredential( - new LDCredentialBody(getTestCredential(Map.of())), - new CredentialBuildConfig() - ); - } - ); - } - ); + runOnServer.run(session -> assertThrows( + CredentialSignerException.class, + () -> new JwtCredentialSigner(session).signCredential( + new LDCredentialBody(getTestCredential(Map.of())), + new CredentialBuildConfig()))); } - // If an unsupported algorithm is provided, signing should reliably fail. @Test public void testUnsupportedAlgorithm() throws Throwable { - runOnServer.run(session -> { - assertThrows( - CredentialSignerException.class, - () -> { - testSignJwtCredential( - session, - getKeyIdFromSession(session), - "unsupported-algorithm", - Map.of() - ); - } - ); - } - ); + runOnServer.run(session -> assertThrows( + CredentialSignerException.class, + () -> testSignJwtCredential(session, getKeyIdFromSession(session), "unsupported-algorithm", Map.of()))); } - // If an unknown key is provided, signing should reliably fail. @Test public void testFailIfNoKey() throws Throwable { - runOnServer.run(session -> { - assertThrows( - CredentialSignerException.class, - () -> { - testSignJwtCredential( - session, - "no-such-key", - Algorithm.RS256, - Map.of() - ); - } - ); - } - ); + runOnServer.run(session -> assertThrows( + CredentialSignerException.class, + () -> testSignJwtCredential(session, "no-such-key", Algorithm.RS256, Map.of()))); } - // The provided credentials should be successfully signed as a JWT-VC. @Test - public void testRsaSignedCredentialWithOutIssuanceDate() throws Exception { - runOnServer.run(session -> { - testSignJwtCredential( - session, - getKeyIdFromSession(session), - Algorithm.RS256, - Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), - "test", "test", - "arrayClaim", List.of("a", "b", "c")) - ); - - } - ); + public void testRsaSignedCredentialWithOutIssuanceDate() { + runOnServer.run(session -> + testSignJwtCredential( + session, + getKeyIdFromSession(session), + Algorithm.RS256, + Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), + "test", "test", + "arrayClaim", List.of("a", "b", "c")))); } @Test public void testRsaSignedCredentialWithIssuanceDate() { - runOnServer.run(session -> { - testSignJwtCredential( - session, - getKeyIdFromSession(session), - Algorithm.RS256, - Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), - "test", "test", - "arrayClaim", List.of("a", "b", "c"), - "issuanceDate", Instant.ofEpochSecond(10)) - ); - } - ); + runOnServer.run(session -> + testSignJwtCredential( + session, + getKeyIdFromSession(session), + Algorithm.RS256, + Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), + "test", "test", + "arrayClaim", List.of("a", "b", "c"), + "issuanceDate", Instant.ofEpochSecond(10)))); } @Test public void testRsaSignedCredentialWithoutAdditionalClaims() { - runOnServer.run(session -> { - testSignJwtCredential( - session, - getKeyIdFromSession(session), - Algorithm.RS256, - Map.of() - ); - } - ); + runOnServer.run(session -> + testSignJwtCredential( + session, + getKeyIdFromSession(session), + Algorithm.RS256, + Map.of())); } public static void testSignJwtCredential( @@ -190,19 +130,15 @@ public class JwtCredentialSignerTest extends OID4VCIssuerTestBase { VerifiableCredential testCredential = getTestCredential(claims); JwtCredentialBuilder builder = new JwtCredentialBuilder( - new StaticTimeProvider(1000), + new OID4VCIssuerTestBase.StaticTimeProvider(1000), session ); - CredentialBody credentialBody = builder.buildCredentialBody( - testCredential, - credentialBuildConfig - ); - + CredentialBody credentialBody = builder.buildCredentialBody(testCredential, credentialBuildConfig); String jwtCredential = jwtCredentialSigner.signCredential(credentialBody, credentialBuildConfig); KeyWrapper keyWrapper = getKeyFromSession(session); - SignatureVerifierContext verifierContext = null; + SignatureVerifierContext verifierContext; switch (algorithm) { case Algorithm.ES256: { verifierContext = new ServerECDSASignatureVerifierContext(keyWrapper); @@ -212,9 +148,8 @@ public class JwtCredentialSignerTest extends OID4VCIssuerTestBase { verifierContext = new AsymmetricSignatureVerifierContext(keyWrapper); break; } - default: { - fail("Algorithm not supported."); - } + default: + throw new AssertionError("Algorithm not supported."); } TokenVerifier verifier = TokenVerifier @@ -230,16 +165,20 @@ public class JwtCredentialSignerTest extends OID4VCIssuerTestBase { try { JsonWebToken theToken = verifier.getToken(); - assertEquals(TEST_EXPIRATION_DATE.getEpochSecond(), theToken.getExp().longValue(), "JWT claim in JWT encoded VC or VP MUST be used to set the value of the “expirationDate” of the VC"); + assertEquals(TEST_EXPIRATION_DATE.getEpochSecond(), theToken.getExp().longValue(), + "JWT claim in JWT encoded VC or VP MUST be used to set the value of the expirationDate of the VC"); if (claims.containsKey("issuanceDate")) { - assertEquals(((Instant) claims.get("issuanceDate")).getEpochSecond(), theToken.getNbf().longValue(), "VC Data Model v1.1 specifies that “issuanceDate” property MUST be represented as an nbf JWT claim, and not iat JWT claim."); + assertEquals(((Instant) claims.get("issuanceDate")).getEpochSecond(), theToken.getNbf().longValue(), + "VC Data Model v1.1 specifies that issuanceDate property MUST be represented as nbf JWT claim, and not iat JWT claim."); } else { - // if not specific date is set, check against "currentTime" - assertEquals(TEST_ISSUANCE_DATE.getEpochSecond(), theToken.getNbf().longValue(), "VC Data Model v1.1 specifies that “issuanceDate” property MUST be represented as an nbf JWT claim, and not iat JWT claim."); + assertEquals(TEST_ISSUANCE_DATE.getEpochSecond(), theToken.getNbf().longValue(), + "VC Data Model v1.1 specifies that issuanceDate property MUST be represented as nbf JWT claim, and not iat JWT claim."); } assertEquals(TEST_ISSUER_DID, theToken.getIssuer(), "The issuer should be set in the token."); assertEquals(testCredential.getId().toString(), theToken.getId(), "The credential ID should be set as the token ID."); - Optional.ofNullable(testCredential.getCredentialSubject().getClaims().get("id")).ifPresent(id -> assertEquals(id.toString(), theToken.getSubject(), "If the credentials subject id is set, it should be set as the token subject.")); + Optional.ofNullable(testCredential.getCredentialSubject().getClaims().get("id")) + .ifPresent(id -> assertEquals(id.toString(), theToken.getSubject(), + "If the credentials subject id is set, it should be set as the token subject.")); assertNotNull(theToken.getOtherClaims().get("vc"), "The credentials should be included at the vc-claim."); VerifiableCredential credential = JsonSerialization.mapper.convertValue(theToken.getOtherClaims().get("vc"), VerifiableCredential.class); @@ -253,10 +192,10 @@ public class JwtCredentialSignerTest extends OID4VCIssuerTestBase { CredentialSubject subject = credential.getCredentialSubject(); claims.entrySet().stream() .filter(e -> !e.getKey().equals("issuanceDate")) - .forEach(e -> assertEquals(e.getValue(), subject.getClaims().get(e.getKey()), String.format("All additional claims should be set - %s is incorrect", e.getKey()))); + .forEach(e -> assertEquals(e.getValue(), subject.getClaims().get(e.getKey()), + String.format("All additional claims should be set - %s is incorrect", e.getKey()))); } catch (VerificationException e) { fail("Was not able to get the token from the verifier."); } } - } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCTest.java new file mode 100644 index 00000000000..960a05bc524 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCTest.java @@ -0,0 +1,45 @@ +package org.keycloak.tests.oid4vc.issuance.signing; + +import java.net.URI; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.keycloak.protocol.oid4vc.model.CredentialSubject; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.tests.oid4vc.OID4VCIssuerEndpointTest; + +/** + * New-testsuite local utility base for SD-JWT signing/builder tests. + */ +public abstract class OID4VCTest extends OID4VCIssuerEndpointTest { + + protected static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; + protected static final URI TEST_DID = URI.create("did:web:test.org"); + protected static final List TEST_TYPES = List.of("VerifiableCredential"); + protected static final Instant TEST_EXPIRATION_DATE = Instant.now().plus(365, ChronoUnit.DAYS).truncatedTo(ChronoUnit.SECONDS); + + protected static CredentialSubject getCredentialSubject(Map claims) { + CredentialSubject credentialSubject = new CredentialSubject(); + claims.forEach(credentialSubject::setClaims); + return credentialSubject; + } + + protected static VerifiableCredential getTestCredential(Map claims) { + VerifiableCredential credential = new VerifiableCredential(); + credential.setId(URI.create(String.format("uri:uuid:%s", UUID.randomUUID()))); + credential.setContext(List.of(CONTEXT_URL)); + credential.setType(TEST_TYPES); + credential.setIssuer(TEST_DID); + credential.setExpirationDate(TEST_EXPIRATION_DATE); + Optional.ofNullable(claims.get("issuanceDate")) + .filter(Instant.class::isInstance) + .map(Instant.class::cast) + .ifPresent(credential::setIssuanceDate); + credential.setCredentialSubject(getCredentialSubject(claims)); + return credential; + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java index 0db1347a142..f62a44e87c3 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java @@ -17,8 +17,13 @@ package org.keycloak.tests.oid4vc.issuance.signing; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; import java.security.PublicKey; import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Base64; import java.util.List; @@ -29,6 +34,7 @@ import java.util.UUID; import org.keycloak.OID4VCConstants; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; +import org.keycloak.common.util.CertificateUtils; import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.AsymmetricSignatureVerifierContext; import org.keycloak.crypto.KeyWrapper; @@ -47,6 +53,7 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; import org.keycloak.sdjwt.SdJwt; import org.keycloak.sdjwt.SdJwtUtils; +import org.keycloak.sdjwt.vp.SdJwtVP; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; import org.keycloak.testframework.remote.runonserver.RunOnServerClient; @@ -68,157 +75,185 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class) -public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { +public class SdJwtCredentialSignerTest extends OID4VCTest { @InjectRunOnServer - RunOnServerClient runOnServer; + private RunOnServerClient runOnServer; @Test public void testUnsupportedCredentialBody() throws Throwable { - runOnServer.run(session -> { - assertThrows( - CredentialSignerException.class, - () -> { - new SdJwtCredentialSigner(session).signCredential( - new LDCredentialBody(getTestCredential(Map.of())), - new CredentialBuildConfig() - ); - } - ); - } - ); + runOnServer.run(session -> assertThrows( + CredentialSignerException.class, + () -> new SdJwtCredentialSigner(session).signCredential( + new LDCredentialBody(getTestCredential(Map.of())), + new CredentialBuildConfig()))); } - // If an unsupported algorithm is provided, signing should reliably fail. @Test public void testUnsupportedAlgorithm() throws Throwable { - runOnServer.run(session -> { - assertThrows( - CredentialSignerException.class, - () -> { - testSignSDJwtCredential( - session, - getKeyIdFromSession(session), - null, - "unsupported-algorithm", - Map.of(), - 0, - List.of()); - } - ); - } - ); + runOnServer.run(session -> assertThrows( + CredentialSignerException.class, + () -> testSignSDJwtCredential( + session, + getKeyIdFromSession(session), + null, + "unsupported-algorithm", + Map.of(), + 0, + List.of()))); } - // If an unknown key is provided, signing should reliably fail. @Test public void testFailIfNoKey() throws Throwable { - runOnServer.run(session -> { - assertThrows( - CredentialSignerException.class, - () -> { - testSignSDJwtCredential( - session, - "no-such-key", - null, - Algorithm.RS256, - Map.of(), - 0, - List.of()); - } - ); - } - ); + runOnServer.run(session -> assertThrows( + CredentialSignerException.class, + () -> testSignSDJwtCredential( + session, + "no-such-key", + null, + Algorithm.RS256, + Map.of(), + 0, + List.of()))); + } + + @Test + public void testFailWhenSigningCertificateIsSelfSigned() throws Throwable { + runOnServer.run(session -> assertThrows(CredentialSignerException.class, () -> { + KeyWrapper keyWrapper = getKeyFromSession(session); + X509Certificate selfSigned = CertificateUtils.generateV1SelfSignedCertificate( + new KeyPair((PublicKey) keyWrapper.getPublicKey(), (PrivateKey) keyWrapper.getPrivateKey()), + "SelfSignedSigningCert"); + keyWrapper.setCertificate(selfSigned); + keyWrapper.setCertificateChain(List.of(selfSigned)); + + CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig() + .setCredentialIssuer(TEST_DID.toString()) + .setCredentialType("https://credentials.example.com/test-credential") + .setTokenJwsType("example+sd-jwt") + .setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM) + .setNumberOfDecoys(0) + .setSdJwtVisibleClaims(List.of()) + .setSigningKeyId(keyWrapper.getKid()) + .setSigningAlgorithm(Algorithm.RS256); + + SdJwtCredentialSigner signer = new SdJwtCredentialSigner(session); + SdJwtCredentialBody body = new SdJwtCredentialBuilder() + .buildCredentialBody(getTestCredential(Map.of()), credentialBuildConfig); + signer.signCredential(body, credentialBuildConfig); + })); } @Test public void testRsaSignedCredentialWithClaims() { - runOnServer.run(session -> { - testSignSDJwtCredential( - session, - getKeyIdFromSession(session), - null, - Algorithm.RS256, - Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), - "test", "test", - "arrayClaim", List.of("a", "b", "c")), - 0, - List.of()); - } - ); + runOnServer.run(session -> + testSignSDJwtCredential( + session, + getKeyIdFromSession(session), + null, + Algorithm.RS256, + Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), + "test", "test", + "arrayClaim", List.of("a", "b", "c")), + 0, + List.of())); } @Test public void testRsaSignedCredentialWithVisibleClaims() { - runOnServer.run(session -> { - testSignSDJwtCredential( - session, - getKeyIdFromSession(session), - null, - Algorithm.RS256, - Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), - "test", "test", - "arrayClaim", List.of("a", "b", "c")), - 0, - List.of("test")); - } - ); + runOnServer.run(session -> + testSignSDJwtCredential( + session, + getKeyIdFromSession(session), + null, + Algorithm.RS256, + Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), + "test", "test", + "arrayClaim", List.of("a", "b", "c")), + 0, + List.of("test"))); } - @Test public void testRsaSignedCredentialWithClaimsAndDecoys() { - runOnServer.run(session -> { - testSignSDJwtCredential( - session, - getKeyIdFromSession(session), - null, - Algorithm.RS256, - Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), - "test", "test", - "arrayClaim", List.of("a", "b", "c")), - 6, - List.of()); - } - ); + runOnServer.run(session -> + testSignSDJwtCredential( + session, + getKeyIdFromSession(session), + null, + Algorithm.RS256, + Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), + "test", "test", + "arrayClaim", List.of("a", "b", "c")), + 6, + List.of())); } @Test public void testRsaSignedCredentialWithKeyId() { - runOnServer.run(session -> { - testSignSDJwtCredential( - session, - getKeyIdFromSession(session), - "did:web:test.org#key-id", - Algorithm.RS256, - Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), - "test", "test", - "arrayClaim", List.of("a", "b", "c")), - 0, - List.of()); - } - ); + runOnServer.run(session -> + testSignSDJwtCredential( + session, + getKeyIdFromSession(session), + "did:web:test.org#key-id", + Algorithm.RS256, + Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()), + "test", "test", + "arrayClaim", List.of("a", "b", "c")), + 0, + List.of())); } @Test public void testRsaSignedCredentialWithoutAdditionalClaims() { + runOnServer.run(session -> + testSignSDJwtCredential( + session, + getKeyIdFromSession(session), + null, + Algorithm.RS256, + Map.of(), + 0, + List.of())); + } + + @Test + public void testIssuedSdJwtContainsX5cHeader() throws Exception { runOnServer.run(session -> { - testSignSDJwtCredential( - session, - getKeyIdFromSession(session), - null, - Algorithm.RS256, - Map.of(), - 0, - List.of()); - } - ); + String sdJwtString = issueSignedSdJwtForHeaderValidation(session); + SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtString); + JsonWebToken jwt = TokenVerifier.create(sdJwtVP.getIssuerSignedJWT().getJws(), JsonWebToken.class).getToken(); + + assertNotNull(jwt, "Issued SD-JWT should be parseable"); + assertNotNull(sdJwtVP.getIssuerSignedJWT().getJwsHeader().getX5c(), "x5c must be present"); + assertFalse(sdJwtVP.getIssuerSignedJWT().getJwsHeader().getX5c().isEmpty(), "x5c must not be empty"); + }); + } + + @Test + public void testIssuedSdJwtX5cDoesNotContainSelfSignedTrustAnchor() throws Exception { + runOnServer.run(session -> { + String sdJwtString = issueSignedSdJwtForHeaderValidation(session); + SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtString); + List x5c = sdJwtVP.getIssuerSignedJWT().getJwsHeader().getX5c(); + assertNotNull(x5c, "x5c must be present"); + assertFalse(x5c.isEmpty(), "x5c must not be empty"); + + for (String certDerB64 : x5c) { + X509Certificate cert = decodeDerBase64Certificate(certDerB64); + assertTrue(!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal()), + "x5c must not contain self-signed trust anchor certificate"); + } + }); } @Test public void testSdJwtCredentialContainsX5cHeader() { runOnServer.run(session -> { - String signingKeyId = getKeyIdFromSession(session); + KeyWrapper keyWrapper = getKeyFromSession(session); + ensureHaipCompliantCertificateChain(keyWrapper); + + String signingKeyId = keyWrapper.getKid(); CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig() .setCredentialIssuer(TEST_ISSUER_DID) .setCredentialType("https://credentials.example.com/test-credential") @@ -246,7 +281,6 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { .add(splittedToken[2]) .toString(); - KeyWrapper keyWrapper = getKeyFromSession(session); SignatureVerifierContext verifierContext = new AsymmetricSignatureVerifierContext(keyWrapper); TokenVerifier verifier = TokenVerifier @@ -264,7 +298,8 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { if (keyWrapper.getCertificate() != null) { try { String expectedCert = Base64.getEncoder().encodeToString(keyWrapper.getCertificate().getEncoded()); - assertEquals(expectedCert, header.getX5c().get(0), "First certificate in x5c should match the signing key certificate"); + assertEquals(expectedCert, header.getX5c().get(0), + "First certificate in x5c should match the signing key certificate"); } catch (CertificateEncodingException e) { fail("Failed to encode certificate for comparison: " + e.getMessage()); } @@ -275,8 +310,30 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { }); } + private static String issueSignedSdJwtForHeaderValidation(KeycloakSession session) { + KeyWrapper keyWrapper = getKeyFromSession(session); + ensureHaipCompliantCertificateChain(keyWrapper); + + CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig() + .setCredentialIssuer(TEST_DID.toString()) + .setCredentialType("https://credentials.example.com/test-credential") + .setTokenJwsType("example+sd-jwt") + .setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM) + .setNumberOfDecoys(0) + .setSdJwtVisibleClaims(List.of()) + .setSigningKeyId(keyWrapper.getKid()) + .setSigningAlgorithm(Algorithm.RS256); + + VerifiableCredential testCredential = getTestCredential(Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()))); + SdJwtCredentialBody credentialBody = new SdJwtCredentialBuilder().buildCredentialBody(testCredential, credentialBuildConfig); + return new SdJwtCredentialSigner(session).signCredential(credentialBody, credentialBuildConfig); + } + public static void testSignSDJwtCredential(KeycloakSession session, String signingKeyId, String overrideKeyId, String algorithm, Map claims, int decoys, List visibleClaims) { + KeyWrapper keyWrapper = getKeyFromSession(session); + ensureHaipCompliantCertificateChain(keyWrapper); + CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig() .setCredentialIssuer(TEST_ISSUER_DID) .setCredentialType("https://credentials.example.com/test-credential") @@ -296,8 +353,7 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { String sdJwt = sdJwtCredentialSigner.signCredential(sdJwtCredentialBody, credentialBuildConfig); - KeyWrapper keyWrapper = getKeyFromSession(session); - SignatureVerifierContext verifierContext = null; + SignatureVerifierContext verifierContext; switch (algorithm) { case Algorithm.ES256: { verifierContext = new ServerECDSASignatureVerifierContext(keyWrapper); @@ -307,22 +363,15 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { verifierContext = new AsymmetricSignatureVerifierContext(keyWrapper); break; } - default: { - fail("Algorithm not supported."); - } + default: + throw new AssertionError("Algorithm not supported."); } - // the sd-jwt is dot-concatenated header.payload.signature~disclosure1~___~disclosureN - // we first split the disclosuers String[] splittedSdToken = sdJwt.split(SDJWT_DELIMITER); - // and then split the actual token part String[] splittedToken = splittedSdToken[0].split("\\."); String jwt = new StringJoiner(".") - // header .add(splittedToken[0]) - // payload .add(splittedToken[1]) - // signature .add(splittedToken[2]) .toString(); TokenVerifier verifier = TokenVerifier @@ -346,10 +395,9 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { List disclosed = Arrays.asList(splittedSdToken).subList(1, splittedSdToken.length); int numSds = sds != null ? sds.size() : 0; assertEquals(disclosed.size() + (decoys == 0 ? decoys + SdJwt.DEFAULT_NUMBER_OF_DECOYS : decoys), - numSds, - "All undisclosed claims and decoys should be provided."); + numSds, + "All undisclosed claims and decoys should be provided."); verifyDisclosures(sds, disclosed); - visibleClaims .forEach(vc -> assertTrue(theToken.getOtherClaims().containsKey(vc), "The visible claims should be present within the token.")); } catch (VerificationException e) { @@ -369,9 +417,41 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { }) .map(dl -> new DisclosedClaim((String) dl.get(0), (String) dl.get(1), dl.get(2))) .forEach(dc -> assertTrue(undisclosed.contains(dc.getHash()), "Every disclosure claim should be provided in the undisclosures.")); - } + private static void ensureHaipCompliantCertificateChain(KeyWrapper keyWrapper) { + List existingChain = keyWrapper.getCertificateChain(); + if (existingChain != null && !existingChain.isEmpty()) { + X509Certificate signingCert = existingChain.get(0); + if (signingCert != null + && !signingCert.getSubjectX500Principal().equals(signingCert.getIssuerX500Principal())) { + return; + } + } + + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair caKeyPair = kpg.generateKeyPair(); + X509Certificate caCert = CertificateUtils.generateV1SelfSignedCertificate(caKeyPair, "Test CA"); + + KeyPair leafKeyPair = new KeyPair( + (PublicKey) keyWrapper.getPublicKey(), + (PrivateKey) keyWrapper.getPrivateKey() + ); + X509Certificate leafCert = CertificateUtils.generateV3Certificate( + leafKeyPair, + caKeyPair.getPrivate(), + caCert, + "TestKey" + ); + + keyWrapper.setCertificateChain(List.of(leafCert, caCert)); + keyWrapper.setCertificate(leafCert); + } catch (Exception e) { + fail("Failed to prepare HAIP-compliant certificate chain: " + e.getMessage()); + } + } static class DisclosedClaim { private final String salt; @@ -395,7 +475,6 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { } catch (JsonProcessingException e) { throw new RuntimeException(e); } - } public String getSalt() { @@ -415,20 +494,13 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase { } } - - /* - private static class SdJwtCredentialSignerRealmConfig extends LegacyRealmConfig { - - @Override - public void configureTestRealm(RealmRepresentation testRealm) { - testRealm.setVerifiableCredentialsEnabled(true); - - if (testRealm.getComponents() != null) { - testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(rsaKey)); - } else { - testRealm.setComponents(new MultivaluedHashMap<>( - Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(rsaKey))))); - } + private static X509Certificate decodeDerBase64Certificate(String certificateDerBase64) { + try { + byte[] certBytes = Base64.getDecoder().decode(certificateDerBase64); + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) certFactory.generateCertificate(new java.io.ByteArrayInputStream(certBytes)); + } catch (Exception e) { + throw new RuntimeException("Failed to decode x5c certificate", e); + } } - */ } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java index 4ff4ca22f25..342283ecffe 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -17,6 +17,11 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; import java.io.IOException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.X509Certificate; import java.util.List; import java.util.Map; import java.util.UUID; @@ -28,7 +33,11 @@ import org.keycloak.TokenVerifier; import org.keycloak.VCFormat; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.CertificateUtils; import org.keycloak.constants.OID4VCIConstants; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -121,6 +130,10 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testRequestTestCredentialWithKeybinding() { + // Ensure SD-JWT signing uses a non-self-signed leaf cert for HAIP-6.1.1 compliance. + testingClient.server(TEST_REALM_NAME).run(session -> + ensureHaipCompliantCertificateChains(session)); + String cNonce = getCNonce(); String scopeName = sdJwtTypeCredentialClientScope.getName(); String credConfigId = sdJwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); @@ -339,6 +352,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { String token, Proofs proof, String credentialIdentifier) throws VerificationException, IOException { + // HAIP-6.1.1: ensure signing cert is not self-signed and trust anchor is not emitted in x5c. + ensureHaipCompliantCertificateChains(session); + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); authenticator.setTokenString(token); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); @@ -363,6 +379,66 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { return SdJwtVP.of(credentialResponseVO.getCredentials().get(0).getCredential().toString()); } + private static void ensureHaipCompliantCertificateChain(KeyWrapper keyWrapper) { + if (keyWrapper == null) { + throw new RuntimeException("Signing key is null"); + } + if (keyWrapper.getPublicKey() == null || keyWrapper.getPrivateKey() == null) { + throw new RuntimeException("Signing key missing key material for certificate generation. kid=" + keyWrapper.getKid()); + } + + List existingChain = keyWrapper.getCertificateChain(); + if (existingChain != null && !existingChain.isEmpty()) { + X509Certificate signingCert = existingChain.get(0); + if (signingCert != null + && !signingCert.getSubjectX500Principal().equals(signingCert.getIssuerX500Principal())) { + return; + } + } + + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair caKeyPair = kpg.generateKeyPair(); + X509Certificate caCert = CertificateUtils.generateV1SelfSignedCertificate(caKeyPair, "Test CA"); + + KeyPair leafKeyPair = new KeyPair( + (PublicKey) keyWrapper.getPublicKey(), + (PrivateKey) keyWrapper.getPrivateKey() + ); + X509Certificate leafCert = CertificateUtils.generateV3Certificate( + leafKeyPair, + caKeyPair.getPrivate(), + caCert, + "TestKey" + ); + + keyWrapper.setCertificateChain(List.of(leafCert, caCert)); + keyWrapper.setCertificate(leafCert); + } catch (Exception e) { + throw new RuntimeException("Failed to prepare HAIP-compliant certificate chain", e); + } + } + + private static void ensureHaipCompliantCertificateChains(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + KeyWrapper activeRs256 = session.keys().getActiveKey(realm, KeyUse.SIG, Algorithm.RS256); + if (activeRs256 != null) { + ensureHaipCompliantCertificateChain(activeRs256); + return; + } + + // Fallback for environments without an explicit active RS256 key. + KeyWrapper fallback = session.keys() + .getKeysStream(realm) + .filter(k -> KeyUse.SIG.equals(k.getUse())) + .filter(k -> Algorithm.RS256.equals(k.getAlgorithm())) + .filter(k -> k.getPublicKey() != null && k.getPrivateKey() != null) + .findFirst() + .orElseThrow(() -> new RuntimeException("No usable RS256 signing key configured in realm")); + ensureHaipCompliantCertificateChain(fallback); + } + // Tests the complete flow from // 1. Retrieving the credential-offer-uri // 2. Using the uri to get the actual credential offer @@ -372,6 +448,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { // 6. Get the credential @Test public void testCredentialIssuance() throws Exception { + // Ensure SD-JWT signing uses a non-self-signed leaf cert for HAIP-6.1.1 compliance. + testingClient.server(TEST_REALM_NAME).run(session -> + ensureHaipCompliantCertificateChains(session)); ClientScopeRepresentation clientScope = sdJwtTypeCredentialClientScope; String token = getBearerToken(oauth, client, clientScope.getName());