[java-idp-plugin-vci] branch main updated: Disclose array elements of an SD-JWT one by one with sd in _issuance_configuration

Codeberg noreply at shibboleth.net
Thu Oct 29 15:08:44 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/369bd5bf1a6ed5e7246439190ae0f4e0908bfdc0

The following commit(s) were added to refs/heads/main by this push:
     new 369bd5b  Disclose array elements of an SD-JWT one by one with sd in _issuance_configuration
369bd5b is described below

commit 369bd5bf1a6ed5e7246439190ae0f4e0908bfdc0
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Thu Oct 29 17:08:20 2026 +0200

    Disclose array elements of an SD-JWT one by one with sd in _issuance_configuration
---
 README.md                                          |  29 ++++-
 .../openidvci/credential/ClaimDisclosure.java      | 116 ++++++++++++++++++
 .../credential/CredentialConfiguration.java        |  32 ++++-
 .../CredentialIssuanceConfiguration.java           |  18 ++-
 .../CredentialIssuanceConfigurationTest.java       |  72 ++++++++++++
 .../impl/SelectiveDisclosureArrayElements.java     | 130 +++++++++++++++++++++
 .../impl/SelectiveDisclosureClaimTree.java         |  44 ++++++-
 ...FormJsonLdSelectiveDisclosureJWTCredential.java |   9 +-
 .../impl/FormSelectiveDisclosureJWTCredential.java |   7 +-
 .../impl/SelectiveDisclosureClaimTreeTest.java     |  84 +++++++++++++
 10 files changed, 527 insertions(+), 14 deletions(-)

diff --git a/README.md b/README.md
index 6cdfecc..d68fdee 100644
--- a/README.md
+++ b/README.md
@@ -535,6 +535,7 @@ It goes inside the credential's own configuration, next to `format` and `vct`:
 | `issuer` | String | `openidvci.issuer` | What the credential says issued it. `did:jwk`, `did:web` or `url`. See [Step 2](#step-2-add-a-did-web-credential) and [Step 3](#step-3-add-a-plain-url-credential). |
 | `oidfed` | Boolean | `true` | Whether the credential states the Entity Identifier of this deployment, once the deployment is in OpenID Federation. See [Step 4](#step-4-make-the-url-credential-an-openid-federation-credential). |
 | `claim_types` | Array | | Type of a claim value in an mdoc where JSON has none, a date for example, `{"path": [...], "type": ...}`. See [The mdoc credential](#the-mdoc-credential) and [Complex claims](#complex-claims). |
+| `sd` | Array | | Elements of an array in a `dc+sd-jwt` or `vc+sd-jwt` credential that are disclosed one by one, `{"path": [..., null], "sd": "always"}`. See [Complex claims](#complex-claims). |
 
 All of them are optional and so is the member itself.
 
@@ -4282,8 +4283,25 @@ How deep a path goes decides what a wallet can disclose:
 
 An object that holds values you give gets an `_sd` of its own. The objects on the way to it,
 `verified_claims` here, are not selectively disclosable, a wallet always shows that they are there.
-The elements of an array are never disclosed one by one. A path you give has names only, no
-`null` and no index, an array is always given as a whole value.
+A path you give has names only, no `null` and no index, an array is always given as a whole value.
+It is disclosed as a whole, as in
+[Disclosures for Object Properties](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/).
+To disclose its elements one by one, as in
+[Disclosures for Array Elements](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/),
+name it in `sd` of `_issuance_configuration`. An entry is a claim of
+[SD-JWT VC Type Metadata](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) with
+`"sd": "always"`, see its Claim Selective Disclosure Metadata. Only arrays, path ending in `null`,
+and only `always`:
+
+```json
+"_issuance_configuration": {
+  "sd": [
+    { "path": ["nationalities", null], "sd": "always" }
+  ]
+}
+```
+
+For a `vc+sd-jwt` credential the path starts with `credentialSubject`.
 
 #### mdoc values
 
@@ -4406,7 +4424,7 @@ The offer is refused when a `mandatory` claim of the metadata is not in it, here
 | Date and time | a string | a string or epoch seconds with `tdate` in `claim_types` |
 | Bytes | a string, a `data:` url for example | base64 or base64url with `bstr` in `claim_types` |
 | Object | a JSON object, disclosed as a whole, or a path per member, each disclosed | a JSON object or a path per member, both one element |
-| Array | a JSON array, disclosed as a whole | a JSON array, a `claim_types` entry on it types every item |
+| Array | a JSON array, disclosed as a whole, or each element on its own with `sd` | a JSON array, a `claim_types` entry on it types every item |
 | Typed value inside an array | | a `claim_types` path with `null` for the array |
 | Null | not supported, refused | not supported, refused |
 
@@ -4416,8 +4434,8 @@ The places to configure, in order:
    the credential for everything JSON has.
 2. `claim_types` in `_issuance_configuration`, mdoc only. Dates, byte strings, and numbers and
    booleans that come as text.
-3. The depth of the paths. For an SD-JWT VC it decides what is disclosed on its own, for an mdoc
-   where an element ends.
+3. The depth of the paths, and `sd` for arrays. For an SD-JWT VC it decides what is disclosed on
+   its own, for an mdoc where an element ends.
 4. For a document type of a standard, the definitions of that standard. The types, the values and
    for an mDL the country of the Document Signer certificate.
 
@@ -4592,6 +4610,7 @@ Member `_issuance_configuration` of a credential configuration. It is not publis
 | `issuer` | String | `openidvci.issuer` | `did:jwk`, `did:web`, `url` or the identifier itself. |
 | `oidfed` | Boolean | `true` | Whether the credential states the Entity Identifier, when *conf/openid-vci-oidfed.xml* is imported. |
 | `claim_types` | Array | | `{"path": [...], "type": ...}` per claim of an mdoc. `full-date`, `tdate`, `bstr`, `int` or `bool`. The path is a Claims Path Pointer, `null` for every element of an array, see [Complex claims](#complex-claims). |
+| `sd` | Array | | `{"path": [..., null], "sd": "always"}` per array of a `dc+sd-jwt` or `vc+sd-jwt` credential whose elements are disclosed one by one. The path is names followed by `null`, for `vc+sd-jwt` starting with `credentialSubject`. `always` is the only value. See [Complex claims](#complex-claims). |
 
 Members of a `vc+sd-jwt` credential, [W3C VCDM 2.0](https://www.w3.org/TR/vc-data-model-2.0/). The ones you set are members of
 `credential_definition`. Their content is purely yours, the plugin does not check it. Only
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimDisclosure.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimDisclosure.java
new file mode 100644
index 0000000..4c835fa
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimDisclosure.java
@@ -0,0 +1,116 @@
+/*
+ * 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.credential;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Selective disclosure of a claim in an SD-JWT, an entry of 'sd' in
+ * {@link CredentialIssuanceConfiguration}.
+ */
+ at JsonIgnoreProperties(ignoreUnknown = true)
+public final class ClaimDisclosure {
+
+    /** The claim is always selectively disclosable. */
+    @Nonnull
+    public static final String ALWAYS = "always";
+
+    /** Supported values. */
+    @Nonnull
+    private static final Set<String> VALUES = Set.of(ALWAYS);
+
+    /** Path of the claim, a claims path pointer. */
+    @Nonnull
+    private final List<Object> path;
+
+    /** Names of the array whose elements the path selects. */
+    @Nonnull
+    private final List<String> arrayPath;
+
+    /** Selective disclosure of the claim. */
+    @Nonnull
+    private final String sd;
+
+    /**
+     * Constructor.
+     *
+     * @param claimPath path of the claim, a claims path pointer
+     * @param disclosure selective disclosure of the claim
+     */
+    @JsonCreator
+    private ClaimDisclosure(@JsonProperty("path") final List<Object> claimPath,
+            @JsonProperty("sd") final String disclosure) {
+        if (claimPath == null || claimPath.size() < 2 || claimPath.get(claimPath.size() - 1) != null) {
+            throw new IllegalArgumentException("sd entry " + claimPath
+                    + " is not a path of names ending in null, the elements of an array");
+        }
+        if (disclosure == null || !VALUES.contains(disclosure)) {
+            throw new IllegalArgumentException("sd entry " + claimPath + " has sd '" + disclosure
+                    + "', not one of " + VALUES);
+        }
+        final List<String> names = new ArrayList<>(claimPath.size() - 1);
+        for (final Object element : claimPath.subList(0, claimPath.size() - 1)) {
+            if (!(element instanceof String)) {
+                throw new IllegalArgumentException("sd entry " + claimPath + " has path element '" + element
+                        + "', not a name");
+            }
+            names.add((String) element);
+        }
+        path = Collections.unmodifiableList(new ArrayList<>(claimPath));
+        arrayPath = List.copyOf(names);
+        sd = disclosure;
+    }
+
+    /**
+     * Get the path of the claim, a claims path pointer.
+     *
+     * @return path of the claim
+     */
+    @Nonnull
+    public List<Object> getPath() {
+        return path;
+    }
+
+    /**
+     * Get the names of the array whose elements the path selects.
+     *
+     * @return names of the array
+     */
+    @Nonnull
+    public List<String> getArrayPath() {
+        return arrayPath;
+    }
+
+    /**
+     * Get the selective disclosure of the claim.
+     *
+     * @return selective disclosure of the claim
+     */
+    @Nonnull
+    public String getSd() {
+        return sd;
+    }
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
index ef1f93c..b267044 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
@@ -170,14 +170,17 @@ public final class CredentialConfiguration {
     @JsonCreator
     private CredentialConfiguration(@JsonProperty("format") @Nonnull final String format,
             @JsonProperty("scope") @Nullable final String scope,
-            @JsonProperty("credential_signing_alg_values_supported") @Nullable final List<Object> credentialSigningAlgValuesSupported,
-            @JsonProperty("cryptographic_binding_methods_supported") @Nullable final List<String> cryptographicBindingMethodsSupported,
+            @JsonProperty("credential_signing_alg_values_supported") @Nullable
+            final List<Object> credentialSigningAlgValuesSupported,
+            @JsonProperty("cryptographic_binding_methods_supported") @Nullable
+            final List<String> cryptographicBindingMethodsSupported,
             @JsonProperty("credential_definition") @Nullable final CredentialDefinition credentialDefinition,
             @JsonProperty("proof_types_supported") @Nullable final Map<String, ProofTypeSupported> proofTypesSupported,
             @JsonProperty("credential_metadata") @Nullable final CredentialMetadata credentialMetadata,
             @JsonProperty("vct") @Nullable final String vct,
             @JsonProperty("doctype") @Nullable final String doctype,
-            @JsonProperty(ISSUANCE_CONFIGURATION) @Nullable final CredentialIssuanceConfiguration issuanceConfiguration) {
+            @JsonProperty(ISSUANCE_CONFIGURATION) @Nullable
+            final CredentialIssuanceConfiguration issuanceConfiguration) {
         assert format != null;
         this.format = format;
         this.scope = scope;
@@ -201,6 +204,9 @@ public final class CredentialConfiguration {
         if (MSO_MDOC.equals(format)) {
             validateMdoc();
         }
+        if (issuanceConfiguration != null) {
+            validateClaimDisclosures(issuanceConfiguration.getClaimDisclosures());
+        }
     }
 // Checkstyle: ParameterNumber ON
 
@@ -281,6 +287,26 @@ public final class CredentialConfiguration {
         return credentialMetadata;
     }
 
+    /**
+     * Check that selective disclosure is stated for an SD-JWT, with the claims of a
+     * 'vc+sd-jwt' inside 'credentialSubject'.
+     *
+     * @param disclosures selective disclosure of claims
+     */
+    private void validateClaimDisclosures(@Nonnull final List<ClaimDisclosure> disclosures) {
+        for (final ClaimDisclosure disclosure : disclosures) {
+            if ("vc+sd-jwt".equals(format)) {
+                if (disclosure.getArrayPath().size() < 2
+                        || !"credentialSubject".equals(disclosure.getArrayPath().get(0))) {
+                    throw new IllegalArgumentException("sd path " + disclosure.getPath()
+                            + " of a vc+sd-jwt does not start with credentialSubject and a claim");
+                }
+            } else if (!"dc+sd-jwt".equals(format)) {
+                throw new IllegalArgumentException("sd is supported for dc+sd-jwt and vc+sd-jwt, not for " + format);
+            }
+        }
+    }
+
     /**
      * Check that an mdoc has a document type and claim paths and claim type paths of a
      * name space and a data element identifier.
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java
index 5c23ee8..1479f0e 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java
@@ -67,6 +67,10 @@ public final class CredentialIssuanceConfiguration {
     @Nonnull
     private final List<ClaimType> claimTypes;
 
+    /** Selective disclosure of claims in an SD-JWT. */
+    @Nonnull
+    private final List<ClaimDisclosure> claimDisclosures;
+
 // Checkstyle: ParameterNumber OFF
     /**
      * Constructor.
@@ -83,6 +87,7 @@ public final class CredentialIssuanceConfiguration {
      *                           configuration carry, one of 'did:jwk', 'did:web'
      *                           and 'url', overriding the one of the deployment
      * @param types              types of claim values in an mdoc
+     * @param disclosures        selective disclosure of claims in an SD-JWT
      */
     @JsonCreator
     private CredentialIssuanceConfiguration(@JsonProperty("lifetime") @Nullable final String credentialLifetime,
@@ -90,13 +95,15 @@ public final class CredentialIssuanceConfiguration {
             @JsonProperty("status_list") @Nullable final Boolean inStatusList,
             @JsonProperty("oidfed") @Nullable final Boolean inOIDFederation,
             @JsonProperty("issuer") @Nullable final String issuerIdentifier,
-            @JsonProperty("claim_types") @Nullable final List<ClaimType> types) {
+            @JsonProperty("claim_types") @Nullable final List<ClaimType> types,
+            @JsonProperty("sd") @Nullable final List<ClaimDisclosure> disclosures) {
         lifetime = parseLifetime(credentialLifetime);
         notBefore = validityStart == null || validityStart.trim().isEmpty() ? null : ValidityStart.parse(validityStart);
         statusList = inStatusList;
         oidFederation = inOIDFederation;
         issuer = issuerIdentifier == null || issuerIdentifier.trim().isEmpty() ? null : issuerIdentifier.trim();
         claimTypes = types == null ? List.of() : List.copyOf(types);
+        claimDisclosures = disclosures == null ? List.of() : List.copyOf(disclosures);
     }
 // Checkstyle: ParameterNumber ON
 
@@ -161,6 +168,15 @@ public final class CredentialIssuanceConfiguration {
         return claimTypes;
     }
 
+    /**
+     * Get the selective disclosure of claims in an SD-JWT.
+     * @return selective disclosure of claims, empty when the configuration states none
+     */
+    @Nonnull
+    public List<ClaimDisclosure> getClaimDisclosures() {
+        return claimDisclosures;
+    }
+
     /**
      * Resolve the instant the validity window of a credential opens.
      *
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfigurationTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfigurationTest.java
index d4882ac..200934a 100644
--- a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfigurationTest.java
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfigurationTest.java
@@ -18,6 +18,8 @@ package org.geant.shibboleth.plugin.openidvci.credential;
 
 import java.time.Duration;
 import java.time.Instant;
+import java.util.Arrays;
+import java.util.List;
 
 import org.testng.Assert;
 import org.testng.annotations.Test;
@@ -157,6 +159,76 @@ public class CredentialIssuanceConfigurationTest {
         Assert.assertThrows(() -> CredentialConfiguration.parse(withSettings("{\"lifetime\":\"-P7D\"}")));
     }
 
+    @Test
+    public void claimDisclosures() throws JsonProcessingException {
+
+        final CredentialIssuanceConfiguration settings = CredentialConfiguration
+                .parse(withSettings("{\"sd\":[{\"path\":[\"ga4gh_visas\",null],\"sd\":\"always\"},"
+                        + "{\"path\":[\"passport\",\"visas\",null],\"sd\":\"always\"}]}"))
+                .getIssuanceConfiguration();
+
+        Assert.assertEquals(settings.getClaimDisclosures().size(), 2);
+        Assert.assertEquals(settings.getClaimDisclosures().get(0).getPath(), Arrays.asList("ga4gh_visas", null));
+        Assert.assertEquals(settings.getClaimDisclosures().get(0).getArrayPath(), List.of("ga4gh_visas"));
+        Assert.assertEquals(settings.getClaimDisclosures().get(0).getSd(), ClaimDisclosure.ALWAYS);
+        Assert.assertEquals(settings.getClaimDisclosures().get(1).getArrayPath(), List.of("passport", "visas"));
+    }
+
+    @Test
+    public void claimDisclosuresAbsent() throws JsonProcessingException {
+
+        final CredentialIssuanceConfiguration settings = CredentialConfiguration
+                .parse(withSettings("{\"lifetime\":\"P7D\"}")).getIssuanceConfiguration();
+
+        Assert.assertTrue(settings.getClaimDisclosures().isEmpty());
+    }
+
+    @Test
+    public void unsupportedClaimDisclosuresAreRefused() {
+
+        Assert.assertThrows(() -> CredentialConfiguration
+                .parse(withSettings("{\"sd\":[{\"path\":[\"ga4gh_visas\"],\"sd\":\"always\"}]}")));
+        Assert.assertThrows(() -> CredentialConfiguration
+                .parse(withSettings("{\"sd\":[{\"path\":[null],\"sd\":\"always\"}]}")));
+        Assert.assertThrows(() -> CredentialConfiguration
+                .parse(withSettings("{\"sd\":[{\"path\":[\"visas\",0,null],\"sd\":\"always\"}]}")));
+        Assert.assertThrows(() -> CredentialConfiguration
+                .parse(withSettings("{\"sd\":[{\"path\":[\"visas\",null,\"type\"],\"sd\":\"always\"}]}")));
+        Assert.assertThrows(() -> CredentialConfiguration
+                .parse(withSettings("{\"sd\":[{\"path\":[\"visas\",null],\"sd\":\"never\"}]}")));
+        Assert.assertThrows(() -> CredentialConfiguration
+                .parse(withSettings("{\"sd\":[{\"path\":[\"visas\",null]}]}")));
+    }
+
+    @Test
+    public void claimDisclosuresOfVcSdJwtAreInCredentialSubject() throws JsonProcessingException {
+
+        final String vcSdJwt = "{\"format\":\"vc+sd-jwt\",\"credential_definition\":"
+                + "{\"@context\":[\"https://example.org/ctx\"],\"type\":[\"VerifiableCredential\"]}";
+        final CredentialIssuanceConfiguration settings = CredentialConfiguration.parse(vcSdJwt
+                + ",\"_issuance_configuration\":{\"sd\":[{\"path\":[\"credentialSubject\",\"visas\",null],"
+                + "\"sd\":\"always\"}]}}").getIssuanceConfiguration();
+
+        Assert.assertEquals(settings.getClaimDisclosures().get(0).getArrayPath(),
+                List.of("credentialSubject", "visas"));
+        Assert.assertThrows(() -> CredentialConfiguration.parse(vcSdJwt
+                + ",\"_issuance_configuration\":{\"sd\":[{\"path\":[\"visas\",null],\"sd\":\"always\"}]}}"));
+        Assert.assertThrows(() -> CredentialConfiguration.parse(vcSdJwt
+                + ",\"_issuance_configuration\":{\"sd\":[{\"path\":[\"credentialSubject\",null],"
+                + "\"sd\":\"always\"}]}}"));
+    }
+
+    @Test
+    public void claimDisclosuresOfMdocAreRefused() throws JsonProcessingException {
+
+        final String mdoc = "{\"format\":\"mso_mdoc\",\"doctype\":\"org.iso.18013.5.1.mDL\"";
+
+        Assert.assertNotNull(CredentialConfiguration.parse(mdoc + "}"));
+        Assert.assertThrows(() -> CredentialConfiguration.parse(mdoc
+                + ",\"_issuance_configuration\":{\"sd\":[{\"path\":[\"org.iso.18013.5.1\",\"visas\",null],"
+                + "\"sd\":\"always\"}]}}"));
+    }
+
     @Test
     public void memberIsNotSerialized() throws JsonProcessingException {
 
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureArrayElements.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureArrayElements.java
new file mode 100644
index 0000000..e2ec86d
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureArrayElements.java
@@ -0,0 +1,130 @@
+/*
+ * 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.credential.impl;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.shared.security.impl.SecureRandomIdentifierGenerationStrategy;
+
+/**
+ * Helper to create SD JWT disclosures of the elements of an array.
+ */
+public class SelectiveDisclosureArrayElements {
+
+    /** Name of the member carrying the digest of an element. */
+    @Nonnull
+    private static final String ELEMENT_DIGEST = "...";
+
+    /** Elements of the array, each replaced by the digest of its disclosure. */
+    @Nonnull
+    private final List<Map<String, Object>> elements;
+
+    /** Disclosures of the elements. */
+    @Nonnull
+    private final List<String> disclosures;
+
+    /** Generator of salts. */
+    @Nonnull
+    private final IdentifierGenerationStrategy strategy = new SecureRandomIdentifierGenerationStrategy();
+
+    /** Mapper for disclosures. */
+    @Nonnull
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Constructor.
+     *
+     * @param values elements of the array
+     * @param algorithm digest algorithm of disclosures
+     *
+     * @throws IllegalArgumentException if the elements cannot be disclosed
+     */
+    public SelectiveDisclosureArrayElements(@Nonnull final List<?> values, @Nonnull final String algorithm) {
+        final MessageDigest digest;
+        try {
+            digest = MessageDigest.getInstance(algorithm);
+        } catch (final NoSuchAlgorithmException e) {
+            throw new IllegalArgumentException("Unsupported disclosure digest algorithm " + algorithm, e);
+        }
+        elements = new ArrayList<>(values.size());
+        disclosures = new ArrayList<>(values.size());
+        try {
+            for (final Object value : values) {
+                final String disclosure = Base64Support.encodeURLSafe(encode(value).getBytes(StandardCharsets.UTF_8));
+                disclosures.add(disclosure);
+                elements.add(Map.of(ELEMENT_DIGEST,
+                        Base64Support.encodeURLSafe(digest.digest(disclosure.getBytes(StandardCharsets.UTF_8)))));
+            }
+        } catch (final EncodingException e) {
+            throw new IllegalArgumentException("Unable to encode disclosures", e);
+        }
+    }
+
+    /**
+     * Get the elements of the array, each replaced by the digest of its disclosure.
+     *
+     * @return elements of the array in their order
+     */
+    @Nonnull
+    public List<Map<String, Object>> getElements() {
+        return elements;
+    }
+
+    /**
+     * Get the disclosures of the elements.
+     *
+     * @return disclosures of the elements
+     */
+    @Nonnull
+    public List<String> getDisclosures() {
+        return disclosures;
+    }
+
+    /**
+     * Encode the disclosure of an array element, salt and value.
+     *
+     * @param value value of the element
+     *
+     * @return the disclosure as a JSON array
+     */
+    @Nonnull
+    private String encode(final Object value) {
+        final ArrayNode disclosure = objectMapper.createArrayNode();
+        disclosure.add(strategy.generateIdentifier());
+        disclosure.add(objectMapper.valueToTree(value));
+        try {
+            return objectMapper.writeValueAsString(disclosure);
+        } catch (final JsonProcessingException e) {
+            throw new IllegalArgumentException("Unable to serialize disclosure of an array element", e);
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimTree.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimTree.java
index 1a4dc13..d6b7b21 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimTree.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimTree.java
@@ -60,7 +60,7 @@ public class SelectiveDisclosureClaimTree {
      * @throws IllegalArgumentException if the claims cannot form a tree
      */
     public SelectiveDisclosureClaimTree(@Nonnull final Collection<CredentialOfferRequestedClaim> requested) {
-        this(requested, "sha-256");
+        this(requested, List.of(), "sha-256");
     }
 
     /**
@@ -73,20 +73,58 @@ public class SelectiveDisclosureClaimTree {
      */
     public SelectiveDisclosureClaimTree(@Nonnull final Collection<CredentialOfferRequestedClaim> requested,
             @Nonnull final String algorithm) {
+        this(requested, List.of(), algorithm);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param requested claims to disclose
+     * @param disclosedArrays paths of the arrays whose elements are disclosed one by one
+     *
+     * @throws IllegalArgumentException if the claims cannot form a tree
+     */
+    public SelectiveDisclosureClaimTree(@Nonnull final Collection<CredentialOfferRequestedClaim> requested,
+            @Nonnull final Collection<List<String>> disclosedArrays) {
+        this(requested, disclosedArrays, "sha-256");
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param requested claims to disclose
+     * @param disclosedArrays paths of the arrays whose elements are disclosed one by one
+     * @param algorithm digest algorithm of disclosures
+     *
+     * @throws IllegalArgumentException if the claims cannot form a tree
+     */
+    public SelectiveDisclosureClaimTree(@Nonnull final Collection<CredentialOfferRequestedClaim> requested,
+            @Nonnull final Collection<List<String>> disclosedArrays, @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<>();
+        final List<String> parts = new ArrayList<>();
         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());
+            Object value = claim.getValue();
+            if (disclosedArrays.contains(path)) {
+                if (!(value instanceof List)) {
+                    throw new IllegalArgumentException("Claim " + String.join("/", path)
+                            + " has elements to disclose but is not an array");
+                }
+                final SelectiveDisclosureArrayElements elements =
+                        new SelectiveDisclosureArrayElements((List<?>) value, algorithm);
+                value = elements.getElements();
+                parts.addAll(elements.getDisclosures());
+            }
+            levels.computeIfAbsent(parent, k -> new LinkedHashMap<>()).put(path.get(path.size() - 1), value);
         }
 
         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);
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 1c24046..dee21b4 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
@@ -25,6 +25,7 @@ import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 import org.geant.shibboleth.plugin.openidvci.credential.CredentialDefinition;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialIssuanceConfiguration;
 import org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialIssuerLookupFunction;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
@@ -131,6 +132,12 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
 
+        final CredentialIssuanceConfiguration settings = ctx.getCredentialConfiguration().getIssuanceConfiguration();
+        // The paths name 'credentialSubject' first, the claims are inside it.
+        final List<List<String>> disclosedArrays = settings == null ? List.of()
+                : settings.getClaimDisclosures().stream()
+                        .map(disclosure -> disclosure.getArrayPath().subList(1, disclosure.getArrayPath().size()))
+                        .toList();
         final List<JWTClaimsSet> credentials = new ArrayList<>();
         final List<String> disclosures = new ArrayList<>();
         final String issuer = issuerLookupStrategy.apply(profileRequestContext);
@@ -138,7 +145,7 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
             // A tree per credential, so that salts, digests and disclosures are not shared within a batch.
             final SelectiveDisclosureClaimTree tree;
             try {
-                tree = new SelectiveDisclosureClaimTree(credential.getRequestedCredential());
+                tree = new SelectiveDisclosureClaimTree(credential.getRequestedCredential(), disclosedArrays);
             } catch (final IllegalArgumentException e) {
                 log.error("{} Unable to disclose the requested claims", getLogPrefix(), e);
                 ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
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 9723560..6006526 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
@@ -21,6 +21,8 @@ import java.util.List;
 
 import javax.annotation.Nonnull;
 
+import org.geant.shibboleth.plugin.openidvci.credential.ClaimDisclosure;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialIssuanceConfiguration;
 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;
@@ -100,6 +102,9 @@ public class FormSelectiveDisclosureJWTCredential extends AbstractProfileAction
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
 
+        final CredentialIssuanceConfiguration settings = ctx.getCredentialConfiguration().getIssuanceConfiguration();
+        final List<List<String>> disclosedArrays = settings == null ? List.of()
+                : settings.getClaimDisclosures().stream().map(ClaimDisclosure::getArrayPath).toList();
         // Claims of this format are on top level of the credential.
         final List<JWTClaimsSet> credentials = new ArrayList<>();
         final List<String> disclosures = new ArrayList<>();
@@ -107,7 +112,7 @@ public class FormSelectiveDisclosureJWTCredential extends AbstractProfileAction
             // A tree per credential, so that salts, digests and disclosures are not shared within a batch.
             final SelectiveDisclosureClaimTree tree;
             try {
-                tree = new SelectiveDisclosureClaimTree(credential.getRequestedCredential());
+                tree = new SelectiveDisclosureClaimTree(credential.getRequestedCredential(), disclosedArrays);
             } catch (final IllegalArgumentException e) {
                 log.error("{} Unable to disclose the requested claims", getLogPrefix(), e);
                 ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimTreeTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimTreeTest.java
index 3f328af..cabef27 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimTreeTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimTreeTest.java
@@ -17,6 +17,7 @@
 package org.geant.shibboleth.plugin.openidvci.credential.impl;
 
 import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
 import java.util.ArrayList;
 import java.util.Base64;
 import java.util.HashMap;
@@ -174,6 +175,89 @@ public class SelectiveDisclosureClaimTreeTest {
         Assert.assertEquals(tree.getFormattedDisclosures(), "");
     }
 
+    private JsonNode decode(final String disclosure) throws Exception {
+        return mapper.readTree(new String(Base64.getUrlDecoder().decode(disclosure), StandardCharsets.UTF_8));
+    }
+
+    private String digest(final String disclosure) throws Exception {
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(
+                MessageDigest.getInstance("sha-256").digest(disclosure.getBytes(StandardCharsets.US_ASCII)));
+    }
+
+    @Test
+    public void testArrayElementsAreDisclosedOneByOne() throws Exception {
+        final List<Object> visas = List.of(Map.of("type", "ControlledAccessGrants"), Map.of("type", "ResearcherStatus"),
+                "plain");
+        final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+                List.of(claim(visas, "ga4gh_visas"), claim("John", "given_name")), List.of(List.of("ga4gh_visas")));
+
+        final Map<String, JsonNode> byDigest = new HashMap<>();
+        for (final String disclosure : tree.getFormattedDisclosures().split("~")) {
+            byDigest.put(digest(disclosure), decode(disclosure));
+        }
+        Assert.assertEquals(byDigest.size(), 5);
+
+        JsonNode array = null;
+        for (final String digest : sd(tree.getClaims())) {
+            final JsonNode disclosure = byDigest.get(digest);
+            Assert.assertNotNull(disclosure);
+            Assert.assertEquals(disclosure.size(), 3);
+            if ("ga4gh_visas".equals(disclosure.get(1).asText())) {
+                array = disclosure.get(2);
+            }
+        }
+        Assert.assertNotNull(array);
+        Assert.assertEquals(array.size(), 3);
+        for (int i = 0; i < visas.size(); i++) {
+            Assert.assertEquals(array.get(i).size(), 1);
+            final JsonNode element = byDigest.get(array.get(i).get("...").asText());
+            Assert.assertNotNull(element);
+            Assert.assertEquals(element.size(), 2);
+            Assert.assertEquals(element.get(1), mapper.valueToTree(visas.get(i)));
+        }
+    }
+
+    @Test
+    public void testNestedArrayElementsAreDisclosedOneByOne() throws Exception {
+        final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+                List.of(claim(List.of("a", "b"), "passport", "visas")), List.of(List.of("passport", "visas")));
+
+        Assert.assertEquals(sd(object(tree.getClaims(), "passport")).size(), 1);
+        Assert.assertEquals(tree.getFormattedDisclosures().split("~").length, 3);
+    }
+
+    @Test
+    public void testEmptyArrayHasNoElementDisclosures() throws Exception {
+        final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+                List.of(claim(List.of(), "ga4gh_visas")), List.of(List.of("ga4gh_visas")));
+
+        Assert.assertEquals(tree.getFormattedDisclosures().split("~").length, 1);
+        Assert.assertTrue(decode(tree.getFormattedDisclosures()).get(2).isEmpty());
+    }
+
+    @Test
+    public void testArraysAreDisclosedWholeByDefault() throws Exception {
+        final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+                List.of(claim(List.of("a", "b"), "ga4gh_visas")));
+
+        Assert.assertEquals(tree.getFormattedDisclosures().split("~").length, 1);
+        Assert.assertEquals(decode(tree.getFormattedDisclosures()).get(2), mapper.valueToTree(List.of("a", "b")));
+    }
+
+    @Test
+    public void testUnlistedArrayIsDisclosedWhole() throws Exception {
+        final SelectiveDisclosureClaimTree tree = new SelectiveDisclosureClaimTree(
+                List.of(claim(List.of("a", "b"), "nationalities")), List.of(List.of("ga4gh_visas")));
+
+        Assert.assertEquals(tree.getFormattedDisclosures().split("~").length, 1);
+    }
+
+    @Test(expectedExceptions = IllegalArgumentException.class)
+    public void testElementsOfNonArrayRejected() throws Exception {
+        new SelectiveDisclosureClaimTree(List.of(claim("not an array", "ga4gh_visas")),
+                List.of(List.of("ga4gh_visas")));
+    }
+
     @Test(expectedExceptions = IllegalArgumentException.class)
     public void testSameClaimTwiceRejected() throws Exception {
         new SelectiveDisclosureClaimTree(List.of(claim("John", "given_name"), claim("Jack", "given_name")));

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


More information about the commits mailing list