fix(haip): exclude trust anchor from SD-JWT x5c and update migrated tests

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
This commit is contained in:
forkimenjeckayang 2026-05-21 08:39:09 +00:00
parent 69b3503a0f
commit d7af0efada
No known key found for this signature in database
GPG key ID: B94C8C377D12DEED
9 changed files with 515 additions and 292 deletions

View file

@ -78,12 +78,43 @@ public class SdJwtCredentialSigner extends AbstractCredentialSigner<String> {
* @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<X509Certificate> certificateChain = signer.getCertificateChain();
if (certificateChain != null && !certificateChain.isEmpty()) {
List<String> 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<X509Certificate> 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<String> x5cList = filteredChain.stream()
.map(cert -> {
try {
return Base64.getEncoder().encodeToString(cert.getEncoded());
@ -96,10 +127,10 @@ public class SdJwtCredentialSigner extends AbstractCredentialSigner<String> {
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.");
}
}
}

View file

@ -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<String> enabledEventTypes = realmEventsConfig.getEnabledEventTypes();

View file

@ -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 <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
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);
}
}
}

View file

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

View file

@ -133,22 +133,21 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest {
List<String> 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);
}
}

View file

@ -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<JsonWebToken> 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.");
}
}
}

View file

@ -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<String> 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<String, Object> claims) {
CredentialSubject credentialSubject = new CredentialSubject();
claims.forEach(credentialSubject::setClaims);
return credentialSubject;
}
protected static VerifiableCredential getTestCredential(Map<String, Object> 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;
}
}

View file

@ -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<String> 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<JsonWebToken> 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<String, Object> claims, int decoys, List<String> 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<JsonWebToken> verifier = TokenVerifier
@ -346,10 +395,9 @@ public class SdJwtCredentialSignerTest extends OID4VCIssuerTestBase {
List<String> 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<X509Certificate> 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);
}
}
*/
}

View file

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