mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-28 04:13:22 -04:00
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:
parent
69b3503a0f
commit
d7af0efada
9 changed files with 515 additions and 292 deletions
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue