[java-idp-plugin-vci] branch bugs/LowHanging updated: Credential batch size in now published and honored by implementation. Disclousure salts are now different to each credential in batch

Codeberg noreply at shibboleth.net
Mon Sep 21 15:13:21 UTC 2026


This is an automated email from the git hooks/post-receive script.

codeberg pushed a commit to branch bugs/LowHanging
in repository java-idp-plugin-vci.

View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/9d0eca00b2c588294f621e8c26df85192aff1beb

The following commit(s) were added to refs/heads/bugs/LowHanging by this push:
     new 9d0eca0  Credential batch size in now published and honored by implementation. Disclousure salts are now different to each credential in batch
9d0eca0 is described below

commit 9d0eca00b2c588294f621e8c26df85192aff1beb
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Mon Sep 21 18:13:03 2026 +0300

    Credential batch size in now published and honored by implementation. Disclousure salts are now different to each credential in batch
---
 README.md                                          | 13 +++++
 .../metadata/CredentialIssuerMetadata.java         | 64 +++++++++++++++++++++-
 .../profile/config/OpenIDVCIConfiguration.java     | 17 ++++++
 .../messaging/context/CredentialsContext.java      | 19 ++++---
 .../impl/AbstractOpenIDVCIConfiguration.java       | 36 ++++++++++++
 ...FormJsonLdSelectiveDisclosureJWTCredential.java | 21 ++++---
 .../FormOutboundCredentialsResponseMessage.java    | 11 +++-
 .../impl/FormSelectiveDisclosureJWTCredential.java | 21 ++++---
 .../plugin/openidvci/profile/impl/ParseProof.java  | 61 ++++++++++++++++++++-
 .../openid/vci/credentials/credentials-beans.xml   |  3 +-
 .../idp/service/relying-party/postconfig.xml       |  1 +
 .../openidvci/static/openid-credential-issuer.json |  3 +
 .../openidvci/profile/impl/ParseProofTest.java     | 13 +++++
 13 files changed, 248 insertions(+), 35 deletions(-)

diff --git a/README.md b/README.md
index 7f495c3..76596bb 100644
--- a/README.md
+++ b/README.md
@@ -1178,6 +1178,7 @@ to understand before you pick a value. See
 | `openidvci.preAuthorizedCode.defaultLength` | `0` seals the whole offer inside the code, which needs no storage and makes a dense QR code. `10` or more is a short code and a stored offer. |
 | `openidvci.preAuthorizedCode.defaultLifetime` | How long the holder of an offer has to redeem it. |
 | `preAuthorizedCodeLength`, `preAuthorizedCodeLifetime` | The same two per relying party rather than per deployment. |
+| `batchSize` | How many key proofs a given wallet may send in one Credential Request. Has no effect while the Credential Issuer metadata advertises `batch_credential_issuance`, which applies to every wallet alike. |
 
 #### Who is allowed to call an endpoint
 
@@ -1254,6 +1255,9 @@ cannot be read, so a broken file of credentials takes the metadata endpoint down
   ],
   "credential_endpoint": "$baseUrl/idp/profile/openid/vci/credentials",
   "nonce_endpoint": "$baseUrl/idp/profile/openid/vci/nonce",
+  "batch_credential_issuance": {
+    "batch_size": 2
+  },
   "logo_uri": "https://issuer.example.org/images/logo.png",
   "contacts": [
     "credentials at example.org"
@@ -1275,6 +1279,13 @@ cannot be read, so a broken file of credentials takes the metadata endpoint down
 
 </details>
 
+`batch_credential_issuance` is the one member of this document the plugin reads back. Its
+`batch_size` is the largest `proofs` array a Credential Request may carry, and the credential
+endpoint rejects a longer one before it verifies a single proof. The specification allows the
+member only with a `batch_size` of 2 or greater; drop the member to accept one key proof per
+request. Where the member is absent the `batchSize` of **OpenID.VCI.Credentials** applies
+instead.
+
 `/.well-known/jwt-vc-issuer` tells a verifier which keys sign your credentials. Its `issuer`
 has to be the same value the credentials carry as `iss`. The document matters only when your
 Credential Issuer identifier is the plain issuer url. With `did:jwk`, which is the default, the
@@ -1715,6 +1726,7 @@ credential rather than from the settings below, see
 | `credentialSignatureSigningConfiguration` | SignatureSigningConfiguration | Bean named **openidvci.SigningConfiguration** | Signing configuration of the credential. |
 | `proofSignatureValidationConfiguration` | SignatureValidationConfiguration | Bean named **openidvci.ProofSignatureValidationConfiguration** | Validation configuration of the key proof. |
 | `proofClaimsValidator` | ClaimsValidator | Bean named **openidvci.DefaultProofBodyClaimsValidator** | Validator of the claims of the key proof. |
+| `batchSize` | Integer | `2` | Largest number of key proofs one Credential Request may carry, so also the largest number of credentials one request issues and the largest number of Status List slots it consumes. Read only when the Credential Issuer metadata advertises no `batch_credential_issuance`; where that member exists its `batch_size` is enforced instead, so what a wallet is told is what it gets. Property `openidvci.batchSize`. |
 | `proofNonceGenerator` | Function<ProfileRequestContext,String> | Bean named **openidvci.DefaultOpenIDVCINonceGenerator** | Generator of the nonce a key proof has to carry. The same generator has to be in **OpenID.VCI.Nonce**, it is what hands the nonce out. |
 
 ### OpenID.VCI.Nonce
@@ -2054,6 +2066,7 @@ DPoP settings are among them.
 | `proofSignatureValidationConfiguration` | SignatureValidationConfiguration | Bean named **openidvci.ProofSignatureValidationConfiguration** | Validation configuration for the wallet's key proof. |
 | `proofClaimsValidator` | ClaimsValidator | Bean named **openidvci.DefaultProofBodyClaimsValidator** | Validator of the key proof's claims. |
 | `proofNonceGenerator` | Function<ProfileRequestContext,String> | Bean named **openidvci.DefaultOpenIDVCINonceGenerator** | Generator of the nonce a key proof must carry. |
+| `batchSize` | Integer | `2` | Largest number of key proofs one Credential Request may carry. Overridden by `batch_credential_issuance.batch_size` of the Credential Issuer metadata when that member is present. **OpenID.VCI.Credentials** only. |
 | `issuerMetadataSignatureSigningConfiguration` | SignatureSigningConfiguration | Bean named **openidvci.issuerMetadata.SigningConfiguration** | Signing configuration for issuer metadata. **OpenID.VCI.IssuerMetadata** only. |
 | `statusListSignatureSigningConfiguration` | SignatureSigningConfiguration | Bean named **openidvci.status-list.SigningConfiguration** | Signing configuration for Status List Tokens. **OAuth.StatusList** only. |
 
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java
index f760b60..b1d1c84 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java
@@ -49,6 +49,17 @@ public final class CredentialIssuerMetadata {
     @Nonnull
     public static final String CREDENTIAL_CONFIGURATIONS_SUPPORTED = "credential_configurations_supported";
 
+    /** Member describing support for issuing a batch of Credentials. */
+    @Nonnull
+    public static final String BATCH_CREDENTIAL_ISSUANCE = "batch_credential_issuance";
+
+    /** Member of {@link #BATCH_CREDENTIAL_ISSUANCE} carrying the maximum size of the proofs array. */
+    @Nonnull
+    public static final String BATCH_SIZE = "batch_size";
+
+    /** Smallest batch size the specification allows to be advertised. */
+    private static final int MINIMUM_BATCH_SIZE = 2;
+
     /** Members of the document, in the order they were parsed. */
     @Nonnull
     private final Map<String, Object> members;
@@ -57,15 +68,21 @@ public final class CredentialIssuerMetadata {
     @Nonnull
     private final Issuer credentialIssuer;
 
+    /** Maximum size of the proofs array, null when a batch is not advertised. */
+    @Nullable
+    private final Integer batchSize;
+
     /**
      * Constructor.
      *
      * @param parsed members of the document
      * @param issuer Credential Issuer the document is for
      */
-    private CredentialIssuerMetadata(@Nonnull final Map<String, Object> parsed, @Nonnull final Issuer issuer) {
+    private CredentialIssuerMetadata(@Nonnull final Map<String, Object> parsed, @Nonnull final Issuer issuer,
+            @Nullable final Integer size) {
         members = parsed;
         credentialIssuer = issuer;
+        batchSize = size;
     }
 
     /**
@@ -109,6 +126,16 @@ public final class CredentialIssuerMetadata {
         return members.containsKey(member);
     }
 
+    /**
+     * Get the maximum size of the proofs array of a Credential Request.
+     * 
+     * @return maximum size of the proofs array, or null when a batch is not advertised
+     */
+    @Nullable
+    public Integer getBatchSize() {
+        return batchSize;
+    }
+
     /**
      * Parse a document.
      *
@@ -129,7 +156,40 @@ public final class CredentialIssuerMetadata {
                     "Credential Issuer metadata must have a non empty '" + CREDENTIAL_ISSUER + "' member");
         }
         return new CredentialIssuerMetadata(Collections.unmodifiableMap(new LinkedHashMap<>(members)),
-                new Issuer((String) issuer));
+                new Issuer((String) issuer), parseBatchSize(members));
+    }
+
+    /**
+     * Parse the advertised maximum size of the proofs array.
+     * 
+     * @param members members of the document
+     * 
+     * @return maximum size of the proofs array, or null when a batch is not advertised
+     * 
+     * @throws ParseException if the member is present but not a valid batch size
+     */
+    @Nullable
+    private static Integer parseBatchSize(@Nonnull final Map<String, Object> members) throws ParseException {
+
+        final Object batch = members.get(BATCH_CREDENTIAL_ISSUANCE);
+        if (batch == null) {
+            return null;
+        }
+        if (!(batch instanceof Map)) {
+            throw new ParseException("Credential Issuer metadata member '" + BATCH_CREDENTIAL_ISSUANCE
+                    + "' must be an object");
+        }
+        final Object size = ((Map<?, ?>) batch).get(BATCH_SIZE);
+        if (!(size instanceof Number) || ((Number) size).intValue() != ((Number) size).doubleValue()) {
+            throw new ParseException("Credential Issuer metadata member '" + BATCH_CREDENTIAL_ISSUANCE + "' must have"
+                    + " an integer '" + BATCH_SIZE + "' member");
+        }
+        final int value = ((Number) size).intValue();
+        if (value < MINIMUM_BATCH_SIZE) {
+            throw new ParseException("Credential Issuer metadata member '" + BATCH_SIZE + "' must be "
+                    + MINIMUM_BATCH_SIZE + " or greater, not " + value);
+        }
+        return Integer.valueOf(value);
     }
 
 }
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/OpenIDVCIConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/OpenIDVCIConfiguration.java
index 136d12f..9166c26 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/OpenIDVCIConfiguration.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/OpenIDVCIConfiguration.java
@@ -85,6 +85,23 @@ public interface OpenIDVCIConfiguration extends ConditionalProfileConfiguration
     @Nonnull
     Integer getPreAuthorizedCodeLength(@Nullable final ProfileRequestContext profileRequestContext);
 
+    /**
+     * Get the maximum number of key proofs accepted in one Credential Request.
+     *
+     * <p>
+     * Applied when the Credential Issuer metadata advertises no <code>batch_credential_issuance</code>, whose
+     * <code>batch_size</code> is used instead. Defaults to 2.
+     * </p>
+     *
+     * @param profileRequestContext profile request context
+     *
+     * @return maximum number of key proofs accepted in one Credential Request
+     */
+    @ConfigurationSetting(name = "batchSize")
+    @Positive
+    @Nonnull
+    Integer getBatchSize(@Nullable final ProfileRequestContext profileRequestContext);
+
     /**
      * Get pre-authorized code lifetime.
      *
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
index 7b008a5..3e5777e 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
@@ -74,9 +74,9 @@ public class CredentialsContext extends BaseContext {
     @Nullable
     private List<String> signedJWTCredentials;
 
-    /** Disclosures of JWT based credential. */
+    /** Per proof disclosures of JWT based credentials. */
     @Nullable
-    private String disclosures;
+    private List<String> disclosures;
 
     /**
      * Get validated proofs of wallet.
@@ -258,21 +258,22 @@ public class CredentialsContext extends BaseContext {
     }
 
     /**
-     * Get Disclosures of JWT based credential.
+     * Get per proof disclosures of JWT based credentials, in the order of {@link #getJWTCredentials()}.
      * 
-     * @return Disclosures of JWT based credential
+     * @return Per proof disclosures of JWT based credentials
      */
-    public String getDisclosures() {
+    @Nullable
+    public List<String> getDisclosures() {
         return disclosures;
     }
 
     /**
-     * Set Disclosures of JWT based credential.
+     * Set per proof disclosures of JWT based credentials.
      * 
-     * @param disclosures Disclosures of JWT based credential
+     * @param disclosureList Per proof disclosures of JWT based credentials
      */
-    public void setDisclosures(final String disclosures) {
-        this.disclosures = disclosures;
+    public void setDisclosures(@Nullable final List<String> disclosureList) {
+        disclosures = disclosureList;
     }
 
     /**
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/AbstractOpenIDVCIConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/AbstractOpenIDVCIConfiguration.java
index 6828d4f..494a7b8 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/AbstractOpenIDVCIConfiguration.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/AbstractOpenIDVCIConfiguration.java
@@ -46,6 +46,10 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
     @Nonnull
     private Function<ProfileRequestContext, Integer> preAuthorizedCodeLengthLookupStrategy;
 
+    /** Lookup function to supply the maximum number of key proofs of a Credential Request. */
+    @Nonnull
+    private Function<ProfileRequestContext, Integer> batchSizeLookupStrategy;
+
     /** Lookup function to supply credential lifetime. */
     @Nonnull
     private Function<ProfileRequestContext, Duration> credentialLifetimeLookupStrategy;
@@ -77,6 +81,7 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
         super(profileId);
         preAuthorizedCodeLifetimeLookupStrategy = FunctionSupport.constant(Duration.ofMinutes(10));
         preAuthorizedCodeLengthLookupStrategy = FunctionSupport.constant(Integer.valueOf(0));
+        batchSizeLookupStrategy = FunctionSupport.constant(Integer.valueOf(2));
         credentialLifetimeLookupStrategy = FunctionSupport.constant(Duration.ofDays(180));
         proofSignatureValidationConfigurationLookupStrategy = FunctionSupport.constant(null);
         credentialSignatureSigningConfigurationLookupStrategy = FunctionSupport.constant(null);
@@ -133,6 +138,37 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
         preAuthorizedCodeLengthLookupStrategy = FunctionSupport.constant(codeLength);
     }
 
+    /** {@inheritDoc} */
+    @Override
+    @Positive
+    @Nonnull
+    public Integer getBatchSize(@Nullable final ProfileRequestContext profileRequestContext) {
+        final Integer size = batchSizeLookupStrategy.apply(profileRequestContext);
+
+        Constraint.isTrue(size != null && size > 0, "Batch size must be at least 1");
+        return size;
+    }
+
+    /**
+     * Set the maximum number of key proofs accepted in one Credential Request.
+     *
+     * @param size maximum number of key proofs accepted in one Credential Request
+     */
+    public void setBatchSize(@Positive @Nonnull final Integer size) {
+        Constraint.isTrue(size != null && size > 0, "Batch size must be at least 1");
+
+        batchSizeLookupStrategy = FunctionSupport.constant(size);
+    }
+
+    /**
+     * Set the lookup strategy for the maximum number of key proofs accepted in one Credential Request.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setBatchSizeLookupStrategy(@Nonnull final Function<ProfileRequestContext, Integer> strategy) {
+        batchSizeLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override
     @Positive
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 b7dd03e..9e64f19 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
@@ -129,17 +129,19 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
 
-        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;
-        }
         final List<JWTClaimsSet> credentials = new ArrayList<>();
+        final List<String> disclosures = new ArrayList<>();
         final String issuer = issuerLookupStrategy.apply(profileRequestContext);
         for (final ClaimsSet cred : ctx.getCredentialShells()) {
+            // 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());
+            } catch (final IllegalArgumentException e) {
+                log.error("{} Unable to disclose the requested claims", getLogPrefix(), e);
+                ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+                return;
+            }
             try {
                 // Claims of this format are inside 'credentialSubject'.
                 credentials.add(new JWTClaimsSet.Builder(cred.toJWTClaimsSet())
@@ -148,6 +150,7 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
                         .claim("type", ctx.getCredentialConfiguration().getCredentialDefinition().getType())
                         .claim("issuer", issuer).claim("credentialSubject", tree.getClaims())
                         .claim("_sd_alg", tree.getAlgorithm()).build());
+                disclosures.add(tree.getFormattedDisclosures());
             } catch (final ParseException e) {
                 log.error("{} Parsing credential failed", getLogPrefix(), e);
                 ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
@@ -155,6 +158,6 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
             }
         }
         ctx.setJWTCredentials(credentials);
-        ctx.setDisclosures(tree.getFormattedDisclosures());
+        ctx.setDisclosures(disclosures);
     }
 }
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundCredentialsResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundCredentialsResponseMessage.java
index 4d678c0..fb827ea 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundCredentialsResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundCredentialsResponseMessage.java
@@ -76,10 +76,15 @@ public class FormOutboundCredentialsResponseMessage extends AbstractOIDCResponse
             return;
         }
         try {
+            final List<String> signed = ctx.getSignedCredentials();
+            final List<String> disclosures = ctx.getDisclosures();
             final List<String> credentials = new ArrayList<>();
-            ctx.getSignedCredentials().forEach(cred -> {
-                credentials.add(cred + (ctx.getDisclosures() != null ? "~" + ctx.getDisclosures() + "~" : ""));
-            });
+            for (int ordinal = 0; ordinal < signed.size(); ordinal++) {
+                final String disclosed = disclosures != null && ordinal < disclosures.size()
+                        ? disclosures.get(ordinal)
+                        : null;
+                credentials.add(signed.get(ordinal) + (disclosed != null ? "~" + disclosed + "~" : ""));
+            }
             final CredentialSuccessResponse response = new CredentialSuccessResponse(credentials);
             log.info("Setting response as {}", response.toJSONString());
             profileRequestContext.ensureOutboundMessageContext().setMessage(response);
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 c4c44a9..9723560 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
@@ -100,21 +100,24 @@ public class FormSelectiveDisclosureJWTCredential extends AbstractProfileAction
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
 
-        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<>();
+        final List<String> disclosures = new ArrayList<>();
         for (final ClaimsSet cred : ctx.getCredentialShells()) {
+            // 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());
+            } catch (final IllegalArgumentException e) {
+                log.error("{} Unable to disclose the requested claims", getLogPrefix(), e);
+                ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+                return;
+            }
             try {
                 final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder(cred.toJWTClaimsSet());
                 tree.getClaims().forEach(builder::claim);
                 credentials.add(builder.claim("_sd_alg", tree.getAlgorithm()).build());
+                disclosures.add(tree.getFormattedDisclosures());
             } catch (final ParseException e) {
                 log.error("{} Parsing credential failed", getLogPrefix(), e);
                 ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
@@ -122,6 +125,6 @@ public class FormSelectiveDisclosureJWTCredential extends AbstractProfileAction
             }
         }
         ctx.setJWTCredentials(credentials);
-        ctx.setDisclosures(tree.getFormattedDisclosures());
+        ctx.setDisclosures(disclosures);
     }
 }
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
index d028961..7326be1 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
@@ -28,6 +28,8 @@ import javax.annotation.Nullable;
 import org.geant.shibboleth.plugin.openidvci.profile.config.OpenIDVCIConfiguration;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCICredentialsRequest;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
 import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
@@ -47,6 +49,7 @@ import net.shibboleth.oidc.jwt.claims.JWTValidationException;
 import net.shibboleth.profile.context.RelyingPartyContext;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.resolver.ResolverException;
 
 /** Action that parses the key proofs of a Credential request. */
 public class ParseProof extends AbstractProfileAction {
@@ -74,6 +77,13 @@ public class ParseProof extends AbstractProfileAction {
     @Nullable
     private ClaimsValidator validator;
 
+    /** Resolver of the published Credential Issuer metadata. */
+    @Nullable
+    private CredentialIssuerMetadataResolver metadataResolver;
+
+    /** Maximum number of key proofs this request may carry. */
+    private int batchSize;
+
     /**
      * Constructor.
      */
@@ -96,6 +106,17 @@ public class ParseProof extends AbstractProfileAction {
                 "RelyingPartyContext lookup strategy cannot be null");
     }
 
+    /**
+     * Set the resolver of the published Credential Issuer metadata.
+     * 
+     * @param resolver resolver of the published Credential Issuer metadata
+     */
+    public void setMetadataResolver(@Nullable final CredentialIssuerMetadataResolver resolver) {
+        checkSetterPreconditions();
+
+        metadataResolver = resolver;
+    }
+
     /** {@inheritDoc} */
     @Override
     protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
@@ -105,7 +126,11 @@ public class ParseProof extends AbstractProfileAction {
         rpCtx = relyingPartyContextLookupStrategy.apply(profileRequestContext);
         if (rpCtx != null && rpCtx.getProfileConfig() instanceof OpenIDVCIConfiguration configuration) {
             validator = configuration.getProofClaimsValidator(profileRequestContext);
+            batchSize = configuration.getBatchSize(profileRequestContext);
+        } else {
+            batchSize = 1;
         }
+        batchSize = resolveAdvertisedBatchSize(profileRequestContext, batchSize);
         if (profileRequestContext.getInboundMessageContext() == null || !(profileRequestContext
                 .getInboundMessageContext().getMessage() instanceof OpenIDVCICredentialsRequest)) {
             log.error("{} No OpenIDVCICredentialsRequest as inbound message", getLogPrefix());
@@ -133,9 +158,13 @@ public class ParseProof extends AbstractProfileAction {
             return;
         }
         final List<SignedJWT> proofs = new ArrayList<SignedJWT>();
-        // TODO: Enforce a maximum number of proofs per request, aligned with the
-        // batch_credential_issuance.batch_size value advertised in issuer metadata.
         if (proofToken instanceof List<?> tokens) {
+            if (tokens.size() > batchSize) {
+                log.error("{} Request carries {} key proofs, at most {} are accepted", getLogPrefix(), tokens.size(),
+                        batchSize);
+                ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_PROOF);
+                return;
+            }
             tokens.forEach(token -> {
                 if (token instanceof String strToken) {
                     try {
@@ -190,4 +219,32 @@ public class ParseProof extends AbstractProfileAction {
             validator.validate(JWTClaimsSet.parse(jwtProof.getPayload().toJSONObject()), profileRequestContext);
         }
     }
+
+    /**
+     * Resolve the maximum number of key proofs to accept, the advertised batch size where the Credential Issuer
+     * metadata has one and the configured batch size otherwise.
+     * 
+     * @param profileRequestContext profile request context
+     * @param configured            batch size of the profile configuration
+     * 
+     * @return maximum number of key proofs to accept
+     */
+    private int resolveAdvertisedBatchSize(@Nonnull final ProfileRequestContext profileRequestContext,
+            final int configured) {
+
+        if (metadataResolver == null) {
+            return configured;
+        }
+        try {
+            final CredentialIssuerMetadata metadata = metadataResolver.resolveSingle(profileRequestContext);
+            final Integer advertised = metadata != null ? metadata.getBatchSize() : null;
+            if (advertised != null) {
+                return advertised.intValue();
+            }
+        } catch (final ResolverException e) {
+            log.warn("{} Unable to resolve Credential Issuer metadata, applying the configured batch size {}",
+                    getLogPrefix(), configured, e);
+        }
+        return configured;
+    }
 }
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
index a9eaf03..56fc501 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
@@ -37,7 +37,8 @@
         p:credentialConfigurationsResolver-ref="openidvci.CredentialConfigurationsResolver" scope="prototype" />
         
   <bean id="ParseProof"
-        class="org.geant.shibboleth.plugin.openidvci.profile.impl.ParseProof" scope="prototype" />
+        class="org.geant.shibboleth.plugin.openidvci.profile.impl.ParseProof" scope="prototype"
+        p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}" />
   
   <bean id="ProofSecurityParametersContextProfileRequestContextLookup" parent="shibboleth.Functions.Compose">
         <constructor-arg name="g">
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index ba16b66..ba997df 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -34,6 +34,7 @@
         p:proofSignatureValidationConfiguration="#{getObject('openidvci.ProofSignatureValidationConfiguration')}"
         p:credentialSignatureSigningConfiguration="#{getObject('openidvci.SigningConfiguration')}"
         p:proofNonceGenerator="#{getObject('openidvci.DefaultOpenIDVCINonceGenerator')}"
+        p:batchSize="%{openidvci.batchSize:2}"
         p:proofClaimsValidator="#{getObject('openidvci.DefaultProofBodyClaimsValidator')}" />
         
     <bean id="OpenID.VCI.CredentialOffer" parent="AbstractVCIProfile" lazy-init="true"
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
index 12d7144..f2e93e5 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
@@ -5,6 +5,9 @@
    ],
    "credential_endpoint":"$baseUrl/idp/profile/openid/vci/credentials",
    "nonce_endpoint":"$baseUrl/idp/profile/openid/vci/nonce",
+   "batch_credential_issuance":{
+      "batch_size":2
+   },
    "display":[
       {
          "name":"$host",
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProofTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProofTest.java
index 117d0f0..796518f 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProofTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProofTest.java
@@ -21,6 +21,7 @@ import java.net.URI;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCICredentialsRequest;
 import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultOpenIDVCICredentialsConfiguration;
 import org.opensaml.messaging.context.MessageContext;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.springframework.webflow.execution.RequestContext;
@@ -34,6 +35,7 @@ import com.nimbusds.oauth2.sdk.http.HTTPRequest;
 import net.shibboleth.idp.profile.testing.ActionTestingSupport;
 import net.shibboleth.idp.profile.testing.RequestContextBuilder;
 import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.profile.context.RelyingPartyContext;
 
 /**
  * Unit tests for {@link ValidateCredentialOffering}.
@@ -48,6 +50,8 @@ public class ParseProofTest {
 
     private HTTPRequest httpRequest;
 
+    private DefaultOpenIDVCICredentialsConfiguration configuration;
+
     @BeforeMethod
     protected void setUp() throws Exception {
         httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
@@ -61,6 +65,8 @@ public class ParseProofTest {
 
         requestCtx = new RequestContextBuilder().buildRequestContext();
         profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+        configuration = new DefaultOpenIDVCICredentialsConfiguration();
+        profileRequestCtx.ensureSubcontext(RelyingPartyContext.class).setProfileConfig(configuration);
         profileRequestCtx.setInboundMessageContext(new MessageContext());
         profileRequestCtx.getInboundMessageContext().setMessage(OpenIDVCICredentialsRequest.parse(httpRequest));
         action = new ParseProof();
@@ -76,6 +82,13 @@ public class ParseProofTest {
         Assert.assertEquals(ctx.getProofs().size(),2);
     }
 
+    @Test
+    public void testTooManyProofs() throws ParseException {
+        configuration.setBatchSize(1);
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.INVALID_PROOF);
+        Assert.assertNull(profileRequestCtx.getInboundMessageContext().getSubcontext(CredentialsContext.class));
+    }
+
     @Test
     public void testUnsupportedProofs() throws ParseException {
         httpRequest.setQuery("{\n" + "  \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"

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


More information about the commits mailing list