[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