[java-idp-plugin-vci] 02/02: Claims having path depth are now written correctly also to SD JWT VC. Refactoring and helpers.
Codeberg
noreply at shibboleth.net
Thu Aug 27 11:49:07 UTC 2026
This is an automated email from the git hooks/post-receive script.
codeberg pushed a commit to branch fix/SD_PATHS
in repository java-idp-plugin-vci.
View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/bbcf22bb3b0fab394b8770becfc40ef9e64b5998
commit bbcf22bb3b0fab394b8770becfc40ef9e64b5998
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Thu Aug 27 14:48:45 2026 +0300
Claims having path depth are now written correctly also to SD JWT VC. Refactoring and helpers.
---
...FormJsonLdSelectiveDisclosureJWTCredential.java | 51 ++--
.../impl/FormSelectiveDisclosureJWTCredential.java | 33 +--
.../util/SelectiveDisclosureClaimSetUtil.java | 132 +++++++---
.../util/SelectiveDisclosureClaimTree.java | 184 ++++++++++++++
.../util/SelectiveDisclosureClaimSetUtilTest.java | 266 +++++++++++++++++++--
.../util/SelectiveDisclosureClaimTreeTest.java | 214 +++++++++++++++++
6 files changed, 772 insertions(+), 108 deletions(-)
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
index 030c640..ba7bc5e 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
@@ -17,9 +17,7 @@
package org.geant.shibboleth.plugin.openidvci.profile.impl;
import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
@@ -27,10 +25,9 @@ import javax.annotation.Nonnull;
import org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
-import org.geant.shibboleth.plugin.openidvci.util.SelectiveDisclosureClaimSetUtil;
+import org.geant.shibboleth.plugin.openidvci.util.SelectiveDisclosureClaimTree;
import org.opensaml.profile.action.ActionSupport;
import org.opensaml.profile.action.EventIds;
import org.opensaml.profile.context.ProfileRequestContext;
@@ -43,6 +40,7 @@ import net.shibboleth.shared.logic.Constraint;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
/**
* Action that forms
@@ -123,48 +121,35 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
return true;
}
- @SuppressWarnings("unchecked")
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
- Map<List<String>, Map<String, Object>> sdMaps = new HashMap<>();
- for (CredentialOfferRequestedClaim claim : credential.getRequestedCredential()) {
- List<String> path = new ArrayList<>(claim.getPath());
- path.remove(path.size() - 1);
- List<String> key = List.copyOf(path);
- String leafKey = claim.getPath().get(claim.getPath().size() - 1);
- sdMaps.computeIfAbsent(key, k -> new HashMap<>()).put(leafKey, claim.getValue());
+ final SelectiveDisclosureClaimTree tree;
+ try {
+ tree = new SelectiveDisclosureClaimTree(credential.getRequestedCredential());
+ } catch (final IllegalArgumentException e) {
+ log.error("{} Unable to disclose the requested claims", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+ return;
}
- String disclosures = null;
- Map<String, Object> credentialSubject = new HashMap<>();
- for (Map.Entry<List<String>, Map<String, Object>> entry : sdMaps.entrySet()) {
- List<String> path = entry.getKey();
- Map<String, Object> claims = entry.getValue();
- Map<String, Object> map = credentialSubject;
- for (String segment : path) {
- map = (Map<String, Object>) map.computeIfAbsent(segment, k -> new HashMap<String, Object>());
- }
- SelectiveDisclosureClaimSetUtil sdActClaims = new SelectiveDisclosureClaimSetUtil(claims);
- map.put("_sd", sdActClaims.get_sd());
- disclosures = (disclosures == null) ? sdActClaims.getFormattedDisclosures()
- : disclosures + "~" + sdActClaims.getFormattedDisclosures();
- }
- List<JWTClaimsSet> credentials = new ArrayList<>();
+ final List<JWTClaimsSet> credentials = new ArrayList<>();
final String issuer = issuerLookupStrategy.apply(profileRequestContext);
- ctx.getCredentialShells().forEach(cred -> {
+ for (final ClaimsSet cred : ctx.getCredentialShells()) {
try {
+ // Claims of this format are inside 'credentialSubject'.
credentials.add(new JWTClaimsSet.Builder(cred.toJWTClaimsSet())
.claim("@context", ctx.getCredentialConfiguration().getCredentialDefinition().getContext())
.claim("id", "urn:uuid:" + UUID.randomUUID())
.claim("type", ctx.getCredentialConfiguration().getCredentialDefinition().getType())
- .claim("issuer", issuer).claim("credentialSubject", credentialSubject)
- .claim("_sd_alg", "sha-256").build());
- } catch (ParseException e) {
+ .claim("issuer", issuer).claim("credentialSubject", tree.getClaims())
+ .claim("_sd_alg", tree.getAlgorithm()).build());
+ } catch (final ParseException e) {
log.error("{} Parsing credential failed", getLogPrefix(), e);
ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+ return;
}
- });
+ }
ctx.setJWTCredentials(credentials);
- ctx.setDisclosures(disclosures);
+ ctx.setDisclosures(tree.getFormattedDisclosures());
}
}
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormSelectiveDisclosureJWTCredential.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormSelectiveDisclosureJWTCredential.java
index 4715d6c..490df3b 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormSelectiveDisclosureJWTCredential.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormSelectiveDisclosureJWTCredential.java
@@ -17,16 +17,14 @@
package org.geant.shibboleth.plugin.openidvci.profile.impl;
import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
import javax.annotation.Nonnull;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
-import org.geant.shibboleth.plugin.openidvci.util.SelectiveDisclosureClaimSetUtil;
+import org.geant.shibboleth.plugin.openidvci.util.SelectiveDisclosureClaimTree;
import org.opensaml.profile.action.ActionSupport;
import org.opensaml.profile.action.EventIds;
import org.opensaml.profile.context.ProfileRequestContext;
@@ -38,6 +36,7 @@ import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
/**
* Action that forms
@@ -99,22 +98,28 @@ public class FormSelectiveDisclosureJWTCredential extends AbstractProfileAction
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
- Map<String, Object> claims = new HashMap<String, Object>();
- // TODO: support for depth of more than 1.
- credential.getRequestedCredential().forEach(claim -> claims.put(claim.getPath().get(0), claim.getValue()));
- SelectiveDisclosureClaimSetUtil sdClaims = new SelectiveDisclosureClaimSetUtil(claims);
- List<JWTClaimsSet> credentials = new ArrayList<>();
- ctx.getCredentialShells().forEach(cred -> {
+ final SelectiveDisclosureClaimTree tree;
+ try {
+ tree = new SelectiveDisclosureClaimTree(credential.getRequestedCredential());
+ } catch (final IllegalArgumentException e) {
+ log.error("{} Unable to disclose the requested claims", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+ return;
+ }
+ // Claims of this format are on top level of the credential.
+ final List<JWTClaimsSet> credentials = new ArrayList<>();
+ for (final ClaimsSet cred : ctx.getCredentialShells()) {
try {
- credentials.add(new JWTClaimsSet.Builder(cred.toJWTClaimsSet()).claim("_sd", sdClaims.get_sd())
- .claim("_sd_alg", sdClaims.get_alg()).build());
- } catch (ParseException e) {
+ final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder(cred.toJWTClaimsSet());
+ tree.getClaims().forEach(builder::claim);
+ credentials.add(builder.claim("_sd_alg", tree.getAlgorithm()).build());
+ } catch (final ParseException e) {
log.error("{} Parsing credential failed", getLogPrefix(), e);
ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
return;
}
- });
+ }
ctx.setJWTCredentials(credentials);
- ctx.setDisclosures(sdClaims.getFormattedDisclosures());
+ ctx.setDisclosures(tree.getFormattedDisclosures());
}
}
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimSetUtil.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimSetUtil.java
index 1dbfdfe..1c0d76a 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimSetUtil.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimSetUtil.java
@@ -21,80 +21,142 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+
import net.shibboleth.shared.security.IdentifierGenerationStrategy;
import net.shibboleth.shared.security.impl.SecureRandomIdentifierGenerationStrategy;
/**
* Helper to create SD JWT claims and disclosures.
*
+ * Claims given to this helper are all on same level. Disclosure is JSON array
+ * of salt, claim name and claim value, and value keeps its type.
*/
public class SelectiveDisclosureClaimSetUtil {
+ /** Claims to disclose. */
+ @Nonnull
private final Map<String, Object> sdClaims;
+ /** Digest algorithm of disclosures. */
+ @Nonnull
private final String algorithm;
+ /** Disclosures, base64url encoded. */
private List<String> urlEncodedDisclosures;
+ /** Digests of disclosures, base64url encoded. */
private List<String> encodedDisclosures;
- IdentifierGenerationStrategy strategy = new SecureRandomIdentifierGenerationStrategy();
-
- public SelectiveDisclosureClaimSetUtil(Map<String, Object> claims) {
- sdClaims = claims;
- algorithm = "sha-256";
- process();
+ /** Source of disclosure salts. */
+ @Nonnull
+ private final IdentifierGenerationStrategy strategy = new SecureRandomIdentifierGenerationStrategy();
+
+ /** Mapper serializing disclosures. */
+ @Nonnull
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /**
+ * Constructor.
+ *
+ * @param claims claims to disclose
+ */
+ public SelectiveDisclosureClaimSetUtil(@Nonnull final Map<String, Object> claims) {
+ this(claims, "sha-256");
}
- public SelectiveDisclosureClaimSetUtil(Map<String, Object> claims, String algorithm) {
+ /**
+ * Constructor.
+ *
+ * @param claims claims to disclose
+ * @param algorithm digest algorithm of disclosures
+ */
+ public SelectiveDisclosureClaimSetUtil(@Nonnull final Map<String, Object> claims, @Nonnull final String algorithm) {
sdClaims = claims;
this.algorithm = algorithm;
process();
}
+ /**
+ * Get digests of the disclosures, for '_sd' claim.
+ *
+ * @return digests of the disclosures
+ */
public List<String> get_sd() {
return encodedDisclosures;
}
+ /**
+ * Get the disclosures, separated by '~'.
+ *
+ * @return the disclosures
+ */
public String getFormattedDisclosures() {
return urlEncodedDisclosures.stream().map(String::valueOf).collect(Collectors.joining("~"));
}
- private String process() {
- urlEncodedDisclosures = new ArrayList<String>();
- encodedDisclosures = new ArrayList<String>();
- List<String> disclosures = new ArrayList<String>();
- sdClaims.keySet().forEach(key -> disclosures.add(
- "[\"" + Base64.getUrlEncoder().withoutPadding().encodeToString(strategy.generateIdentifier().getBytes())
- + "\", \"" + key + "\", \"" + sdClaims.get(key).toString() + "\"]"));
- disclosures.forEach(disclosure -> urlEncodedDisclosures.add(
- Base64.getUrlEncoder().withoutPadding().encodeToString(disclosure.getBytes(StandardCharsets.UTF_8))));
- List<byte[]> hashedDisclosures = new ArrayList<byte[]>();
- urlEncodedDisclosures.forEach(disclosure -> {
- try {
- hashedDisclosures
- .add(MessageDigest.getInstance("SHA-256").digest(disclosure.getBytes(StandardCharsets.UTF_8)));
- } catch (NoSuchAlgorithmException e) {
- // Very unlikely to happen.
- e.printStackTrace();
- }
- });
- hashedDisclosures.forEach(disclosure -> encodedDisclosures
- .add(Base64.getUrlEncoder().withoutPadding().encodeToString(disclosure)));
- String ret = "";
- for (int i = 0; i < encodedDisclosures.size(); i++) {
- ret += "~" + encodedDisclosures.get(i);
- }
- return ret;
+ /**
+ * Get digest algorithm of disclosures, for '_sd_alg' claim.
+ *
+ * @return digest algorithm of disclosures
+ */
+ public String get_alg() {
+ return algorithm.toLowerCase();
+ }
+ /** Form the disclosures and their digests. */
+ private void process() {
+ urlEncodedDisclosures = new ArrayList<>();
+ encodedDisclosures = new ArrayList<>();
+ final MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance(algorithm);
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IllegalArgumentException("Unsupported disclosure digest algorithm " + algorithm, e);
+ }
+ for (final Map.Entry<String, Object> claim : sdClaims.entrySet()) {
+ final String disclosure = Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(encode(claim.getKey(), claim.getValue()).getBytes(StandardCharsets.UTF_8));
+ urlEncodedDisclosures.add(disclosure);
+ encodedDisclosures.add(Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(digest.digest(disclosure.getBytes(StandardCharsets.UTF_8))));
+ }
+ // We mess up the order so it cannot be used to determine anything in '_sd'.
+ Collections.sort(encodedDisclosures);
}
- public String get_alg() {
- return algorithm.toLowerCase();
+ /**
+ * Serialize single disclosure as JSON array of salt, claim name and claim
+ * value.
+ *
+ * @param name name of the claim
+ * @param value value of the claim
+ *
+ * @return the disclosure
+ */
+ @Nonnull
+ private String encode(@Nonnull final String name, final Object value) {
+ final JsonNode encodedValue = objectMapper.valueToTree(value);
+ final ArrayNode disclosure = objectMapper.createArrayNode();
+ disclosure.add(strategy.generateIdentifier());
+ disclosure.add(name);
+ disclosure.add(encodedValue);
+ try {
+ return objectMapper.writeValueAsString(disclosure);
+ } catch (final JsonProcessingException e) {
+ // Serializing a tree of nodes does not fail.
+ throw new IllegalArgumentException("Unable to serialize disclosure of claim " + name, e);
+ }
}
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimTree.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimTree.java
new file mode 100644
index 0000000..5f70872
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimTree.java
@@ -0,0 +1,184 @@
+/*
+ * 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.util;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
+
+/**
+ * Helper to create SD JWT claims and disclosures of claims having a path.
+ *
+ * Claims of same parent path are disclosed together and their '_sd' is placed
+ * to that parent. Only leaf claims are disclosed.
+ */
+public class SelectiveDisclosureClaimTree {
+
+ /** Name of the claim carrying the digests. */
+ @Nonnull
+ private static final String SD_CLAIM = "_sd";
+
+ /** Objects on the paths, with '_sd' of the leaf claims. */
+ @Nonnull
+ private final Map<String, Object> claims;
+
+ /** Disclosures of all levels, separated by '~'. */
+ @Nonnull
+ private final String disclosures;
+
+ /** Digest algorithm of disclosures. */
+ @Nonnull
+ private final String algorithm;
+
+ /**
+ * Constructor.
+ *
+ * @param requested claims to disclose
+ *
+ * @throws IllegalArgumentException if the claims cannot form a tree
+ */
+ public SelectiveDisclosureClaimTree(@Nonnull final Collection<CredentialOfferRequestedClaim> requested) {
+ this(requested, "sha-256");
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param requested claims to disclose
+ * @param algorithm digest algorithm of disclosures
+ *
+ * @throws IllegalArgumentException if the claims cannot form a tree
+ */
+ public SelectiveDisclosureClaimTree(@Nonnull final Collection<CredentialOfferRequestedClaim> requested,
+ @Nonnull final String algorithm) {
+
+ this.algorithm = algorithm;
+ validate(requested);
+
+ // Claims of same parent are disclosed together.
+ final Map<List<String>, Map<String, Object>> levels = new LinkedHashMap<>();
+ for (final CredentialOfferRequestedClaim claim : requested) {
+ final List<String> path = claim.getPath();
+ final List<String> parent = List.copyOf(path.subList(0, path.size() - 1));
+ levels.computeIfAbsent(parent, k -> new LinkedHashMap<>()).put(path.get(path.size() - 1), claim.getValue());
+ }
+
+ claims = new LinkedHashMap<>();
+ final List<String> parts = new ArrayList<>();
+ for (final Map.Entry<List<String>, Map<String, Object>> level : levels.entrySet()) {
+ final SelectiveDisclosureClaimSetUtil disclosed = new SelectiveDisclosureClaimSetUtil(level.getValue(),
+ algorithm);
+ resolve(level.getKey()).put(SD_CLAIM, disclosed.get_sd());
+ parts.add(disclosed.getFormattedDisclosures());
+ }
+ disclosures = String.join("~", parts);
+ }
+
+ /**
+ * Get objects on the paths, with '_sd' of the leaf claims.
+ *
+ * @return the claims
+ */
+ @Nonnull
+ public Map<String, Object> getClaims() {
+ return claims;
+ }
+
+ /**
+ * Get the disclosures of all levels, separated by '~'.
+ *
+ * @return the disclosures
+ */
+ @Nonnull
+ public String getFormattedDisclosures() {
+ return disclosures;
+ }
+
+ /**
+ * Get digest algorithm of disclosures, for '_sd_alg' claim.
+ *
+ * @return digest algorithm of disclosures
+ */
+ @Nonnull
+ public String getAlgorithm() {
+ return algorithm.toLowerCase();
+ }
+
+ /**
+ * Check that the claims can form tree. Same claim twice, claim that is also an
+ * object on the path of another claim, and claim reserving the name of the
+ * digest claim are all rejected. Left unchecked they would silently lose a
+ * claim.
+ *
+ * @param requested claims to disclose
+ *
+ * @throws IllegalArgumentException if the claims cannot form a tree
+ */
+ private void validate(@Nonnull final Collection<CredentialOfferRequestedClaim> requested) {
+
+ final Set<List<String>> objects = new HashSet<>();
+ for (final CredentialOfferRequestedClaim claim : requested) {
+ final List<String> path = claim.getPath();
+ for (int i = 1; i < path.size(); i++) {
+ objects.add(List.copyOf(path.subList(0, i)));
+ }
+ }
+
+ final Set<List<String>> seen = new HashSet<>();
+ for (final CredentialOfferRequestedClaim claim : requested) {
+ final List<String> path = List.copyOf(claim.getPath());
+ if (path.contains(SD_CLAIM)) {
+ throw new IllegalArgumentException(
+ "Claim " + String.join("/", path) + " uses reserved name " + SD_CLAIM);
+ }
+ if (objects.contains(path)) {
+ throw new IllegalArgumentException(
+ "Claim " + String.join("/", path) + " is also an object on path of another claim");
+ }
+ if (!seen.add(path)) {
+ throw new IllegalArgumentException("Claim " + String.join("/", path) + " is requested twice");
+ }
+ }
+ }
+
+ /**
+ * Resolve the object of a path, creating objects on the way when they are not
+ * there yet.
+ *
+ * @param path path of the object
+ *
+ * @return the object
+ */
+ @SuppressWarnings("unchecked")
+ @Nonnull
+ private Map<String, Object> resolve(@Nonnull final List<String> path) {
+ Map<String, Object> object = claims;
+ for (final String segment : path) {
+ object = (Map<String, Object>) object.computeIfAbsent(segment, k -> new LinkedHashMap<String, Object>());
+ }
+ return object;
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimSetUtilTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimSetUtilTest.java
index 5481d93..50d5b99 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimSetUtilTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimSetUtilTest.java
@@ -1,48 +1,262 @@
+/*
+ * 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.util;
-import java.security.NoSuchAlgorithmException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Base64;
import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
+
import org.testng.Assert;
-import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
-import com.nimbusds.jose.jwk.ECKey;
-import com.nimbusds.oauth2.sdk.ParseException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
public class SelectiveDisclosureClaimSetUtilTest {
- SelectiveDisclosureClaimSetUtil claimSet;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ private JsonNode decode(final String disclosure) throws Exception {
+ return mapper.readTree(new String(Base64.getUrlDecoder().decode(disclosure), StandardCharsets.UTF_8));
+ }
+
+ private List<JsonNode> decodeAll(final SelectiveDisclosureClaimSetUtil util) throws Exception {
+ final List<JsonNode> decoded = new ArrayList<>();
+ for (final String disclosure : util.getFormattedDisclosures().split("~")) {
+ decoded.add(decode(disclosure));
+ }
+ return decoded;
+ }
+
+ private JsonNode valueOf(final SelectiveDisclosureClaimSetUtil util, final String name) throws Exception {
+ for (final JsonNode disclosure : decodeAll(util)) {
+ if (name.equals(disclosure.get(1).asText())) {
+ return disclosure.get(2);
+ }
+ }
+ Assert.fail("No disclosure for claim " + name);
+ return null;
+ }
+
+ @Test
+ public void testDisclosureIsArrayOfSaltNameAndValue() throws Exception {
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of("given_name", "John"));
+
+ final List<JsonNode> disclosures = decodeAll(util);
+ Assert.assertEquals(disclosures.size(), 1);
+
+ final JsonNode disclosure = disclosures.get(0);
+ Assert.assertTrue(disclosure.isArray());
+ Assert.assertEquals(disclosure.size(), 3);
+ Assert.assertTrue(disclosure.get(0).isTextual());
+ Assert.assertFalse(disclosure.get(0).asText().isEmpty());
+ Assert.assertEquals(disclosure.get(1).asText(), "given_name");
+ Assert.assertEquals(disclosure.get(2).asText(), "John");
+ }
+
+ @Test
+ public void testStringValueKeepsType() throws Exception {
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of("given_name", "John"));
+
+ final JsonNode value = valueOf(util, "given_name");
+ Assert.assertTrue(value.isTextual());
+ Assert.assertEquals(value.asText(), "John");
+ }
+
+ @Test
+ public void testBooleanValueKeepsType() throws Exception {
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of("is_over_18", Boolean.TRUE));
+
+ final JsonNode value = valueOf(util, "is_over_18");
+ Assert.assertTrue(value.isBoolean());
+ Assert.assertTrue(value.booleanValue());
+ }
+
+ @Test
+ public void testNumberValueKeepsType() throws Exception {
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of("age", Integer.valueOf(42)));
+
+ final JsonNode value = valueOf(util, "age");
+ Assert.assertTrue(value.isNumber());
+ Assert.assertEquals(value.intValue(), 42);
+ }
- @BeforeMethod
- protected void setUp() throws Exception {
-
- Map<String, Object> claims = new HashMap<String, Object> ();
+ @Test
+ public void testObjectValueKeepsType() throws Exception {
+ final Map<String, String> address = new HashMap<>();
+ address.put("street_address", "123 Main St");
+ address.put("locality", "Anytown");
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of("address", address));
+
+ final JsonNode value = valueOf(util, "address");
+ Assert.assertTrue(value.isObject());
+ Assert.assertEquals(value.get("street_address").asText(), "123 Main St");
+ Assert.assertEquals(value.get("locality").asText(), "Anytown");
+ }
+
+ @Test
+ public void testArrayValueKeepsType() throws Exception {
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of("nationalities", List.of("FI", "SE")));
+
+ final JsonNode value = valueOf(util, "nationalities");
+ Assert.assertTrue(value.isArray());
+ Assert.assertEquals(value.size(), 2);
+ Assert.assertEquals(value.get(0).asText(), "FI");
+ Assert.assertEquals(value.get(1).asText(), "SE");
+ }
+
+ @Test
+ public void testValueWithJsonSyntaxIsEscaped() throws Exception {
+ final String awkward = "O'Brien \"Bob\" \\ back\nnewline\ttab";
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of("nickname", awkward));
+
+ final JsonNode value = valueOf(util, "nickname");
+ Assert.assertTrue(value.isTextual());
+ Assert.assertEquals(value.asText(), awkward);
+ }
+
+ @Test
+ public void testNameWithJsonSyntaxIsEscaped() throws Exception {
+ final String awkward = "od\"d\\name";
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of(awkward, "value"));
+
+ final JsonNode disclosure = decodeAll(util).get(0);
+ Assert.assertEquals(disclosure.get(1).asText(), awkward);
+ Assert.assertEquals(disclosure.get(2).asText(), "value");
+ }
+
+ @Test
+ public void testDigestMatchesDisclosure() throws Exception {
+ final Map<String, Object> claims = new HashMap<>();
+ claims.put("given_name", "John");
+ claims.put("family_name", "Doe");
+ claims.put("is_over_18", Boolean.TRUE);
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(claims);
+
+ final MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ for (final String disclosure : util.getFormattedDisclosures().split("~")) {
+ final String expected = Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(digest.digest(disclosure.getBytes(StandardCharsets.UTF_8)));
+ Assert.assertTrue(util.get_sd().contains(expected), "Missing digest of disclosure " + disclosure);
+ }
+ Assert.assertEquals(util.get_sd().size(), 3);
+ }
+
+ @Test
+ public void testDigestsAreSorted() {
+ final Map<String, Object> claims = new HashMap<>();
claims.put("given_name", "John");
claims.put("family_name", "Doe");
claims.put("email", "johndoe at example.com");
claims.put("phone_number", "+1-202-555-0101");
- Map<String, String> address = new HashMap<String, String>();
- address.put("street_address", "123 Main St");
- address.put("locality", "Anytown");
- address.put("street_address", "123 Main St");
- address.put("region", "Anystate");
- address.put("country", "US");
- claims.put("address", address);
claims.put("birthdate", "1940-01-01");
- claims.put("is_over_18", true);
- claims.put("is_over_21", true);
- claims.put("is_over_65", true);
- claimSet = new SelectiveDisclosureClaimSetUtil(claims);
-
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(claims);
+
+ final List<String> sorted = new ArrayList<>(util.get_sd());
+ sorted.sort(null);
+ Assert.assertEquals(util.get_sd(), sorted);
+ }
+
+ @Test
+ public void testSaltsAreUnique() throws Exception {
+ final Map<String, Object> claims = new HashMap<>();
+ claims.put("given_name", "John");
+ claims.put("family_name", "Doe");
+ claims.put("email", "johndoe at example.com");
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(claims);
+
+ final Set<String> salts = new HashSet<>();
+ for (final JsonNode disclosure : decodeAll(util)) {
+ salts.add(disclosure.get(0).asText());
+ }
+ Assert.assertEquals(salts.size(), 3);
}
@Test
- public void testBasicAuthSuccess() throws ParseException, NoSuchAlgorithmException, java.text.ParseException {
+ public void testSameClaimGetsDifferentSaltEachTime() throws Exception {
+ final String first = decodeAll(
+ new SelectiveDisclosureClaimSetUtil(Map.<String, Object>of("given_name", "John"))).get(0).get(0)
+ .asText();
+ final String second = decodeAll(
+ new SelectiveDisclosureClaimSetUtil(Map.<String, Object>of("given_name", "John"))).get(0).get(0)
+ .asText();
+ Assert.assertNotEquals(first, second);
+ }
-
- //Assert.assertEquals(claimSet.getFormattedDisclosures(), "");
- ECKey ecJWK = ECKey.parse("{\"kty\":\"EC\",\"d\":\"_C4BcEXyIbnNY1njFip-e4yjO4U1hxOHuQ_tHJp8FlM\",\"crv\":\"P-256\",\"x\":\"qQ9ESeIrQ36JijWM-8xdcjXwY46RW3p9YDtP0MVaLnE\",\"y\":\"5yVZRqwp1tde_CnKC662wW-XFQhEOrGAPi0LTG4OpI8\"}");
-
+ @Test
+ public void testDisclosureCountMatchesClaimCount() {
+ final Map<String, Object> claims = new HashMap<>();
+ claims.put("given_name", "John");
+ claims.put("family_name", "Doe");
+ claims.put("email", "johndoe at example.com");
+ claims.put("phone_number", "+1-202-555-0101");
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(claims);
+
+ Assert.assertEquals(util.getFormattedDisclosures().split("~").length, 4);
+ Assert.assertEquals(util.get_sd().size(), 4);
+ }
+
+ @Test
+ public void testDefaultAlgorithm() {
+ Assert.assertEquals(new SelectiveDisclosureClaimSetUtil(Map.<String, Object>of("given_name", "John")).get_alg(),
+ "sha-256");
+ }
+
+ @Test
+ public void testAlgorithmIsLowerCased() {
+ Assert.assertEquals(
+ new SelectiveDisclosureClaimSetUtil(Map.<String, Object>of("given_name", "John"), "SHA-512").get_alg(),
+ "sha-512");
+ }
+
+ @Test
+ public void testConfiguredAlgorithmIsUsedForDigest() throws Exception {
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(
+ Map.<String, Object>of("given_name", "John"), "sha-512");
+
+ final String disclosure = util.getFormattedDisclosures();
+ final String expected = Base64.getUrlEncoder().withoutPadding().encodeToString(
+ MessageDigest.getInstance("SHA-512").digest(disclosure.getBytes(StandardCharsets.UTF_8)));
+ Assert.assertEquals(util.get_sd().get(0), expected);
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testUnsupportedAlgorithmRejected() {
+ new SelectiveDisclosureClaimSetUtil(Map.<String, Object>of("given_name", "John"), "sha-999");
+ }
+
+ @Test
+ public void testNoClaims() {
+ final SelectiveDisclosureClaimSetUtil util = new SelectiveDisclosureClaimSetUtil(Map.of());
+ Assert.assertTrue(util.get_sd().isEmpty());
+ Assert.assertEquals(util.getFormattedDisclosures(), "");
}
}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimTreeTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimTreeTest.java
new file mode 100644
index 0000000..afcaf0f
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/util/SelectiveDisclosureClaimTreeTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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.util;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class SelectiveDisclosureClaimTreeTest {
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ private CredentialOfferRequestedClaim claim(final Object value, final String... path) throws Exception {
+ final Map<String, Object> map = new HashMap<>();
+ map.put("path", List.of(path));
+ map.put("value", value);
+ return CredentialOfferRequestedClaim.parse(map);
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map<String, Object> object(final Map<String, Object> parent, final String name) {
+ final Object child = parent.get(name);
+ Assert.assertTrue(child instanceof Map, "Expected object at " + name);
+ return (Map<String, Object>) child;
+ }
+
+ @SuppressWarnings("unchecked")
+ private List<String> sd(final Map<String, Object> object) {
+ final Object digests = object.get("_sd");
+ Assert.assertTrue(digests instanceof List, "Expected _sd digests");
+ return (List<String>) digests;
+ }
+
+ private Set<String> disclosedNames(final SelectiveDisclosureClaimTree tree) throws Exception {
+ final Set<String> names = new HashSet<>();
+ for (final String disclosure : tree.getFormattedDisclosures().split("~")) {
+ final JsonNode decoded =
+ mapper.readTree(new String(Base64.getUrlDecoder().decode(disclosure), StandardCharsets.UTF_8));
+ names.add(decoded.get(1).asText());
+ }
+ return names;
+ }
+
+ @Test
+ public void testFlatClaimsAreDisclosedAtRoot() throws Exception {
+ final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+ List.of(claim("John", "given_name"), claim("Doe", "family_name")));
+
+ Assert.assertEquals(tree.getClaims().keySet(), Set.of("_sd"));
+ Assert.assertEquals(sd(tree.getClaims()).size(), 2);
+ Assert.assertEquals(disclosedNames(tree), Set.of("given_name", "family_name"));
+ }
+
+ @Test
+ public void testLeafValuesAreNotInTheTree() throws Exception {
+ final SelectiveDisclosureClaimTree tree =
+ new SelectiveDisclosureClaimTree(List.of(claim("John", "given_name")));
+
+ Assert.assertFalse(tree.getClaims().containsKey("given_name"));
+ }
+
+ @Test
+ public void testNestedClaimsShareOneObject() throws Exception {
+ final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+ List.of(claim("Anytown", "address", "locality"), claim("US", "address", "country")));
+
+ Assert.assertEquals(tree.getClaims().keySet(), Set.of("address"));
+ Assert.assertEquals(sd(object(tree.getClaims(), "address")).size(), 2);
+ Assert.assertEquals(disclosedNames(tree), Set.of("locality", "country"));
+ }
+
+ @Test
+ public void testFlatAndNestedClaimsCoexist() throws Exception {
+ final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(List.of(claim("John", "given_name"),
+ claim("Anytown", "address", "locality"), claim("US", "address", "country")));
+
+ Assert.assertEquals(tree.getClaims().keySet(), Set.of("_sd", "address"));
+ Assert.assertEquals(sd(tree.getClaims()).size(), 1);
+ Assert.assertEquals(sd(object(tree.getClaims(), "address")).size(), 2);
+ Assert.assertEquals(disclosedNames(tree), Set.of("given_name", "locality", "country"));
+ }
+
+ @Test
+ public void testDeepNesting() throws Exception {
+ final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+ List.of(claim(1, "a", "b", "c"), claim(2, "a", "b", "d"), claim(3, "a", "e")));
+
+ final Map<String, Object> a = object(tree.getClaims(), "a");
+ Assert.assertEquals(a.keySet(), Set.of("b", "_sd"));
+ Assert.assertEquals(sd(a).size(), 1);
+ Assert.assertEquals(sd(object(a, "b")).size(), 2);
+ Assert.assertEquals(disclosedNames(tree), Set.of("c", "d", "e"));
+ }
+
+ @Test
+ public void testSeparateBranches() throws Exception {
+ final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+ List.of(claim("Anytown", "address", "locality"), claim("Suomi", "birth_place", "country")));
+
+ Assert.assertEquals(tree.getClaims().keySet(), Set.of("address", "birth_place"));
+ Assert.assertEquals(sd(object(tree.getClaims(), "address")).size(), 1);
+ Assert.assertEquals(sd(object(tree.getClaims(), "birth_place")).size(), 1);
+ }
+
+ @Test
+ public void testDisclosureCountMatchesClaimCount() throws Exception {
+ final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(List.of(claim("John", "given_name"),
+ claim("Anytown", "address", "locality"), claim("US", "address", "country"),
+ claim(1, "a", "b", "c")));
+
+ Assert.assertEquals(tree.getFormattedDisclosures().split("~").length, 4);
+ }
+
+ @Test
+ public void testValueTypesSurvive() throws Exception {
+ final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+ List.of(claim(Boolean.TRUE, "address", "verified"), claim(Integer.valueOf(42), "address", "floor")));
+
+ final List<JsonNode> decoded = new ArrayList<>();
+ for (final String disclosure : tree.getFormattedDisclosures().split("~")) {
+ decoded.add(mapper.readTree(new String(Base64.getUrlDecoder().decode(disclosure), StandardCharsets.UTF_8)));
+ }
+ for (final JsonNode disclosure : decoded) {
+ if ("verified".equals(disclosure.get(1).asText())) {
+ Assert.assertTrue(disclosure.get(2).isBoolean());
+ Assert.assertTrue(disclosure.get(2).booleanValue());
+ } else {
+ Assert.assertTrue(disclosure.get(2).isNumber());
+ Assert.assertEquals(disclosure.get(2).intValue(), 42);
+ }
+ }
+ Assert.assertEquals(decoded.size(), 2);
+ }
+
+ @Test
+ public void testAlgorithm() throws Exception {
+ Assert.assertEquals(new SelectiveDisclosureClaimTree(List.of(claim("John", "given_name"))).getAlgorithm(),
+ "sha-256");
+ Assert.assertEquals(
+ new SelectiveDisclosureClaimTree(List.of(claim("John", "given_name")), "SHA-512").getAlgorithm(),
+ "sha-512");
+ }
+
+ @Test
+ public void testNoClaims() {
+ final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(List.of());
+ Assert.assertTrue(tree.getClaims().isEmpty());
+ Assert.assertEquals(tree.getFormattedDisclosures(), "");
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testSameClaimTwiceRejected() throws Exception {
+ new SelectiveDisclosureClaimTree(List.of(claim("John", "given_name"), claim("Jack", "given_name")));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testSameNestedClaimTwiceRejected() throws Exception {
+ new SelectiveDisclosureClaimTree(
+ List.of(claim("Anytown", "address", "locality"), claim("Othertown", "address", "locality")));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testClaimThatIsAlsoObjectRejected() throws Exception {
+ new SelectiveDisclosureClaimTree(List.of(claim("whole thing", "address"), claim("Anytown", "address",
+ "locality")));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testClaimThatIsAlsoObjectRejectedInReverseOrder() throws Exception {
+ new SelectiveDisclosureClaimTree(List.of(claim("Anytown", "address", "locality"), claim("whole thing",
+ "address")));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testDeepClaimThatIsAlsoObjectRejected() throws Exception {
+ new SelectiveDisclosureClaimTree(List.of(claim(1, "a", "b"), claim(2, "a", "b", "c")));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testReservedLeafNameRejected() throws Exception {
+ new SelectiveDisclosureClaimTree(List.of(claim("value", "_sd")));
+ }
+
+ @Test(expectedExceptions = IllegalArgumentException.class)
+ public void testReservedObjectNameRejected() throws Exception {
+ new SelectiveDisclosureClaimTree(List.of(claim("value", "address", "_sd", "locality")));
+ }
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list