Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ public class CredentialScopeModel implements ClientScopeModel {
*/
public static final String VC_SIGNING_KEY_ID = "vc.signing_key_id";

/**
* Optional strategy for additional issuer key resolution requirements.
* If absent, credential issuance follows the regular OID4VCI behavior.
*/
public static final String VC_ISSUER_KEY_RESOLUTION_STRATEGY = "vc.issuer_key_resolution_strategy";
public static final String VC_ISSUER_KEY_RESOLUTION_STRATEGY_HAIP_X5C = "haip_x5c";

/**
* an optional attribute for the metadata endpoint
*/
Expand Down Expand Up @@ -260,6 +267,16 @@ public void setSigningKeyId(String signingKeyId) {
clientScope.setAttribute(VC_SIGNING_KEY_ID, signingKeyId);
}

public String getIssuerKeyResolutionStrategy() {
return Optional.ofNullable(clientScope.getAttribute(VC_ISSUER_KEY_RESOLUTION_STRATEGY))
.filter(StringUtil::isNotBlank)
.orElse(null);
}

public void setIssuerKeyResolutionStrategy(String issuerKeyResolutionStrategy) {
clientScope.setAttribute(VC_ISSUER_KEY_RESOLUTION_STRATEGY, issuerKeyResolutionStrategy);
}

public String getBuildConfigHashAlgorithm() {
return Optional.ofNullable(clientScope.getAttribute(VC_BUILD_CONFIG_HASH_ALGORITHM))
.orElse(VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1034,12 +1034,12 @@ public Response requestCredential(String requestPayload) {

eventBuilder.success();

// Clean up offer state after successful credential issuance
// This prevents memory leaks while ensuring the state remains available during the request
if (offerState != null) {
offerStorage.removeOfferState(offerState);
LOGGER.debugf("Removed credential offer state after successful issuance");
}
// Keep offer state after successful issuance for all grant types.
// Per OID4VCI Section 14.3 ("Multiple Accesses to the Credential Endpoint"),
// the credential endpoint may be accessed multiple times with the same access token.
// Offer lifecycle is controlled by expiry/cleanup policy, while replay protection
// is enforced at grant artifact level (e.g. single-use authorization_code and
// pre-authorized_code semantics in token exchange).

return response;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.security.auth.x500.X500Principal;

import org.keycloak.crypto.SignatureSignerContext;
Expand All @@ -32,6 +33,8 @@

import org.jboss.logging.Logger;

import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_ISSUER_KEY_RESOLUTION_STRATEGY_HAIP_X5C;

/**
* {@link CredentialSigner} implementing the SD_JWT_VC format. It returns the signed SD-JWT as a String.
* <p>
Expand All @@ -58,16 +61,16 @@ public String signCredential(CredentialBody credentialBody, CredentialBuildConfi
// Get the signer first to ensure we use the exact same key that will sign the credential
SignatureSignerContext signer = getSigner(credentialBuildConfig);

// Add x5c certificate chain to the header if available (required by HAIP-6.1.1)
// See: https://openid.github.io/OpenID4VC-HAIP/openid4vc-high-assurance-interoperability-profile-wg-draft.html#section-6.1.1
addX5cHeader(sdJwtCredentialBody, signer);
if (VC_ISSUER_KEY_RESOLUTION_STRATEGY_HAIP_X5C.equals(credentialBuildConfig.getIssuerKeyResolutionStrategy())) {
addHaipX5cHeader(sdJwtCredentialBody, signer);
}

return sdJwtCredentialBody.sign(signer);
}

/**
* Adds x5c certificate chain to the IssuerSignedJWT header if available.
* This is required by HAIP-6.1.1 for SD-JWT credentials.
* This is required by HAIP-6.1.1 for SD-JWT credentials using the HAIP x5c key resolution strategy.
* <p>
* Uses the certificate chain from the signer to ensure we use the exact same key
* that will be used for signing, following Keycloak's established pattern.
Expand All @@ -78,38 +81,69 @@ public String signCredential(CredentialBody credentialBody, CredentialBuildConfi
* @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 addHaipX5cHeader(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)
.filter(cert -> !isTrustAnchor(cert))
.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 (isTrustAnchor(last)) {
// Last certificate is a 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());
} catch (CertificateEncodingException e) {
throw new RuntimeException(e);
}
})
.toList();
.collect(Collectors.toList());

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

private boolean isTrustAnchor(X509Certificate cert) {
boolean isTrustAnchor = false;
try {
int basicConstraints = cert.getBasicConstraints();
X500Principal issuerPrincipal = cert.getIssuerX500Principal();
X500Principal subjectPrincipal = cert.getSubjectX500Principal();
isTrustAnchor = subjectPrincipal.equals(issuerPrincipal) && basicConstraints >= 0;
// Self-signed certificate (subject == issuer) is a trust anchor
isTrustAnchor = subjectPrincipal.equals(issuerPrincipal);
} catch (Exception e) {
// ignore
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ public class CredentialBuildConfig {
// Needs to fit the provided signing key.
private String ldpProofType;

// Defines additional issuer key resolution requirements. Null keeps regular OID4VCI issuance behavior.
private String issuerKeyResolutionStrategy;

public static CredentialBuildConfig parse(KeycloakSession keycloakSession,
SupportedCredentialConfiguration credentialConfiguration,
CredentialScopeModel credentialModel) {
Expand Down Expand Up @@ -115,6 +118,7 @@ else if (StringUtil.isNotBlank(credentialModel.getSigningAlg())) {
.setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys())
.setSigningKeyId(credentialModel.getSigningKeyId())
.setSigningAlgorithm(signingAlg)
.setIssuerKeyResolutionStrategy(credentialModel.getIssuerKeyResolutionStrategy())
.setHashAlgorithm(credentialModel.getBuildConfigHashAlgorithm())
.setSdJwtVisibleClaims(credentialModel.getBuildConfigSdJwtVisibleClaims());
}
Expand Down Expand Up @@ -218,6 +222,15 @@ public CredentialBuildConfig setLdpProofType(String ldpProofType) {
return this;
}

public String getIssuerKeyResolutionStrategy() {
return issuerKeyResolutionStrategy;
}

public CredentialBuildConfig setIssuerKeyResolutionStrategy(String issuerKeyResolutionStrategy) {
this.issuerKeyResolutionStrategy = issuerKeyResolutionStrategy;
return this;
}

@Override
public boolean equals(Object o) {
if (this == o) {
Expand All @@ -237,7 +250,9 @@ public boolean equals(Object o) {
that.numberOfDecoys) && Objects.equals(signingKeyId, that.signingKeyId) && Objects.equals(overrideKeyId,
that.overrideKeyId) && Objects.equals(
signingAlgorithm,
that.signingAlgorithm) && Objects.equals(ldpProofType, that.ldpProofType);
that.signingAlgorithm) && Objects.equals(ldpProofType, that.ldpProofType) && Objects.equals(
issuerKeyResolutionStrategy,
that.issuerKeyResolutionStrategy);
}

@Override
Expand All @@ -251,6 +266,7 @@ public int hashCode() {
signingKeyId,
overrideKeyId,
signingAlgorithm,
ldpProofType);
ldpProofType,
issuerKeyResolutionStrategy);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_FORMAT_DEFAULT;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_IDENTIFIER;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_ISSUER_DID;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_ISSUER_KEY_RESOLUTION_STRATEGY;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_KEY_ATTESTATION_REQUIRED;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_KEY_ATTESTATION_REQUIRED_KEY_STORAGE;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_KEY_ATTESTATION_REQUIRED_USER_AUTH;
Expand Down Expand Up @@ -159,6 +160,16 @@ public CredentialScopeRepresentation setSigningKeyId(String signingKeyId) {
return setAttribute(VC_SIGNING_KEY_ID, signingKeyId);
}

public String getIssuerKeyResolutionStrategy() {
return Optional.ofNullable(getAttribute(VC_ISSUER_KEY_RESOLUTION_STRATEGY))
.filter(value -> !value.isBlank())
.orElse(null);
}

public CredentialScopeRepresentation setIssuerKeyResolutionStrategy(String issuerKeyResolutionStrategy) {
return setAttribute(VC_ISSUER_KEY_RESOLUTION_STRATEGY, issuerKeyResolutionStrategy);
}

public String getBuildConfigHashAlgorithm() {
return getAttribute(VC_BUILD_CONFIG_HASH_ALGORITHM);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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;
Expand Down Expand Up @@ -268,6 +269,7 @@ void beforeEachBase() {

oauth.client(client.getClientId(), client.getSecret());
enableVerifiableCredentialEvents();
ensureHaipCompliantSdJwtSigningConfiguration();

wallet = new OID4VCBasicWallet(keycloak, oauth);
}
Expand Down Expand Up @@ -518,6 +520,20 @@ protected void updateCredentialScope(CredentialScopeRepresentation clientScope)
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();
Expand Down Expand Up @@ -562,6 +578,45 @@ private ComponentRepresentation createRsaKeyProviderComponent(KeyWrapper keyWrap
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse;
import org.keycloak.util.JsonSerialization;

Expand Down Expand Up @@ -79,11 +78,9 @@ public void testAuthCodeOffer_Anonymous() throws Exception {

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);

// Attempt to fetch the credential offer again after it has been consumed

CredentialOfferResponse res = wallet.credentialsOfferRequest(ctx, offerURI).send();
assertEquals("invalid_credential_offer_request", res.getError());
assertEquals("Credential offer not found or already consumed", res.getErrorDescription());
CredentialsOffer replayedOffer = wallet.credentialsOfferRequest(ctx, offerURI).send().getCredentialsOffer();
assertNotNull(replayedOffer, "Credential offer should still be available");
assertNotNull(replayedOffer.getIssuerState(), "IssuerState should still be present");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,34 @@
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);
}
}

}
Loading
Loading