[java-idp-plugin-vci] 01/02: Follow attestation-based client auth draft 12 for Wallet Attestation

Codeberg noreply at shibboleth.net
Tue Oct 27 13:49:57 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/a43c3ad4271b1f8c1b64c23c84f63a6f5ed01635

commit a43c3ad4271b1f8c1b64c23c84f63a6f5ed01635
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Tue Oct 27 15:48:34 2026 +0200

    Follow attestation-based client auth draft 12 for Wallet Attestation
---
 README.md                                          |   9 +
 .../impl/ClientAttestationCredentialValidator.java | 129 ++++++++++---
 .../plugin/openidvci/profile/impl/ParseProof.java  |   4 +-
 .../security/impl/KeyAttestationValidator.java     |  16 +-
 .../META-INF/net.shibboleth.idp/postconfig.xml     |   7 +-
 .../plugin/openidvci/conf/openid-vci.properties    |   4 +
 .../ClientAttestationCredentialValidatorTest.java  | 214 +++++++++++++++++++++
 .../security/impl/KeyAttestationValidatorTest.java |  27 ++-
 8 files changed, 361 insertions(+), 49 deletions(-)

diff --git a/README.md b/README.md
index 509e001..b1e3380 100644
--- a/README.md
+++ b/README.md
@@ -3506,6 +3506,13 @@ authenticates an attested request without checking the attestation:
 
 Full file: [conf/authn/oauth2client-authn-config.xml](docs/example/step-5/conf/authn/oauth2client-authn-config.xml)
 
+The validator follows
+[OAuth 2.0 Attestation-Based Client Authentication, draft 12](https://datatracker.ietf.org/doc/draft-ietf-oauth-attestation-based-client-auth/12/).
+The Wallet Attestation needs `sub`, `exp`, `iat` and `cnf`. The proof of possession needs `aud`,
+`jti` and `iat`; `exp` and `iss` are checked only when present. A proof of possession is accepted
+for `openidvci.attestation.proofLifetime` after its `iat`, and its `jti` is refused for that time.
+Rejections are logged at WARN with the reason.
+
 The VCI endpoints use login flow `OpenIDVCIClient` of this plugin, which knows the method. The
 PAR endpoint is the OP's, and it refuses a client registered with a method its own login flow
 does not know. Give the OAuth endpoints of the OP the same flow in *conf/oidc.properties*:
@@ -4751,6 +4758,8 @@ The file comes with the plugin and is loaded automatically.
 | `openidvci.attestation.checkRevocation` | Boolean | `false` | Check revocation of attestation chains also when no CRLs are configured. |
 | `openidvci.attestation.crlRefreshInterval` | Duration | `PT1H` | Interval of reading the CRLs of attestation chains again. |
 | `openidvci.attestation.verifyDepth` | Integer | `5` | Largest number of intermediate certificates of an attestation chain. |
+| `openidvci.attestation.clockSkew` | Duration | `PT5M` | Tolerance of the times of Wallet Attestations, their proofs of possession and key attestations. |
+| `openidvci.attestation.proofLifetime` | Duration | `PT5M` | Time a proof of possession of a Wallet Attestation is accepted after its `iat`. |
 
 #### OpenID Federation
 
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java
index 1a2db97..0aa7eee 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java
@@ -20,7 +20,9 @@ import java.text.ParseException;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Date;
+import java.util.Enumeration;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Function;
@@ -104,6 +106,10 @@ public class ClientAttestationCredentialValidator extends AbstractCredentialVali
     @Nonnull
     private Duration clockSkew = Duration.ofMinutes(5);
 
+    /** Time a proof of possession is accepted after its issue time. */
+    @Nonnull
+    private Duration proofLifetime = Duration.ofMinutes(5);
+
     /**
      * Set the credentials a chain of a client attestation is accepted under.
      *
@@ -193,6 +199,17 @@ public class ClientAttestationCredentialValidator extends AbstractCredentialVali
         clockSkew = Constraint.isNotNull(skew, "Clock skew cannot be null");
     }
 
+    /**
+     * Set the time a proof of possession is accepted after its issue time.
+     *
+     * @param lifetime time a proof of possession is accepted
+     */
+    public void setProofLifetime(@Nonnull final Duration lifetime) {
+        checkSetterPreconditions();
+
+        proofLifetime = Constraint.isNotNull(lifetime, "Proof lifetime cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -221,27 +238,51 @@ public class ClientAttestationCredentialValidator extends AbstractCredentialVali
         if (request == null) {
             return null;
         }
-        final String attestationHeader = request.getHeader(ATTESTATION_HEADER);
-        final String popHeader = request.getHeader(POP_HEADER);
-        if (attestationHeader == null && popHeader == null) {
-            log.debug("Request carries no client attestation");
+        final List<String> attestationHeaders = headerValues(request, ATTESTATION_HEADER);
+        final List<String> popHeaders = headerValues(request, POP_HEADER);
+        if (attestationHeaders.isEmpty() && popHeaders.isEmpty()) {
+            log.debug("{} Request carries no client attestation", getLogPrefix());
             return null;
         }
-        if (attestationHeader == null || popHeader == null) {
-            log.warn("Request carries only one of '{}' and '{}'", ATTESTATION_HEADER, POP_HEADER);
+        if (attestationHeaders.isEmpty() || popHeaders.isEmpty()) {
+            log.warn("{} Request carries only one of '{}' and '{}'", getLogPrefix(), ATTESTATION_HEADER, POP_HEADER);
             return null;
         }
-        final SignedJWT attestation = SignedJWT.parse(attestationHeader);
-        final SignedJWT pop = SignedJWT.parse(popHeader);
-        final String clientId = validate(profileRequestContext, attestation, pop);
-        validateRequestClientID(profileRequestContext, clientId);
+        final String clientId;
+        try {
+            if (attestationHeaders.size() > 1 || popHeaders.size() > 1) {
+                throw new JWTValidationException(
+                        "Request carries more than one '" + ATTESTATION_HEADER + "' or '" + POP_HEADER + "' header");
+            }
+            clientId = validate(profileRequestContext, SignedJWT.parse(attestationHeaders.get(0)),
+                    SignedJWT.parse(popHeaders.get(0)));
+            validateRequestClientID(profileRequestContext, clientId);
+        } catch (final JWTValidationException | ParseException e) {
+            log.warn("{} Client attestation validation failed: {}", getLogPrefix(), e.getMessage());
+            throw e;
+        }
 
         final Subject subject = new Subject();
         subject.getPrincipals().add(new UsernamePrincipal(clientId));
-        log.info("Client {} authenticated with a client attestation", clientId);
+        log.info("{} Client {} authenticated with a client attestation", getLogPrefix(), clientId);
         return super.populateSubject(subject);
     }
 
+    /**
+     * Read the values of a request header.
+     *
+     * @param request HTTP request
+     * @param name    name of the header
+     *
+     * @return the values of the header
+     */
+    @Nonnull
+    private static List<String> headerValues(@Nonnull final HttpServletRequest request, @Nonnull final String name) {
+
+        final Enumeration<String> values = request.getHeaders(name);
+        return values != null ? Collections.list(values) : List.of();
+    }
+
     /**
      * Validate the client identifier of the request against the one the client attestation names.
      *
@@ -287,19 +328,15 @@ public class ClientAttestationCredentialValidator extends AbstractCredentialVali
 
         final JWTClaimsSet attestationClaims = claims(attestation);
         final JWTClaimsSet popClaims = claims(pop);
-        validateTimes(attestationClaims, "client attestation");
-        validateTimes(popClaims, "proof of possession");
+        validateIssueTime(attestationClaims, "client attestation", null);
+        validateTimes(attestationClaims, "client attestation", true);
+        validateIssueTime(popClaims, "proof of possession", proofLifetime);
+        validateTimes(popClaims, "proof of possession", false);
 
         final String subject = attestationClaims.getSubject();
         if (subject == null || subject.isEmpty()) {
             throw new JWTValidationException("Client attestation carries no 'sub'");
         }
-        if (attestationClaims.getIssuer() == null) {
-            throw new JWTValidationException("Client attestation carries no 'iss'");
-        }
-        if (!subject.equals(popClaims.getIssuer())) {
-            throw new JWTValidationException("'iss' of the proof of possession is no 'sub' of the client attestation");
-        }
         final String audience = audienceLookupStrategy.apply(profileRequestContext);
         if (audience == null || popClaims.getAudience() == null || !popClaims.getAudience().contains(audience)) {
             throw new JWTValidationException("'aud' of the proof of possession is no " + audience);
@@ -329,6 +366,9 @@ public class ClientAttestationCredentialValidator extends AbstractCredentialVali
             if (!(key instanceof AsymmetricJWK asymmetric)) {
                 throw new JWTValidationException("Key of 'cnf' is no asymmetric key");
             }
+            if (key.isPrivate()) {
+                throw new JWTValidationException("Key of 'cnf' is a private key");
+            }
             if (!pop.verify(new DefaultJWSVerifierFactory().createJWSVerifier(pop.getHeader(),
                     asymmetric.toPublicKey()))) {
                 throw new JWTValidationException("Signature of the proof of possession does not verify");
@@ -353,10 +393,14 @@ public class ClientAttestationCredentialValidator extends AbstractCredentialVali
         if (jti == null || jti.isEmpty()) {
             throw new JWTValidationException("Proof of possession carries no 'jti'");
         }
-        final Date expires = popClaims.getExpirationTime();
+        final Date issued = popClaims.getIssueTime();
+        if (issued == null) {
+            throw new JWTValidationException("Proof of possession carries no 'iat'");
+        }
+        final Instant expires = issued.toInstant().plus(proofLifetime).plus(clockSkew);
         final String context = getClass().getName();
-        assert context != null;
-        if (!replayCache.check(context, jti, expires.toInstant())) {
+        assert context != null && expires != null;
+        if (!replayCache.check(context, jti, expires)) {
             throw new JWTValidationException("Replay detected of a proof of possession");
         }
     }
@@ -380,23 +424,48 @@ public class ClientAttestationCredentialValidator extends AbstractCredentialVali
     }
 
     /**
-     * Validate the times of a JWT, an expiration time being required of both the client attestation and its
-     * proof of possession.
+     * Validate the issue time of a JWT.
+     *
+     * @param claims   claims to validate
+     * @param what     what is validated, for the message
+     * @param lifetime time the JWT is accepted after its issue time, or null for no limit
+     *
+     * @throws JWTValidationException if the issue time is absent or not acceptable
+     */
+    private void validateIssueTime(@Nonnull final JWTClaimsSet claims, @Nonnull final String what,
+            @Nullable final Duration lifetime) throws JWTValidationException {
+
+        final Date issued = claims.getIssueTime();
+        if (issued == null) {
+            throw new JWTValidationException("The " + what + " carries no 'iat'");
+        }
+        final Instant now = Instant.now();
+        if (issued.toInstant().isAfter(now.plus(clockSkew))) {
+            throw new JWTValidationException("The " + what + " is issued in the future");
+        }
+        if (lifetime != null && issued.toInstant().plus(lifetime).plus(clockSkew).isBefore(now)) {
+            throw new JWTValidationException("The " + what + " is issued too long ago");
+        }
+    }
+
+    /**
+     * Validate the expiration and not before times of a JWT.
      *
-     * @param claims claims to validate
-     * @param what   what is validated, for the message
+     * @param claims             claims to validate
+     * @param what               what is validated, for the message
+     * @param expirationRequired whether the JWT must carry an expiration time
      *
      * @throws JWTValidationException if the times are not acceptable
      */
-    private void validateTimes(@Nonnull final JWTClaimsSet claims, @Nonnull final String what)
-            throws JWTValidationException {
+    private void validateTimes(@Nonnull final JWTClaimsSet claims, @Nonnull final String what,
+            final boolean expirationRequired) throws JWTValidationException {
 
         final Instant now = Instant.now();
         final Date expires = claims.getExpirationTime();
-        if (expires == null) {
+        if (expires == null && expirationRequired) {
             throw new JWTValidationException("The " + what + " carries no 'exp'");
         }
-        if (expires.toInstant().isBefore(now.minus(clockSkew))) {
+        if (expires != null && expires.toInstant().isBefore(now.minus(clockSkew))) {
             throw new JWTValidationException("The " + what + " is expired");
         }
         final Date notBefore = claims.getNotBeforeTime();
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 c69af72..bf83e87 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
@@ -331,7 +331,7 @@ public class ParseProof extends AbstractProfileAction {
             proofSigningAlgValuesSupported = attestationProofs.getProofSigningAlgValuesSupported();
             validateAlgorithm(parsed.getHeader().getAlgorithm());
             attested = keyAttestationValidator.validate(profileRequestContext, parsed,
-                    attestationProofs.getKeyAttestationsRequired(), keyAttestationClaimsValidator);
+                    attestationProofs.getKeyAttestationsRequired(), keyAttestationClaimsValidator, false);
         } catch (final DPoPProofNonceJWTValidationException e) {
             log.error("{} Key attestation carries an invalid nonce.", getLogPrefix(), e);
             ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_NONCE);
@@ -420,7 +420,7 @@ public class ParseProof extends AbstractProfileAction {
         }
         validateAlgorithm(parsed.getHeader().getAlgorithm());
         final List<JWK> attested = keyAttestationValidator.validate(profileRequestContext, parsed,
-                keyAttestationsRequired, keyAttestationClaimsValidator);
+                keyAttestationsRequired, keyAttestationClaimsValidator, true);
         if (!KeyAttestationValidator.attests(attested, jwtProof)) {
             throw new JWTValidationException("Key proof is signed by a key the key attestation does not attest");
         }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java
index 34b37f5..2f74f38 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java
@@ -170,6 +170,7 @@ public class KeyAttestationValidator extends AbstractInitializableComponent {
      * @param attestation           key attestation to validate
      * @param required              requirements of the credential configuration, or null
      * @param validator             validator of the claims, or null to use the configured one
+     * @param expirationRequired    whether the attestation must carry an expiration time
      *
      * @return the attested keys
      *
@@ -178,7 +179,8 @@ public class KeyAttestationValidator extends AbstractInitializableComponent {
     @Nonnull
     public List<JWK> validate(@Nonnull final ProfileRequestContext profileRequestContext,
             @Nonnull final SignedJWT attestation, @Nullable final KeyAttestationsRequired required,
-            @Nullable final ClaimsValidator validator) throws JWTValidationException {
+            @Nullable final ClaimsValidator validator, final boolean expirationRequired)
+            throws JWTValidationException {
 
         final JWSHeader header = attestation.getHeader();
         if (!TYPE.equals(header.getType())) {
@@ -195,7 +197,7 @@ public class KeyAttestationValidator extends AbstractInitializableComponent {
         } catch (final java.text.ParseException e) {
             throw new JWTValidationException("Unable to read the claims of the key attestation", e);
         }
-        validateTimes(claims);
+        validateTimes(claims, expirationRequired);
         final ClaimsValidator effective = validator != null ? validator : claimsValidator;
         if (effective != null) {
             effective.validate(claims, profileRequestContext);
@@ -268,11 +270,13 @@ public class KeyAttestationValidator extends AbstractInitializableComponent {
     /**
      * Validate the issue and expiration times of a key attestation.
      *
-     * @param claims claims of the key attestation
+     * @param claims             claims of the key attestation
+     * @param expirationRequired whether the attestation must carry an expiration time
      *
      * @throws JWTValidationException if the times are not acceptable
      */
-    private void validateTimes(@Nonnull final JWTClaimsSet claims) throws JWTValidationException {
+    private void validateTimes(@Nonnull final JWTClaimsSet claims, final boolean expirationRequired)
+            throws JWTValidationException {
 
         final Date issued = claims.getIssueTime();
         if (issued == null) {
@@ -283,10 +287,10 @@ public class KeyAttestationValidator extends AbstractInitializableComponent {
             throw new JWTValidationException("Key attestation is issued in the future");
         }
         final Date expires = claims.getExpirationTime();
-        if (expires == null) {
+        if (expires == null && expirationRequired) {
             throw new JWTValidationException("Key attestation carries no 'exp'");
         }
-        if (expires.toInstant().isBefore(now.minus(clockSkew))) {
+        if (expires != null && expires.toInstant().isBefore(now.minus(clockSkew))) {
             throw new JWTValidationException("Key attestation is expired");
         }
     }
diff --git a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 1c5f34f..b9ef989 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -64,7 +64,8 @@
         p:CRLs="#{getObject('openidvci.KeyAttestationCRLs')}"
         p:CRLRefreshInterval="%{openidvci.attestation.crlRefreshInterval:PT1H}"
         p:trustEvaluator="#{getObject('openidvci.AttestationTrustEvaluator') ?: getObject('openidvci.DefaultAttestationTrustEvaluator')}"
-        p:claimsValidator="#{getObject('openidvci.KeyAttestationClaimsValidator')}" />
+        p:claimsValidator="#{getObject('openidvci.KeyAttestationClaimsValidator')}"
+        p:clockSkew="%{openidvci.attestation.clockSkew:PT5M}" />
 
     <bean id="openidvci.ClientAttestationValidator" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.authn.impl.ClientAttestationCredentialValidator"
@@ -74,7 +75,9 @@
         p:trustEvaluator="#{getObject('openidvci.AttestationTrustEvaluator') ?: getObject('openidvci.DefaultAttestationTrustEvaluator')}"
         p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
         p:replayCache-ref="shibboleth.ReplayCache"
-        p:audienceLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple" />
+        p:audienceLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple"
+        p:clockSkew="%{openidvci.attestation.clockSkew:PT5M}"
+        p:proofLifetime="%{openidvci.attestation.proofLifetime:PT5M}" />
         
     <bean id="openidvci.TokenManipulationStrategy"
         class="org.geant.shibboleth.plugin.openidvci.profile.logic.OpenIDVCITokenManipulationStrategy" />
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index f9448a4..7888d6f 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -123,6 +123,10 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
 #openidvci.attestation.crlRefreshInterval = PT1H
 # Largest number of intermediate certificates of a chain
 #openidvci.attestation.verifyDepth = 5
+# Tolerance of the times of Wallet Attestations, their proofs of possession and key attestations
+#openidvci.attestation.clockSkew = PT5M
+# Time a proof of possession of a Wallet Attestation is accepted after its 'iat'
+#openidvci.attestation.proofLifetime = PT5M
 
 # Settings for the loggingId of each flow
 #openidvci.logging.credentials = OpenID.VCI.Credentials
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidatorTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidatorTest.java
new file mode 100644
index 0000000..75a1192
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidatorTest.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.authn.impl;
+
+import java.io.InputStream;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import javax.security.auth.Subject;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.crypto.KeySupport;
+import org.opensaml.security.x509.BasicX509Credential;
+import org.opensaml.security.x509.X509Support;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.principal.UsernamePrincipal;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.shared.collection.CollectionSupport;
+
+/** Unit tests for {@link ClientAttestationCredentialValidator}. */
+public class ClientAttestationCredentialValidatorTest {
+
+    private static final String CLIENT_ID = "eudiw-abca";
+
+    private static final String AUDIENCE = "https://issuer.example.org";
+
+    private X509Certificate signerCertificate;
+
+    private PrivateKey signerKey;
+
+    private ECKey instanceKey;
+
+    private MockHttpServletRequest request;
+
+    private ProfileRequestContext prc;
+
+    private AuthenticationContext authnContext;
+
+    private ClientAttestationCredentialValidator validator;
+
+    private X509Certificate read(final String name) throws Exception {
+        try (InputStream in = getClass().getResourceAsStream("/credentials/" + name)) {
+            return X509Support.decodeCertificate(in.readAllBytes());
+        }
+    }
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        signerCertificate = read("attestation-signer.crt");
+        try (InputStream in = getClass().getResourceAsStream("/credentials/attestation-signer.key")) {
+            signerKey = KeySupport.decodePrivateKey(in.readAllBytes(), null);
+        }
+        instanceKey = new ECKeyGenerator(Curve.P_256).generate();
+        request = new MockHttpServletRequest();
+        prc = new ProfileRequestContext();
+        authnContext = new AuthenticationContext();
+        final Set<String> seen = new HashSet<>();
+        validator = new ClientAttestationCredentialValidator();
+        validator.setId("test");
+        validator.setTrustAnchors(CollectionSupport.listOf(new BasicX509Credential(read("attestation-ca.crt"))));
+        validator.setHttpServletRequestSupplier(() -> request);
+        validator.setReplayCache((context, key, expires) -> seen.add(context + key));
+        validator.setAudienceLookupStrategy(context -> AUDIENCE);
+        validator.initialize();
+    }
+
+    private String attestation(final JWTClaimsSet.Builder claims) throws Exception {
+        final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256)
+                .type(ClientAttestationCredentialValidator.ATTESTATION_TYPE)
+                .x509CertChain(Arrays.asList(Base64.encode(signerCertificate.getEncoded()))).build();
+        final SignedJWT jwt = new SignedJWT(header, claims.build());
+        jwt.sign(new ECDSASigner(signerKey, Curve.P_256));
+        return jwt.serialize();
+    }
+
+    private JWTClaimsSet.Builder attestationClaims() {
+        return new JWTClaimsSet.Builder().subject(CLIENT_ID).issueTime(new Date())
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("cnf", Map.of("jwk", instanceKey.toPublicJWK().toJSONObject()));
+    }
+
+    private String pop(final JWTClaimsSet.Builder claims) throws Exception {
+        final SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256)
+                .type(ClientAttestationCredentialValidator.POP_TYPE).build(), claims.build());
+        jwt.sign(new ECDSASigner(instanceKey));
+        return jwt.serialize();
+    }
+
+    private JWTClaimsSet.Builder popClaims() {
+        return new JWTClaimsSet.Builder().audience(AUDIENCE).jwtID(UUID.randomUUID().toString())
+                .issueTime(new Date());
+    }
+
+    private Subject validate(final String attestation, final String pop) throws Exception {
+        request.addHeader(ClientAttestationCredentialValidator.ATTESTATION_HEADER, attestation);
+        request.addHeader(ClientAttestationCredentialValidator.POP_HEADER, pop);
+        return validator.validate(prc, authnContext, null, null);
+    }
+
+    @Test
+    public void testDraft12() throws Exception {
+        final Subject subject = validate(attestation(attestationClaims()), pop(popClaims()));
+        Assert.assertTrue(subject.getPrincipals().contains(new UsernamePrincipal(CLIENT_ID)));
+    }
+
+    @Test
+    public void testIssuersAndExpiration() throws Exception {
+        final Subject subject = validate(attestation(attestationClaims().issuer("https://wallet-provider.example.org")),
+                pop(popClaims().issuer(CLIENT_ID).expirationTime(Date.from(Instant.now().plusSeconds(60)))));
+        Assert.assertTrue(subject.getPrincipals().contains(new UsernamePrincipal(CLIENT_ID)));
+    }
+
+    @Test
+    public void testNoHeaders() throws Exception {
+        Assert.assertNull(validator.validate(prc, authnContext, null, null));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testProofWithoutIssueTime() throws Exception {
+        validate(attestation(attestationClaims()), pop(popClaims().issueTime(null)));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testProofTooOld() throws Exception {
+        validate(attestation(attestationClaims()),
+                pop(popClaims().issueTime(Date.from(Instant.now().minusSeconds(3600)))));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testProofInFuture() throws Exception {
+        validate(attestation(attestationClaims()),
+                pop(popClaims().issueTime(Date.from(Instant.now().plusSeconds(3600)))));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testProofExpired() throws Exception {
+        validate(attestation(attestationClaims()),
+                pop(popClaims().expirationTime(Date.from(Instant.now().minusSeconds(3600)))));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testProofWrongAudience() throws Exception {
+        validate(attestation(attestationClaims()), pop(popClaims().audience("https://other.example.org")));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testProofReplayed() throws Exception {
+        final String attestation = attestation(attestationClaims());
+        final String pop = pop(popClaims());
+        validate(attestation, pop);
+        request = new MockHttpServletRequest();
+        validate(attestation, pop);
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testAttestationWithoutIssueTime() throws Exception {
+        validate(attestation(attestationClaims().issueTime(null)), pop(popClaims()));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testAttestationWithoutExpiration() throws Exception {
+        validate(attestation(attestationClaims().expirationTime(null)), pop(popClaims()));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testPrivateKeyInConfirmation() throws Exception {
+        validate(attestation(attestationClaims().claim("cnf", Map.of("jwk", instanceKey.toJSONObject()))),
+                pop(popClaims()));
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testDuplicateHeader() throws Exception {
+        final String attestation = attestation(attestationClaims());
+        request.addHeader(ClientAttestationCredentialValidator.ATTESTATION_HEADER, attestation);
+        validate(attestation, pop(popClaims()));
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidatorTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidatorTest.java
index 4d70617..5100ac5 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidatorTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidatorTest.java
@@ -128,56 +128,65 @@ public class KeyAttestationValidatorTest {
 
     @Test
     public void testValid() throws Exception {
-        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null);
+        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null, true);
         Assert.assertEquals(attested.size(), 1);
         Assert.assertEquals(attested.get(0).computeThumbprint(), attestedKey.computeThumbprint());
     }
 
     @Test(expectedExceptions = JWTValidationException.class)
     public void testUnknownAnchor() throws Exception {
-        validatorTrusting("attestation-other-ca.crt").validate(prc, validAttestation(), null, null);
+        validatorTrusting("attestation-other-ca.crt").validate(prc, validAttestation(), null, null, true);
     }
 
     @Test(expectedExceptions = JWTValidationException.class)
     public void testNoAnchors() throws Exception {
         final KeyAttestationValidator trusting = new KeyAttestationValidator();
         trusting.initialize();
-        trusting.validate(prc, validAttestation(), null, null);
+        trusting.validate(prc, validAttestation(), null, null, true);
     }
 
     @Test(expectedExceptions = JWTValidationException.class)
     public void testWrongType() throws Exception {
         validator.validate(prc, attestation(new JOSEObjectType("jwt"), Instant.now(),
-                Instant.now().plusSeconds(300), List.of(attestedKey.toPublicJWK().toJSONObject()), null), null, null);
+                Instant.now().plusSeconds(300),
+                List.of(attestedKey.toPublicJWK().toJSONObject()), null), null, null, true);
     }
 
     @Test(expectedExceptions = JWTValidationException.class)
     public void testExpired() throws Exception {
         validator.validate(prc, attestation(KeyAttestationValidator.TYPE, Instant.now().minusSeconds(7200),
-                Instant.now().minusSeconds(3600), List.of(attestedKey.toPublicJWK().toJSONObject()), null), null, null);
+                Instant.now().minusSeconds(3600),
+                List.of(attestedKey.toPublicJWK().toJSONObject()), null), null, null, true);
     }
 
     @Test(expectedExceptions = JWTValidationException.class)
     public void testNoExpiration() throws Exception {
         validator.validate(prc, attestation(KeyAttestationValidator.TYPE, Instant.now(), null,
-                List.of(attestedKey.toPublicJWK().toJSONObject()), null), null, null);
+                List.of(attestedKey.toPublicJWK().toJSONObject()), null), null, null, true);
+    }
+
+    @Test
+    public void testNoExpirationNotRequired() throws Exception {
+        final List<JWK> attested = validator.validate(prc, attestation(KeyAttestationValidator.TYPE, Instant.now(),
+                null, List.of(attestedKey.toPublicJWK().toJSONObject()), null), null, null, false);
+        Assert.assertEquals(attested.size(), 1);
     }
 
     @Test(expectedExceptions = JWTValidationException.class)
     public void testNoAttestedKeys() throws Exception {
         validator.validate(prc, attestation(KeyAttestationValidator.TYPE, Instant.now(),
-                Instant.now().plusSeconds(300), null, null), null, null);
+                Instant.now().plusSeconds(300), null, null), null, null, true);
     }
 
     @Test
     public void testAttestsProofOfAttestedKey() throws Exception {
-        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null);
+        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null, true);
         Assert.assertTrue(KeyAttestationValidator.attests(attested, proofOf(attestedKey)));
     }
 
     @Test
     public void testDoesNotAttestProofOfOtherKey() throws Exception {
-        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null);
+        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null, true);
         Assert.assertFalse(
                 KeyAttestationValidator.attests(attested, proofOf(new ECKeyGenerator(Curve.P_256).generate())));
     }

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


More information about the commits mailing list