[java-idp-plugin-vci] branch main updated: 'x5c' signing for HAIP. Now if plain issuer url is used and credential signed with key containing x5c chain, that information is used and placed to header

Codeberg noreply at shibboleth.net
Wed Sep 23 10:02:32 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/f8cc74da8ae97a40a22645b414b8269edebcc326

The following commit(s) were added to refs/heads/main by this push:
     new f8cc74d  'x5c' signing for HAIP. Now if plain issuer url is used and credential signed with key containing x5c chain, that information is used and placed to header
f8cc74d is described below

commit f8cc74da8ae97a40a22645b414b8269edebcc326
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Sep 23 13:01:56 2026 +0300

    'x5c' signing for HAIP. Now if plain issuer url is used and credential signed with key containing x5c chain, that information is used and placed to header
---
 .../FormOutboundIssuerMetadataResponseMessage.java |   5 +-
 .../openidvci/profile/impl/SignJWTCredential.java  |   8 +-
 .../impl/CertificateChainJWSTokenSigner.java       | 134 +++++++++++++++++++++
 .../profile/impl/SignStatusListToken.java          |   7 +-
 .../openidvci/conf/openid-vci-credentials.xml      |  18 +++
 .../impl/CertificateChainJWSTokenSignerTest.java   | 108 +++++++++++++++++
 6 files changed, 271 insertions(+), 9 deletions(-)

diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java
index a6c9c3a..3ef7a23 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java
@@ -27,6 +27,7 @@ import javax.annotation.Nullable;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialIssuerMetadataSuccessResponse;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.SignedCredentialIssuerMetadataSuccessResponse;
 import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.security.impl.CertificateChainJWSTokenSigner;
 import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
@@ -41,7 +42,6 @@ import com.nimbusds.jwt.SignedJWT;
 
 import jakarta.servlet.http.HttpServletRequest;
 import net.shibboleth.idp.profile.AbstractProfileAction;
-import net.shibboleth.oidc.security.impl.JWSTokenSigner;
 import net.shibboleth.oidc.security.jose.SignatureException;
 import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
 import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
@@ -222,7 +222,8 @@ public class FormOutboundIssuerMetadataResponseMessage extends AbstractProfileAc
 
         final SignedJWT document;
         try {
-            document = new JWSTokenSigner(parameters).sign(claims.build(), SIGNED_DOCUMENT_TYPE);
+            document = new CertificateChainJWSTokenSigner(parameters, metadata.getCredentialIssuer().getValue())
+                    .sign(claims.build(), SIGNED_DOCUMENT_TYPE);
         } catch (final SignatureException e) {
             log.error("{} Could not sign Credential Issuer metadata", getLogPrefix(), e);
             ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
index 6d86ceb..f5bf6b9 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
@@ -23,6 +23,7 @@ import java.util.function.Function;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
+import org.geant.shibboleth.plugin.openidvci.security.impl.CertificateChainJWSTokenSigner;
 import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCICredentialsRequest;
@@ -36,7 +37,6 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import net.shibboleth.idp.plugin.oidc.op.profile.impl.AbstractOIDCResponseAction;
-import net.shibboleth.oidc.security.impl.JWSTokenSigner;
 import net.shibboleth.oidc.security.jose.SignatureException;
 import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
 import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
@@ -163,9 +163,9 @@ public class SignJWTCredential extends AbstractOIDCResponseAction {
         final List<String> credentials = new ArrayList<>();
         for (final JWTClaimsSet credential : ctx.getJWTCredentials()) {
             try {
-                credentials.add(new JWSTokenSigner(
-                        DidSupport.nameKeyByIssuer(signatureSigningParameters, credential.getIssuer()))
-                        .sign(credential, format).serialize());
+                credentials.add(new CertificateChainJWSTokenSigner(
+                        DidSupport.nameKeyByIssuer(signatureSigningParameters, credential.getIssuer()),
+                        credential.getIssuer()).sign(credential, format).serialize());
             } catch (final SignatureException e) {
                 log.error("{} Signing credential failed", getLogPrefix(), e);
                 ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainJWSTokenSigner.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainJWSTokenSigner.java
new file mode 100644
index 0000000..d0e39c1
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainJWSTokenSigner.java
@@ -0,0 +1,134 @@
+/*
+ * 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.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.x509.X509Credential;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSObject.State;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.oidc.security.impl.JWSTokenSigner;
+import net.shibboleth.oidc.security.jose.SignatureException;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+
+/**
+ * Signer that names the signing key with the certificate chain of an X.509
+ * credential, the 'x5c' header of RFC 7515 section 4.1.6, in place of a 'kid'.
+ */
+public class CertificateChainJWSTokenSigner extends JWSTokenSigner {
+
+    /** Prefix of a DID issuer. */
+    @Nonnull
+    private static final String DID_PREFIX = "did:";
+
+    /** The signature signing parameters. */
+    @Nonnull
+    private final SignatureSigningParameters parameters;
+
+    /** Issuer of the token to sign. */
+    @Nullable
+    private final String issuer;
+
+    /**
+     * Constructor.
+     *
+     * @param signingParams the algorithm and credential to use during signing
+     * @param tokenIssuer   issuer of the token to sign
+     */
+    public CertificateChainJWSTokenSigner(@Nonnull final SignatureSigningParameters signingParams,
+            @Nullable final String tokenIssuer) {
+        super(signingParams);
+
+        parameters = signingParams;
+        issuer = tokenIssuer;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public SignedJWT sign(@Nonnull final JWTClaimsSet jwtClaims, @Nullable final String typeHeader)
+            throws SignatureException {
+
+        final Credential credential = parameters.getSigningCredential();
+        final String algorithm = parameters.getSignatureAlgorithm();
+        final List<Base64> chain = resolveChain();
+        if (chain == null || credential == null || algorithm == null) {
+            return super.sign(jwtClaims, typeHeader);
+        }
+        try {
+            final JWSAlgorithm jwsAlgorithm = resolveAlgorithm(credential, algorithm);
+            final JWSSigner signer = getSigner(jwsAlgorithm, credential);
+            final JWSHeader.Builder headerBuilder = new JWSHeader.Builder(jwsAlgorithm).x509CertChain(chain);
+            if (typeHeader != null) {
+                headerBuilder.type(new JOSEObjectType(typeHeader));
+            }
+            final SignedJWT jwt = new SignedJWT(headerBuilder.build(), jwtClaims);
+            jwt.sign(signer);
+            if (jwt.getState() != State.SIGNED) {
+                throw new SignatureException("JWT was not signed, unknown cause");
+            }
+            return jwt;
+        } catch (final JOSEException e) {
+            throw new SignatureException(e.getMessage(), e);
+        }
+    }
+
+    /**
+     * Resolve the certificate chain to name the signing key with.
+     *
+     * @return the chain as the 'x5c' header carries it, or null when the key is
+     *         named otherwise
+     *
+     * @throws SignatureException if a certificate of the chain cannot be encoded
+     */
+    @Nullable
+    private List<Base64> resolveChain() throws SignatureException {
+
+        if (issuer == null || issuer.startsWith(DID_PREFIX)) {
+            return null;
+        }
+        if (!(parameters.getSigningCredential() instanceof X509Credential x509Credential)) {
+            return null;
+        }
+        final List<Base64> chain = new ArrayList<>();
+        for (final X509Certificate certificate : x509Credential.getEntityCertificateChain()) {
+            try {
+                chain.add(Base64.encode(certificate.getEncoded()));
+            } catch (final CertificateEncodingException e) {
+                throw new SignatureException("Unable to encode the certificate chain", e);
+            }
+        }
+        return chain.isEmpty() ? null : chain;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/SignStatusListToken.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/SignStatusListToken.java
index 85ba383..8991722 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/SignStatusListToken.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/SignStatusListToken.java
@@ -22,6 +22,7 @@ import java.util.function.Function;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
+import org.geant.shibboleth.plugin.openidvci.security.impl.CertificateChainJWSTokenSigner;
 import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
 import org.geant.shibboleth.plugin.openidvci.statuslist.context.StatusListTokenContext;
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
@@ -37,7 +38,6 @@ import com.nimbusds.jose.jwk.JWK;
 
 import net.shibboleth.idp.profile.AbstractProfileAction;
 import net.shibboleth.oidc.security.CredentialConversionUtil;
-import net.shibboleth.oidc.security.impl.JWSTokenSigner;
 import net.shibboleth.oidc.security.jose.SignatureException;
 import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
 import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
@@ -248,8 +248,9 @@ public class SignStatusListToken extends AbstractProfileAction {
         }
 
         try {
-            ctx.setSignedToken(new JWSTokenSigner(DidSupport.nameKeyByIssuer(parameters, ctx.getClaims().getIssuer()))
-                    .sign(ctx.getClaims(), TOKEN_TYPE).serialize());
+            ctx.setSignedToken(new CertificateChainJWSTokenSigner(
+                    DidSupport.nameKeyByIssuer(parameters, ctx.getClaims().getIssuer()),
+                    ctx.getClaims().getIssuer()).sign(ctx.getClaims(), TOKEN_TYPE).serialize());
 
             log.debug("{} Signed status list token for {}", getLogPrefix(), ctx.getClaims().getSubject());
         } catch (final SignatureException e) {
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
index 803f3c8..4ef560a 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
@@ -19,6 +19,24 @@
         p:resource="%{openidvci.signing.es.key}" />
     -->
 
+    <!--
+    A key given as a certificate and its chain instead. The chain travels in the 'x5c' header
+    of everything signed with the credential, in the order listed and leaf first, whenever the
+    issuer of the document is a url. A DID issuer resolves the key by its identifier, so a
+    chain is not emitted for one.
+    -->
+    <!--
+    <bean id="openidvci.DefaultESSigningCredential" parent="shibboleth.BasicX509CredentialFactoryBean"
+        p:privateKey="%{openidvci.signing.es.key}">
+        <property name="certificates">
+            <list>
+                <value>%{openidvci.signing.es.cert}</value>
+                <value>%{openidvci.signing.es.issuingCa}</value>
+            </list>
+        </property>
+    </bean>
+    -->
+
     <!--
     Lists ALL credentials used for signing issued Verifiable Credentials. Defining this
     list REPLACES the OP's credentials in Credential signing, it does not add to them. So
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainJWSTokenSignerTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainJWSTokenSignerTest.java
new file mode 100644
index 0000000..67c430b
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainJWSTokenSignerTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.security.PrivateKey;
+import java.security.cert.X509Certificate;
+
+import org.opensaml.security.crypto.KeySupport;
+import org.opensaml.security.x509.BasicX509Credential;
+import org.opensaml.security.x509.X509Support;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.oidc.jwa.support.SignatureConstants;
+import net.shibboleth.oidc.security.credential.BasicJWKCredential;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+
+/** Unit tests for {@link CertificateChainJWSTokenSigner}. */
+public class CertificateChainJWSTokenSignerTest {
+
+    private static final String URL_ISSUER = "https://issuer.example.org";
+
+    private static final String DID_ISSUER = "did:web:issuer.example.org";
+
+    private SignatureSigningParameters x509Parameters;
+
+    private SignatureSigningParameters jwkParameters;
+
+    private JWTClaimsSet claims;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        final X509Certificate certificate;
+        try (InputStream in = getClass().getResourceAsStream("/credentials/x5c-test-cert.pem")) {
+            certificate = X509Support.decodeCertificate(in.readAllBytes());
+        }
+        final PrivateKey privateKey;
+        try (InputStream in = getClass().getResourceAsStream("/credentials/x5c-test-key.pem")) {
+            privateKey = KeySupport.decodePrivateKey(in.readAllBytes(), null);
+        }
+        final BasicX509Credential x509Credential = new BasicX509Credential(certificate, privateKey);
+        x509Parameters = new SignatureSigningParameters();
+        x509Parameters.setSigningCredential(x509Credential);
+        x509Parameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
+
+        final ECKey key = new ECKeyGenerator(Curve.P_256).keyID("signingvci").generate();
+        final BasicJWKCredential jwkCredential = new BasicJWKCredential();
+        jwkCredential.setPublicKey(key.toECPublicKey());
+        jwkCredential.setPrivateKey(key.toECPrivateKey());
+        jwkCredential.setKid(key.getKeyID());
+        jwkParameters = new SignatureSigningParameters();
+        jwkParameters.setSigningCredential(jwkCredential);
+        jwkParameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
+
+        claims = new JWTClaimsSet.Builder().issuer(URL_ISSUER).subject("subject").build();
+    }
+
+    @Test
+    public void testChainOfUrlIssuer() throws Exception {
+        final SignedJWT jwt = new CertificateChainJWSTokenSigner(x509Parameters, URL_ISSUER).sign(claims, "dc+sd-jwt");
+        Assert.assertNotNull(jwt.getHeader().getX509CertChain());
+        Assert.assertEquals(jwt.getHeader().getX509CertChain().size(), 1);
+        Assert.assertNull(jwt.getHeader().getKeyID());
+        Assert.assertEquals(jwt.getHeader().getType().toString(), "dc+sd-jwt");
+    }
+
+    @Test
+    public void testNoChainOfDidIssuer() throws Exception {
+        final SignedJWT jwt = new CertificateChainJWSTokenSigner(x509Parameters, DID_ISSUER).sign(claims, "dc+sd-jwt");
+        Assert.assertNull(jwt.getHeader().getX509CertChain());
+    }
+
+    @Test
+    public void testNoChainWithoutCertificate() throws Exception {
+        final SignedJWT jwt = new CertificateChainJWSTokenSigner(jwkParameters, URL_ISSUER).sign(claims, "dc+sd-jwt");
+        Assert.assertNull(jwt.getHeader().getX509CertChain());
+        Assert.assertEquals(jwt.getHeader().getKeyID(), "signingvci");
+    }
+
+    @Test
+    public void testNoChainWithoutIssuer() throws Exception {
+        final SignedJWT jwt = new CertificateChainJWSTokenSigner(x509Parameters, null).sign(claims, "dc+sd-jwt");
+        Assert.assertNull(jwt.getHeader().getX509CertChain());
+    }
+
+}

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list