[java-idp-plugin-vci] branch main updated: Check revocation of attestation chains against CRLs
Codeberg
noreply at shibboleth.net
Thu Oct 8 08:30:25 UTC 2026
This is an automated email from the git hooks/post-receive script.
codeberg pushed a commit to branch main
in repository java-idp-plugin-vci.
View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/9ae345cee6fb92fece56ccd66c659bf6fedf8996
The following commit(s) were added to refs/heads/main by this push:
new 9ae345c Check revocation of attestation chains against CRLs
9ae345c is described below
commit 9ae345cee6fb92fece56ccd66c659bf6fedf8996
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Thu Oct 8 11:29:57 2026 +0300
Check revocation of attestation chains against CRLs
---
README.md | 38 ++++
.../impl/ClientAttestationCredentialValidator.java | 36 ++++
.../security/impl/CertificateChainTrust.java | 177 ++++++++++++++++---
.../security/impl/KeyAttestationValidator.java | 35 ++++
.../META-INF/net.shibboleth.idp/postconfig.xml | 15 ++
.../plugin/openidvci/conf/openid-vci.properties | 10 ++
.../security/impl/CertificateChainTrustTest.java | 195 +++++++++++++++++++++
.../test/resources/credentials/revocation-ca.crt | 11 ++
.../resources/credentials/revocation-empty.crl | 6 +
.../credentials/revocation-signer-revoked.crl | 7 +
.../resources/credentials/revocation-signer.crt | 11 ++
.../resources/credentials/revocation-signer.key | 5 +
12 files changed, 526 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index 50b5bdf..de6b21f 100644
--- a/README.md
+++ b/README.md
@@ -3414,6 +3414,33 @@ Key attestation is checked for every credential that has `key_attestations_requi
anchors such a request is refused. For the conformance suite add the certificate of its attester CA
to both lists the same way.
+**Optional:** revocation of the attester certificates. Give the CRLs of the CA, and a chain under
+it is checked against them. Each IdP node downloads the CRL itself and reads it again every
+`openidvci.attestation.crlRefreshInterval`, one hour by default. The backing file is used when the
+url cannot be read. The CA of the EUDI wallet provider publishes its CRL for two days at a time,
+in *conf/global.xml*:
+
+```xml
+<bean id="openidvci.CRLHttpClient" parent="shibboleth.HttpClientFactory" />
+
+<util:list id="openidvci.ClientAttestationCRLs">
+ <bean class="net.shibboleth.shared.spring.httpclient.resource.FileBackedHTTPResource"
+ c:backingFile="%{idp.home}/credentials/pid_CA_EU_02.crl"
+ c:client-ref="openidvci.CRLHttpClient"
+ c:url="https://preprod.pki.eudiw.dev/crl/pid_CA_EU_02.crl" />
+</util:list>
+
+<util:list id="openidvci.KeyAttestationCRLs">
+ <bean class="net.shibboleth.shared.spring.httpclient.resource.FileBackedHTTPResource"
+ c:backingFile="%{idp.home}/credentials/pid_CA_EU_02-key.crl"
+ c:client-ref="openidvci.CRLHttpClient"
+ c:url="https://preprod.pki.eudiw.dev/crl/pid_CA_EU_02.crl" />
+</util:list>
+```
+
+A CRL only applies to the trust anchor that issued it, so the attester CA of the conformance suite
+needs none. If CRLs are configured and none can be read, attestations are refused.
+
Wallet Attestation is a client authentication method, so it is added to the login flow of
clients in *conf/authn/oauth2client-authn-config.xml*. It has to be before the validator of
public clients of [Step 1](#wallet-authentication), otherwise a public client match
@@ -4199,9 +4226,12 @@ Beans you may define or refer to. Define your own in *conf/global.xml* or in any
| **openidvci.DefaultAccessTokenClaimsValidator** | ClaimsValidator | built in | Validation of access token claims at the credential endpoint. Chains **openidvci.AccessTokenClaimsValidators**. |
| **openidvci.AccessTokenClaimsValidators** | List<ClaimsValidator> | built in | `jti`, expiry, not before, `iss`, `client_id`, `aud` containing `credentials`, the claims only this plugin's token endpoint writes, and revocation. A list of your own needs to keep **openidvci.CredentialsAudienceClaimsValidator** and **openidvci.RequiredAccessTokenClaimsValidator**, or access tokens of the OP are accepted. |
| **openidvci.KeyAttestationTrustAnchors** | List<Credential> | | Certificates key attestations are accepted under. Needed for `key_attestations_required`. |
+| **openidvci.KeyAttestationCRLs** | List<Resource> | | CRLs of the trust anchors of key attestations, read again every `openidvci.attestation.crlRefreshInterval`. |
| **openidvci.KeyAttestationClaimsValidator** | ClaimsValidator | | Validation of key attestation claims, `nonce` among them. |
| **openidvci.ClientAttestationValidator** | CredentialValidator | built in | Authenticates a wallet with `OAuth-Client-Attestation` headers. Add to **shibboleth.authn.OAuth2Client.Validators** before **openidvci.PublicClientValidator**. |
| **openidvci.ClientAttestationTrustAnchors** | List<Credential> | | Certificates Wallet Attestations are accepted under. |
+| **openidvci.ClientAttestationCRLs** | List<Resource> | | CRLs of the trust anchors of Wallet Attestations, read again every `openidvci.attestation.crlRefreshInterval`. |
+| **openidvci.AttestationTrustEvaluator** | PKIXTrustEvaluator | `CertPathPKIXTrustEvaluator` | Evaluates the certificate chains of Wallet Attestations and key attestations. |
| **openidvci.PublicClientValidator** | CredentialValidator | built in | Authenticates a public client, `none`. |
| **openidvci.CredentialIssuerMetadata** | Resource | | Replaces the Credential Issuer metadata document wholesale. |
| **openidvci.JWTVCIssuerMetadata** | Resource | | Replaces the JWT VC issuer metadata document wholesale. |
@@ -4278,6 +4308,14 @@ The file comes with the plugin and is loaded automatically.
| `openidvci.status-list.assignment.StorageService` | Bean ID | **shibboleth.StorageService** | Storage of assignment records. |
| `openidvci.status-list.index.StorageService` | Bean ID | **shibboleth.StorageService** | Storage of the slot counter. Most churn of the three. |
+#### Attestations
+
+| Name | Type | Default | Function |
+|---|---|---|---|
+| `openidvci.attestation.checkRevocation` | Boolean | `false` | Check revocation of attestation chains also when no CRLs are configured. |
+| `openidvci.attestation.crlRefreshInterval` | Duration | `PT1H` | Interval of reading the CRLs of attestation chains again. |
+| `openidvci.attestation.verifyDepth` | Integer | `5` | Largest number of intermediate certificates of an attestation chain. |
+
#### OpenID Federation
| Name | Type | Default | Function |
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java
index d9b14ad..1a2db97 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java
@@ -21,6 +21,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Date;
+import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -33,9 +34,11 @@ import org.geant.shibboleth.plugin.openidvci.security.impl.CertificateChainTrust
import org.opensaml.messaging.context.MessageContext;
import org.opensaml.profile.context.ProfileRequestContext;
import org.opensaml.security.credential.Credential;
+import org.opensaml.security.x509.PKIXTrustEvaluator;
import org.opensaml.storage.ReplayCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.core.io.Resource;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.JOSEObjectType;
@@ -112,6 +115,39 @@ public class ClientAttestationCredentialValidator extends AbstractCredentialVali
chainTrust.setTrustAnchors(credentials);
}
+ /**
+ * Set the resources of the CRLs of the trust anchors of a client attestation.
+ *
+ * @param resources CRL resources
+ */
+ public void setCRLs(@Nullable final List<Resource> resources) {
+ checkSetterPreconditions();
+
+ chainTrust.setCRLs(resources);
+ }
+
+ /**
+ * Set the interval of reading the CRL resources again.
+ *
+ * @param interval refresh interval
+ */
+ public void setCRLRefreshInterval(@Nonnull final Duration interval) {
+ checkSetterPreconditions();
+
+ chainTrust.setCRLRefreshInterval(interval);
+ }
+
+ /**
+ * Set the evaluator of the PKIX trust of a chain of a client attestation.
+ *
+ * @param evaluator PKIX trust evaluator
+ */
+ public void setTrustEvaluator(@Nonnull final PKIXTrustEvaluator evaluator) {
+ checkSetterPreconditions();
+
+ chainTrust.setTrustEvaluator(evaluator);
+ }
+
/**
* Set the supplier of the servlet request.
*
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrust.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrust.java
index c0d7088..c61b380 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrust.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrust.java
@@ -16,23 +16,30 @@
package org.geant.shibboleth.plugin.openidvci.security.impl;
-import java.security.cert.CertPath;
-import java.security.cert.CertPathValidator;
-import java.security.cert.CertificateFactory;
-import java.security.cert.PKIXParameters;
-import java.security.cert.TrustAnchor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.cert.CRLException;
+import java.security.cert.X509CRL;
import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
-import java.util.HashSet;
import java.util.List;
-import java.util.Set;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import org.opensaml.security.SecurityException;
import org.opensaml.security.credential.Credential;
+import org.opensaml.security.x509.BasicX509Credential;
+import org.opensaml.security.x509.PKIXTrustEvaluator;
import org.opensaml.security.x509.X509Credential;
+import org.opensaml.security.x509.X509Support;
+import org.opensaml.security.x509.impl.BasicPKIXValidationInformation;
+import org.opensaml.security.x509.impl.CertPathPKIXTrustEvaluator;
+import org.slf4j.Logger;
+import org.springframework.core.io.Resource;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory;
@@ -42,17 +49,45 @@ import com.nimbusds.jwt.SignedJWT;
import net.shibboleth.oidc.jwt.claims.JWTValidationException;
import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
/**
- * Verifies a JWT signed by a key named in the 'x5c' header, the chain validating to one of the certificates
- * this component is configured with.
+ * Verifies a JWT signed by a key named in the 'x5c' header, the chain validating to one of the trust anchors this
+ * component is configured with. A trust anchor that has issued one of the configured CRLs is validated against with
+ * revocation checked.
*/
public class CertificateChainTrust extends AbstractInitializableComponent {
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(CertificateChainTrust.class);
+
/** Certificates a chain is accepted under. */
@Nonnull
private Collection<X509Certificate> trustAnchors = new ArrayList<>();
+ /** Resources of the CRLs of the trust anchors. */
+ @Nonnull
+ private List<Resource> crlResources = new ArrayList<>();
+
+ /** Interval of reading the CRL resources again. */
+ @Nonnull
+ private Duration crlRefreshInterval = Duration.ofHours(1);
+
+ /** CRLs last read, per resource. */
+ @Nonnull
+ private List<Collection<X509CRL>> crls = new ArrayList<>();
+
+ /** Instant the CRL resources were last read. */
+ @Nullable
+ private Instant crlsRead;
+
+ /** Evaluator of the PKIX trust of a chain. */
+ @Nonnull
+ private PKIXTrustEvaluator trustEvaluator = new CertPathPKIXTrustEvaluator();
+
/**
* Set the credentials a chain is accepted under.
*
@@ -69,6 +104,39 @@ public class CertificateChainTrust extends AbstractInitializableComponent {
trustAnchors = certificates;
}
+ /**
+ * Set the resources of the CRLs of the trust anchors.
+ *
+ * @param resources CRL resources
+ */
+ public void setCRLs(@Nullable final List<Resource> resources) {
+ checkSetterPreconditions();
+
+ crlResources = resources == null ? new ArrayList<>() : new ArrayList<>(resources);
+ }
+
+ /**
+ * Set the interval of reading the CRL resources again.
+ *
+ * @param interval refresh interval
+ */
+ public void setCRLRefreshInterval(@Nonnull final Duration interval) {
+ checkSetterPreconditions();
+
+ crlRefreshInterval = Constraint.isNotNull(interval, "CRL refresh interval cannot be null");
+ }
+
+ /**
+ * Set the evaluator of the PKIX trust of a chain.
+ *
+ * @param evaluator PKIX trust evaluator
+ */
+ public void setTrustEvaluator(@Nonnull final PKIXTrustEvaluator evaluator) {
+ checkSetterPreconditions();
+
+ trustEvaluator = Constraint.isNotNull(evaluator, "PKIX trust evaluator cannot be null");
+ }
+
/**
* Whether this component is able to accept a chain.
*
@@ -78,8 +146,18 @@ public class CertificateChainTrust extends AbstractInitializableComponent {
return !trustAnchors.isEmpty();
}
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ crls = new ArrayList<>();
+ crlResources.forEach(resource -> crls.add(new ArrayList<>()));
+ refreshCRLs();
+ }
+
/**
- * Verify the signature of a JWT, the chain of its 'x5c' header validating to one of the trust anchors.
+ * Verify the signature of a JWT, the chain of its 'x5c' header validating to the trust anchors.
*
* @param jwt JWT to verify
*
@@ -94,7 +172,7 @@ public class CertificateChainTrust extends AbstractInitializableComponent {
if (encoded == null || encoded.isEmpty()) {
throw new JWTValidationException("JWT carries no 'x5c' header");
}
- if (trustAnchors.isEmpty()) {
+ if (!isConfigured()) {
throw new JWTValidationException("No trust anchors to accept a chain under");
}
final List<X509Certificate> chain = new ArrayList<>();
@@ -105,15 +183,8 @@ public class CertificateChainTrust extends AbstractInitializableComponent {
}
chain.add(parsed);
}
- try {
- final Set<TrustAnchor> anchors = new HashSet<>();
- trustAnchors.forEach(certificate -> anchors.add(new TrustAnchor(certificate, null)));
- final CertPath path = CertificateFactory.getInstance("X.509").generateCertPath(chain);
- final PKIXParameters parameters = new PKIXParameters(anchors);
- parameters.setRevocationEnabled(false);
- CertPathValidator.getInstance("PKIX").validate(path, parameters);
- } catch (final Exception e) {
- throw new JWTValidationException("Chain of the JWT does not validate", e);
+ if (!validates(chain)) {
+ throw new JWTValidationException("Chain of the JWT does not validate");
}
try {
if (!jwt.verify(new DefaultJWSVerifierFactory().createJWSVerifier(jwt.getHeader(),
@@ -126,4 +197,70 @@ public class CertificateChainTrust extends AbstractInitializableComponent {
return chain;
}
+ /**
+ * Evaluate the PKIX trust of a chain against each trust anchor, with the CRLs the anchor has issued.
+ *
+ * @param chain certificate chain, the leaf first
+ *
+ * @return true if the chain validates to one of the trust anchors
+ *
+ * @throws JWTValidationException if CRLs are configured and none is available, or if the evaluation fails for
+ * other reason than an untrusted chain
+ */
+ private boolean validates(@Nonnull final List<X509Certificate> chain) throws JWTValidationException {
+
+ final List<X509CRL> current = currentCRLs();
+ if (!crlResources.isEmpty() && current.isEmpty()) {
+ throw new JWTValidationException("CRLs are configured and none is available");
+ }
+ final BasicX509Credential untrusted = new BasicX509Credential(chain.get(0));
+ untrusted.setEntityCertificateChain(chain);
+ try {
+ for (final X509Certificate anchor : trustAnchors) {
+ final List<X509CRL> issued = current.stream()
+ .filter(crl -> crl.getIssuerX500Principal().equals(anchor.getSubjectX500Principal()))
+ .toList();
+ if (trustEvaluator.validate(new BasicPKIXValidationInformation(List.of(anchor), issued, null),
+ untrusted)) {
+ return true;
+ }
+ }
+ } catch (final SecurityException e) {
+ throw new JWTValidationException("Unable to validate the chain of the JWT", e);
+ }
+ return false;
+ }
+
+ /**
+ * Get the CRLs, read again from their resources when the refresh interval has passed.
+ *
+ * @return the CRLs
+ */
+ @Nonnull
+ private synchronized List<X509CRL> currentCRLs() {
+
+ final Instant read = crlsRead;
+ if (read == null || !Instant.now().isBefore(read.plus(crlRefreshInterval))) {
+ refreshCRLs();
+ }
+ final List<X509CRL> all = new ArrayList<>();
+ crls.forEach(all::addAll);
+ return all;
+ }
+
+ /** Read the CRL resources, keeping the CRLs last read of a resource that cannot be read. */
+ private synchronized void refreshCRLs() {
+
+ for (int i = 0; i < crlResources.size(); i++) {
+ final Resource resource = crlResources.get(i);
+ try (InputStream in = resource.getInputStream()) {
+ crls.set(i, X509Support.decodeCRLs(in));
+ } catch (final IOException | CRLException e) {
+ log.warn("Unable to read CRL from {}, keeping the CRLs read before it: {}", resource.getDescription(),
+ e.getMessage());
+ }
+ }
+ crlsRead = Instant.now();
+ }
+
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java
index d9df6cb..34b37f5 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java
@@ -31,8 +31,10 @@ import javax.annotation.Nullable;
import org.geant.shibboleth.plugin.openidvci.credential.KeyAttestationsRequired;
import org.opensaml.profile.context.ProfileRequestContext;
import org.opensaml.security.credential.Credential;
+import org.opensaml.security.x509.PKIXTrustEvaluator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.core.io.Resource;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.JOSEObjectType;
@@ -98,6 +100,39 @@ public class KeyAttestationValidator extends AbstractInitializableComponent {
chainTrust.setTrustAnchors(credentials);
}
+ /**
+ * Set the resources of the CRLs of the trust anchors of a key attestation.
+ *
+ * @param resources CRL resources
+ */
+ public void setCRLs(@Nullable final List<Resource> resources) {
+ checkSetterPreconditions();
+
+ chainTrust.setCRLs(resources);
+ }
+
+ /**
+ * Set the interval of reading the CRL resources again.
+ *
+ * @param interval refresh interval
+ */
+ public void setCRLRefreshInterval(@Nonnull final Duration interval) {
+ checkSetterPreconditions();
+
+ chainTrust.setCRLRefreshInterval(interval);
+ }
+
+ /**
+ * Set the evaluator of the PKIX trust of a chain of a key attestation.
+ *
+ * @param evaluator PKIX trust evaluator
+ */
+ public void setTrustEvaluator(@Nonnull final PKIXTrustEvaluator evaluator) {
+ checkSetterPreconditions();
+
+ chainTrust.setTrustEvaluator(evaluator);
+ }
+
/**
* Set the validator of the claims of a key attestation.
*
diff --git a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 95afcd3..075c117 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -45,14 +45,29 @@
<bean id="openidvci.PublicClientValidator"
class="org.geant.shibboleth.plugin.openidvci.authn.impl.WalletCredentialValidator" />
+ <bean id="openidvci.DefaultAttestationTrustEvaluator" lazy-init="true"
+ class="org.opensaml.security.x509.impl.CertPathPKIXTrustEvaluator">
+ <property name="pKIXValidationOptions">
+ <bean class="org.opensaml.security.x509.impl.CertPathPKIXValidationOptions"
+ p:forceRevocationEnabled="%{openidvci.attestation.checkRevocation:false}"
+ p:defaultVerificationDepth="%{openidvci.attestation.verifyDepth:5}" />
+ </property>
+ </bean>
+
<bean id="openidvci.DefaultKeyAttestationValidator" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.security.impl.KeyAttestationValidator"
p:trustAnchors="#{getObject('openidvci.KeyAttestationTrustAnchors')}"
+ p:CRLs="#{getObject('openidvci.KeyAttestationCRLs')}"
+ p:CRLRefreshInterval="%{openidvci.attestation.crlRefreshInterval:PT1H}"
+ p:trustEvaluator="#{getObject('openidvci.AttestationTrustEvaluator') ?: getObject('openidvci.DefaultAttestationTrustEvaluator')}"
p:claimsValidator="#{getObject('openidvci.KeyAttestationClaimsValidator')}" />
<bean id="openidvci.ClientAttestationValidator" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.authn.impl.ClientAttestationCredentialValidator"
p:trustAnchors="#{getObject('openidvci.ClientAttestationTrustAnchors')}"
+ p:CRLs="#{getObject('openidvci.ClientAttestationCRLs')}"
+ p:CRLRefreshInterval="%{openidvci.attestation.crlRefreshInterval:PT1H}"
+ p:trustEvaluator="#{getObject('openidvci.AttestationTrustEvaluator') ?: getObject('openidvci.DefaultAttestationTrustEvaluator')}"
p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
p:replayCache-ref="shibboleth.ReplayCache"
p:audienceLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple" />
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index af47ca2..bf245f7 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -111,6 +111,16 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
# filling display_name and logo_uri from Credential Issuer metadata.
#openidvci.oidfed.decorateFederationEntity = true
+# Settings for the certificate chains of Wallet Attestations and key attestations
+# Revocation is checked for a trust anchor that has issued one of the CRLs of
+# openidvci.ClientAttestationCRLs or openidvci.KeyAttestationCRLs. Set to true to require it
+# also without them.
+#openidvci.attestation.checkRevocation = false
+# Interval of reading the CRLs again
+#openidvci.attestation.crlRefreshInterval = PT1H
+# Largest number of intermediate certificates of a chain
+#openidvci.attestation.verifyDepth = 5
+
# Settings for the loggingId of each flow
#openidvci.logging.credentials = OpenID.VCI.Credentials
#openidvci.logging.credential-offer = OpenID.VCI.CredentialOffer
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrustTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrustTest.java
new file mode 100644
index 0000000..1d33d56
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrustTest.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.security.impl;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.crypto.KeySupport;
+import org.opensaml.security.x509.BasicX509Credential;
+import org.opensaml.security.x509.X509Support;
+import org.opensaml.security.x509.impl.CertPathPKIXTrustEvaluator;
+import org.opensaml.security.x509.impl.CertPathPKIXValidationOptions;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+
+/** Unit tests for {@link CertificateChainTrust}. */
+public class CertificateChainTrustTest {
+
+ private X509Certificate signerCertificate;
+
+ private SignedJWT jwt;
+
+ private Path crlFile;
+
+ private X509Certificate certificate(final String name) throws Exception {
+ try (InputStream in = getClass().getResourceAsStream("/credentials/" + name)) {
+ return X509Support.decodeCertificate(in.readAllBytes());
+ }
+ }
+
+ private Resource crl(final String name) {
+ return new ClassPathResource("credentials/" + name);
+ }
+
+ private SignedJWT signed(final String certificateName, final String keyName) throws Exception {
+ final X509Certificate certificate = certificate(certificateName);
+ final PrivateKey key;
+ try (InputStream in = getClass().getResourceAsStream("/credentials/" + keyName)) {
+ key = KeySupport.decodePrivateKey(in.readAllBytes(), null);
+ }
+ final SignedJWT signedJwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256)
+ .x509CertChain(Arrays.asList(Base64.encode(certificate.getEncoded()))).build(),
+ new JWTClaimsSet.Builder().subject("test").build());
+ signedJwt.sign(new ECDSASigner(key, Curve.P_256));
+ return signedJwt;
+ }
+
+ private List<Credential> anchors(final String... names) throws Exception {
+ final List<Credential> credentials = new ArrayList<>();
+ for (final String name : names) {
+ credentials.add(new BasicX509Credential(certificate(name)));
+ }
+ return credentials;
+ }
+
+ private CertificateChainTrust trust(final List<Resource> crls) throws Exception {
+ final CertificateChainTrust trust = new CertificateChainTrust();
+ trust.setTrustAnchors(anchors("revocation-ca.crt"));
+ trust.setCRLs(crls);
+ trust.setCRLRefreshInterval(Duration.ZERO);
+ trust.initialize();
+ return trust;
+ }
+
+ private void writeCrl(final String name) throws Exception {
+ try (InputStream in = getClass().getResourceAsStream("/credentials/" + name)) {
+ Files.copy(in, crlFile, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ signerCertificate = certificate("revocation-signer.crt");
+ jwt = signed("revocation-signer.crt", "revocation-signer.key");
+ crlFile = Files.createTempFile("crl", ".crl");
+ }
+
+ @AfterMethod
+ protected void tearDown() throws Exception {
+ Files.deleteIfExists(crlFile);
+ }
+
+ @Test
+ public void testTrustAnchors() throws Exception {
+ Assert.assertEquals(trust(null).verify(jwt).get(0), signerCertificate);
+ }
+
+ @Test
+ public void testNotRevoked() throws Exception {
+ Assert.assertEquals(trust(List.of(crl("revocation-empty.crl"))).verify(jwt).get(0), signerCertificate);
+ }
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void testRevoked() throws Exception {
+ trust(List.of(crl("revocation-signer-revoked.crl"))).verify(jwt);
+ }
+
+ @Test
+ public void testRefreshed() throws Exception {
+ writeCrl("revocation-empty.crl");
+ final CertificateChainTrust trust = trust(List.of(new FileSystemResource(crlFile)));
+ trust.verify(jwt);
+ writeCrl("revocation-signer-revoked.crl");
+ Assert.assertThrows(JWTValidationException.class, () -> trust.verify(jwt));
+ }
+
+ @Test
+ public void testUnreadableKeepsLastRead() throws Exception {
+ writeCrl("revocation-signer-revoked.crl");
+ final CertificateChainTrust trust = trust(List.of(new FileSystemResource(crlFile)));
+ Files.delete(crlFile);
+ Assert.assertThrows(JWTValidationException.class, () -> trust.verify(jwt));
+ }
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void testNoneAvailable() throws Exception {
+ Files.delete(crlFile);
+ trust(List.of(new FileSystemResource(crlFile))).verify(jwt);
+ }
+
+ @Test
+ public void testAnchorWithoutCrlBesideAnchorWithCrl() throws Exception {
+ final CertificateChainTrust trust = new CertificateChainTrust();
+ trust.setTrustAnchors(anchors("revocation-ca.crt", "attestation-ca.crt"));
+ trust.setCRLs(List.of(crl("revocation-signer-revoked.crl")));
+ trust.initialize();
+ trust.verify(signed("attestation-signer.crt", "attestation-signer.key"));
+ Assert.assertThrows(JWTValidationException.class, () -> trust.verify(jwt));
+ }
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void testUnknownAnchor() throws Exception {
+ final CertificateChainTrust trust = new CertificateChainTrust();
+ trust.setTrustAnchors(anchors("attestation-ca.crt"));
+ trust.initialize();
+ trust.verify(jwt);
+ }
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void testForcedRevocationWithoutCrl() throws Exception {
+ final CertPathPKIXValidationOptions options = new CertPathPKIXValidationOptions();
+ options.setForceRevocationEnabled(true);
+ final CertificateChainTrust trust = new CertificateChainTrust();
+ trust.setTrustAnchors(anchors("revocation-ca.crt"));
+ trust.setTrustEvaluator(new CertPathPKIXTrustEvaluator(options));
+ trust.initialize();
+ trust.verify(jwt);
+ }
+
+ @Test(expectedExceptions = JWTValidationException.class)
+ public void testNotConfigured() throws Exception {
+ final CertificateChainTrust trust = new CertificateChainTrust();
+ trust.initialize();
+ trust.verify(jwt);
+ }
+
+}
diff --git a/openid-vci-impl/src/test/resources/credentials/revocation-ca.crt b/openid-vci-impl/src/test/resources/credentials/revocation-ca.crt
new file mode 100644
index 0000000..80e9c60
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/credentials/revocation-ca.crt
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE-----
+MIIBgzCCASmgAwIBAgIUDyZJ/o9GRlnV3GDoM+QtOA+OyqowCgYIKoZIzj0EAwIw
+HTEbMBkGA1UEAwwSUmV2b2NhdGlvbiBUZXN0IENBMCAXDTI2MTAwODA3MzYyNVoY
+DzIxMjYwOTE0MDczNjI1WjAdMRswGQYDVQQDDBJSZXZvY2F0aW9uIFRlc3QgQ0Ew
+WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR7aQDpwj/pF8hMS1soLuEmHoX5XE9U
+hZBHDLsXpWyI71hYYrXxr2R4ygoNx4Ndfr8132Dg+Lm7TkdIvBvWN9uTo0UwQzAS
+BgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUTmOZ
+Jz79S1aZOvzyZDiZ1UECsPgwCgYIKoZIzj0EAwIDSAAwRQIhALWTvEutN0g2xJUI
+f22ZRoNWvfDikR1zztb30JEnLdhXAiB1fjedGs3L+f5cv3eTAWIOg8YCIeMdT1O+
+3q9p6qeCyg==
+-----END CERTIFICATE-----
diff --git a/openid-vci-impl/src/test/resources/credentials/revocation-empty.crl b/openid-vci-impl/src/test/resources/credentials/revocation-empty.crl
new file mode 100644
index 0000000..1e770c6
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/credentials/revocation-empty.crl
@@ -0,0 +1,6 @@
+-----BEGIN X509 CRL-----
+MIG2MF4CAQEwCgYIKoZIzj0EAwIwHTEbMBkGA1UEAwwSUmV2b2NhdGlvbiBUZXN0
+IENBFw0yNjEwMDgwNzM2MjVaGA8yMTI2MDkxNDA3MzYyNVqgDjAMMAoGA1UdFAQD
+AgEBMAoGCCqGSM49BAMCA0gAMEUCIQCaVr73Py/lgToIBoMdDEmk96h3Eoe6ejq2
+5a6tqZPg5QIgR+ufBNvtw5+5lwHfXIpax8bYyrHxSxWKRXQPE4J5t20=
+-----END X509 CRL-----
diff --git a/openid-vci-impl/src/test/resources/credentials/revocation-signer-revoked.crl b/openid-vci-impl/src/test/resources/credentials/revocation-signer-revoked.crl
new file mode 100644
index 0000000..cf6a55a
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/credentials/revocation-signer-revoked.crl
@@ -0,0 +1,7 @@
+-----BEGIN X509 CRL-----
+MIHhMIGHAgEBMAoGCCqGSM49BAMCMB0xGzAZBgNVBAMMElJldm9jYXRpb24gVGVz
+dCBDQRcNMjYxMDA4MDczNjI1WhgPMjEyNjA5MTQwNzM2MjVaMCcwJQIURJl7MGIq
+4Zq8AVtu3ekCPTub1CAXDTI2MTAwODA3MzYyNVqgDjAMMAoGA1UdFAQDAgECMAoG
+CCqGSM49BAMCA0kAMEYCIQDmrCXVPxignwzUnA4iSzGSDjuXqzen7+cZNGmy7lI6
+ngIhAKctvefT2YDhA5BQ1VToDKRm5woqc9Mh26HwLzwQaYEp
+-----END X509 CRL-----
diff --git a/openid-vci-impl/src/test/resources/credentials/revocation-signer.crt b/openid-vci-impl/src/test/resources/credentials/revocation-signer.crt
new file mode 100644
index 0000000..9d38ba0
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/credentials/revocation-signer.crt
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE-----
+MIIBojCCAUigAwIBAgIURJl7MGIq4Zq8AVtu3ekCPTub1CAwCgYIKoZIzj0EAwIw
+HTEbMBkGA1UEAwwSUmV2b2NhdGlvbiBUZXN0IENBMCAXDTI2MTAwODA3MzYyNVoY
+DzIxMjYwOTE0MDczNjI1WjAhMR8wHQYDVQQDDBZSZXZvY2F0aW9uIFRlc3QgU2ln
+bmVyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8uRTWQfBkvwvkVA/s6hHXXdZ
+SFBA0BiLRung++lZaWK50ReEd2tinFOsU8y9nBLfCLJv2OUWbq9ksOWHHw/r6KNg
+MF4wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIWPTtp3
+Eqit+vQe6faFrKimBbMsMB8GA1UdIwQYMBaAFE5jmSc+/UtWmTr88mQ4mdVBArD4
+MAoGCCqGSM49BAMCA0gAMEUCIQCK/0K4mwry1UPjwLUtIBiszkl9hL0MgtZ6awOh
+Jc2nMAIgPgZWr9n1wozEjSPgu5F+MIgzzme8tisg7L3RVH48PIk=
+-----END CERTIFICATE-----
diff --git a/openid-vci-impl/src/test/resources/credentials/revocation-signer.key b/openid-vci-impl/src/test/resources/credentials/revocation-signer.key
new file mode 100644
index 0000000..788476d
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/credentials/revocation-signer.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggS38f4EsajJvlfGj
+yzFbyyLaLOni+TPgFS5BIYmWW/ihRANCAATy5FNZB8GS/C+RUD+zqEddd1lIUEDQ
+GItG6eD76VlpYrnRF4R3a2KcU6xTzL2cEt8Ism/Y5RZur2Sw5YcfD+vo
+-----END PRIVATE KEY-----
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list