[java-idp-plugin-vci] branch main updated: Bind holder key in cnf by the advertised binding methods

Codeberg noreply at shibboleth.net
Thu Oct 8 07:25:31 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/cb4cace7f4fcaa58f1fbb0891169ef7174cbaa25

The following commit(s) were added to refs/heads/main by this push:
     new cb4cace  Bind holder key in cnf by the advertised binding methods
cb4cace is described below

commit cb4cace7f4fcaa58f1fbb0891169ef7174cbaa25
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Thu Oct 8 10:25:08 2026 +0300

    Bind holder key in cnf by the advertised binding methods
---
 .../openidvci/profile/impl/AddCredentialShell.java |  30 ++--
 .../profile/impl/AddMobileSecurityObjectShell.java |  10 +-
 .../plugin/openidvci/security/impl/DidSupport.java |  19 +++
 .../security/impl/KeyAttestationValidator.java     |  13 +-
 .../profile/impl/AddCredentialShellTest.java       | 167 +++++++++++++++++++++
 .../openidvci/security/impl/DidSupportTest.java    |  23 +++
 6 files changed, 237 insertions(+), 25 deletions(-)

diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
index 547c7ef..c427552 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
@@ -44,13 +44,13 @@ import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
 import org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialIssuerLookupFunction;
 import org.geant.shibboleth.plugin.openidvci.statuslist.context.StatusListContext;
 import org.geant.shibboleth.plugin.openidvci.profile.config.OpenIDVCIConfiguration;
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 
-import com.fasterxml.jackson.core.JsonProcessingException;
 import com.nimbusds.jose.jwk.JWK;
 import com.nimbusds.jwt.SignedJWT;
 import com.nimbusds.oauth2.sdk.id.Issuer;
@@ -74,6 +74,11 @@ public class AddCredentialShell extends AbstractProfileAction {
     @NotEmpty
     public static final String OPENID_FEDERATION = "OpenIDFederation";
 
+    /** Binding method of a key in the 'jwk' member of 'cnf'. */
+    @Nonnull
+    @NotEmpty
+    private static final String BINDING_METHOD_JWK = "jwk";
+
     /** Class logger. */
     @Nonnull
     private Logger log = LoggerFactory.getLogger(AddCredentialShell.class);
@@ -311,22 +316,27 @@ public class AddCredentialShell extends AbstractProfileAction {
     }
 
     /**
-     * Build cnf claim for response. A did:jwk key reference from the proof kid
-     * header is preferred, an embedded proof jwk header is the fallback.
+     * Build cnf claim for response in a binding method the credential configuration supports. A did:jwk
+     * key reference from the proof kid header is preferred, the key in the 'jwk' member is the fallback.
      *
      * @param proof previously verified proof
-     * @return cnf claim
-     * @throws JsonProcessingException if something unexpected occurs.
-     * @throws ParseException          if something unexpected occurs.
+     * @return cnf claim, or null if the proof names no key in a supported binding method
+     * @throws ParseException if the did:jwk of the proof holds no JWK
      */
-    private Map<String, Object> buildCnf(final SignedJWT proof) throws JsonProcessingException, ParseException {
+    @Nullable
+    private Map<String, Object> buildCnf(@Nonnull final SignedJWT proof) throws ParseException {
 
+        final List<String> methods = ctx.getCredentialConfiguration() != null
+                ? ctx.getCredentialConfiguration().getCryptographicBindingMethodsSupported()
+                : null;
         final String kid = proof.getHeader().getKeyID();
-        if (kid != null && kid.startsWith("did:jwk:") && kid.endsWith("#0")) {
+        final JWK didJwk = DidSupport.fromDidJwk(kid);
+        if (didJwk != null && (methods == null || methods.contains(DidSupport.DID_JWK_KEYWORD))) {
             return Map.of("kid", kid);
         }
-        if (proof.getHeader().getJWK() != null) {
-            return Map.of("jwk", proof.getHeader().getJWK().toJSONObject());
+        final JWK key = didJwk != null ? didJwk : proof.getHeader().getJWK();
+        if (key != null && (methods == null || methods.contains(BINDING_METHOD_JWK))) {
+            return Map.of("jwk", key.toPublicJWK().toJSONObject());
         }
         return null;
     }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddMobileSecurityObjectShell.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddMobileSecurityObjectShell.java
index b75542c..9bd2909 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddMobileSecurityObjectShell.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddMobileSecurityObjectShell.java
@@ -37,7 +37,6 @@ import org.slf4j.Logger;
 import com.nimbusds.jose.JOSEException;
 import com.nimbusds.jose.jwk.ECKey;
 import com.nimbusds.jose.jwk.JWK;
-import com.nimbusds.jose.util.Base64URL;
 import com.nimbusds.jwt.SignedJWT;
 
 import net.shibboleth.idp.profile.AbstractProfileAction;
@@ -179,13 +178,8 @@ public class AddMobileSecurityObjectShell extends AbstractProfileAction {
      */
     @Nullable
     private static ECPublicKey deviceKey(@Nonnull final SignedJWT proof) throws ParseException, JOSEException {
-        JWK key = proof.getHeader().getJWK();
-        final String kid = proof.getHeader().getKeyID();
-        if (key == null && kid != null && kid.startsWith(DidSupport.DID_JWK_PREFIX) && kid.endsWith("#0")) {
-            key = JWK.parse(new Base64URL(kid.substring(DidSupport.DID_JWK_PREFIX.length(), kid.length() - 2))
-                    .decodeToString());
-        }
-        return ecPublicKey(key);
+        final JWK key = proof.getHeader().getJWK();
+        return ecPublicKey(key != null ? key : DidSupport.fromDidJwk(proof.getHeader().getKeyID()));
     }
 
     /**
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupport.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupport.java
index d82ac49..e1830da 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupport.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupport.java
@@ -21,6 +21,7 @@ import java.net.URISyntaxException;
 import java.security.PrivateKey;
 import java.security.PublicKey;
 import java.security.interfaces.ECPublicKey;
+import java.text.ParseException;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -30,6 +31,7 @@ import org.opensaml.security.credential.Credential;
 
 import com.nimbusds.jose.jwk.Curve;
 import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
 import com.nimbusds.jose.util.Base64URL;
 
 import net.shibboleth.oidc.security.credential.BasicJWKCredential;
@@ -123,6 +125,23 @@ public final class DidSupport {
         return port == -1 ? DID_WEB_PREFIX + host : DID_WEB_PREFIX + host + ENCODED_PORT_SEPARATOR + port;
     }
 
+    /**
+     * Decode the key of a did:jwk DID URL naming its verification method.
+     *
+     * @param didUrl DID URL, the did:jwk identifier and '#0'
+     * @return the key, or null if the value is no such DID URL
+     * @throws ParseException if the identifier holds no JWK
+     */
+    @Nullable
+    public static JWK fromDidJwk(@Nullable final String didUrl) throws ParseException {
+
+        if (didUrl == null || !didUrl.startsWith(DID_JWK_PREFIX) || !didUrl.endsWith(DID_JWK_VERIFICATION_METHOD)) {
+            return null;
+        }
+        return JWK.parse(new Base64URL(didUrl.substring(DID_JWK_PREFIX.length(),
+                didUrl.length() - DID_JWK_VERIFICATION_METHOD.length())).decodeToString());
+    }
+
     /**
      * Encode a public key as a did:jwk identifier.
      *
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 ae90d7c..d9df6cb 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
@@ -209,14 +209,13 @@ public class KeyAttestationValidator extends AbstractInitializableComponent {
         if (header.getJWK() != null) {
             return header.getJWK();
         }
-        final String kid = header.getKeyID();
-        if (kid != null && kid.startsWith(DidSupport.DID_JWK_PREFIX)) {
-            final String encoded = kid.substring(DidSupport.DID_JWK_PREFIX.length()).split("#")[0];
-            try {
-                return JWK.parse(new String(java.util.Base64.getUrlDecoder().decode(encoded)));
-            } catch (final IllegalArgumentException | java.text.ParseException e) {
-                return null;
+        try {
+            final JWK didJwk = DidSupport.fromDidJwk(header.getKeyID());
+            if (didJwk != null) {
+                return didJwk;
             }
+        } catch (final java.text.ParseException e) {
+            return null;
         }
         if (header.getX509CertChain() != null && !header.getX509CertChain().isEmpty()) {
             final X509Certificate certificate = X509CertUtils.parse(header.getX509CertChain().get(0).decode());
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShellTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShellTest.java
new file mode 100644
index 0000000..fb99944
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShellTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.profile.impl;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultOpenIDVCICredentialsConfiguration;
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+import net.shibboleth.profile.context.RelyingPartyContext;
+
+/**
+ * Unit tests for the 'cnf' claim {@link AddCredentialShell} sets.
+ */
+public class AddCredentialShellTest {
+
+    private RequestContext requestCtx;
+
+    private ProfileRequestContext profileRequestCtx;
+
+    private CredentialsContext credentialsCtx;
+
+    private AddCredentialShell action;
+
+    private ECKey holderKey;
+
+    private String didJwk;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        holderKey = new ECKeyGenerator(Curve.P_256).generate();
+        didJwk = DidSupport.toDidJwk(holderKey.toECPublicKey()) + "#0";
+        requestCtx = new RequestContextBuilder().buildRequestContext();
+        profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+        profileRequestCtx.ensureSubcontext(RelyingPartyContext.class)
+                .setProfileConfig(new DefaultOpenIDVCICredentialsConfiguration());
+        profileRequestCtx.setInboundMessageContext(new MessageContext());
+        credentialsCtx = profileRequestCtx.getInboundMessageContext().ensureSubcontext(CredentialsContext.class);
+        final Instant now = Instant.now();
+        credentialsCtx.setIssuedAt(now);
+        credentialsCtx.setNotBefore(now);
+        credentialsCtx.setExpiresAt(now.plusSeconds(3600));
+        action = new AddCredentialShell();
+        action.setIssuerLookupStrategy(prc -> "https://issuer.example.org");
+        action.initialize();
+    }
+
+    private void configuration(final String bindingMethods) throws Exception {
+        credentialsCtx.setCredentialConfiguration(new ObjectMapper().readValue(
+                "{\"format\":\"dc+sd-jwt\",\"vct\":\"TestCredential\""
+                        + (bindingMethods == null ? "" : ",\"cryptographic_binding_methods_supported\":" + bindingMethods)
+                        + "}",
+                CredentialConfiguration.class));
+    }
+
+    private SignedJWT proofWithKid() {
+        return new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256)
+                .type(new JOSEObjectType("openid4vci-proof+jwt")).keyID(didJwk).build(),
+                new JWTClaimsSet.Builder().build());
+    }
+
+    private SignedJWT proofWithJwk() {
+        return new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256)
+                .type(new JOSEObjectType("openid4vci-proof+jwt")).jwk(holderKey.toPublicJWK()).build(),
+                new JWTClaimsSet.Builder().build());
+    }
+
+    private Object cnf() {
+        return credentialsCtx.getCredentialShells().get(0).getClaim("cnf");
+    }
+
+    private void assertCnfJwk() throws Exception {
+        final Object cnf = cnf();
+        Assert.assertTrue(cnf instanceof Map<?, ?>);
+        final Object jwk = ((Map<?, ?>) cnf).get("jwk");
+        Assert.assertTrue(jwk instanceof Map<?, ?>);
+        Assert.assertEquals(JWK.parse(new ObjectMapper().writeValueAsString(jwk)).computeThumbprint(),
+                holderKey.computeThumbprint());
+    }
+
+    @Test
+    public void testDidJwkAdvertisedKeepsKid() throws Exception {
+        configuration("[\"did:jwk\",\"jwk\"]");
+        credentialsCtx.setProofs(List.of(proofWithKid()));
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        Assert.assertEquals(cnf(), Map.of("kid", didJwk));
+    }
+
+    @Test
+    public void testOnlyJwkAdvertisedTurnsDidJwkIntoJwk() throws Exception {
+        configuration("[\"jwk\"]");
+        credentialsCtx.setProofs(List.of(proofWithKid()));
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        assertCnfJwk();
+    }
+
+    @Test
+    public void testOnlyJwkAdvertisedWithJwkProof() throws Exception {
+        configuration("[\"jwk\"]");
+        credentialsCtx.setProofs(List.of(proofWithJwk()));
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        assertCnfJwk();
+    }
+
+    @Test
+    public void testOnlyDidJwkAdvertisedRefusesJwkProof() throws Exception {
+        configuration("[\"did:jwk\"]");
+        credentialsCtx.setProofs(List.of(proofWithJwk()));
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.INVALID_PROOF);
+    }
+
+    @Test
+    public void testNothingAdvertisedKeepsKid() throws Exception {
+        configuration(null);
+        credentialsCtx.setProofs(List.of(proofWithKid()));
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        Assert.assertEquals(cnf(), Map.of("kid", didJwk));
+    }
+
+    @Test
+    public void testAttestedKeyIsJwk() throws Exception {
+        configuration("[\"jwk\"]");
+        credentialsCtx.setAttestedKeys(List.of(holderKey.toPublicJWK()));
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        assertCnfJwk();
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupportTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupportTest.java
index 6dd2f74..3791d70 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupportTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupportTest.java
@@ -128,4 +128,27 @@ public class DidSupportTest {
         };
     }
 
+    @Test
+    public void testFromDidJwk() throws Exception {
+        final ECKey key = new ECKeyGenerator(Curve.P_256).generate();
+        final String didUrl = DidSupport.toDidJwk(key.toECPublicKey()) + "#0";
+        Assert.assertEquals(DidSupport.fromDidJwk(didUrl).computeThumbprint(), key.computeThumbprint());
+    }
+
+    @Test(dataProvider = "notDidJwkUrls")
+    public void testFromDidJwkNotDidJwkUrl(final String value) throws Exception {
+        Assert.assertNull(DidSupport.fromDidJwk(value));
+    }
+
+    @DataProvider(name = "notDidJwkUrls")
+    public Object[][] notDidJwkUrls() {
+        return new Object[][] {
+            { null },
+            { "" },
+            { "did:web:example.org#key" },
+            { "did:jwk:eyJrdHkiOiJFQyJ9" },
+            { "https://example.org" },
+        };
+    }
+
 }

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


More information about the commits mailing list