[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