[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