[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