[java-idp-plugin-vci] 01/02: Attestation implemented to level OpenId Conformance Tests can begin

Codeberg noreply at shibboleth.net
Wed Sep 23 14:51:43 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/90658cdce79b51a37bccd1e644f10f720feb5cdf

commit 90658cdce79b51a37bccd1e644f10f720feb5cdf
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Sep 23 16:07:16 2026 +0300

    Attestation implemented to level OpenId Conformance Tests can begin
---
 README.md                                          | 163 +++++++++
 .../profile/config/OpenIDVCIConfiguration.java     |  12 +
 .../impl/ClientAttestationCredentialValidator.java | 372 +++++++++++++++++++++
 .../messaging/impl/CredentialSuccessResponse.java  |   4 -
 .../messaging/impl/OpenIDVCITokenRequest.java      |  31 +-
 .../CredentialIssuerIdentifierLookupFunction.java  |  68 ++++
 .../impl/AbstractOpenIDVCIConfiguration.java       |  33 ++
 .../plugin/openidvci/profile/impl/ParseProof.java  | 195 ++++++++++-
 .../security/impl/CertificateChainTrust.java       | 129 +++++++
 .../security/impl/KeyAttestationValidator.java     | 333 ++++++++++++++++++
 .../impl/StatusListAssignmentsSuccessResponse.java |   3 -
 .../messaging/impl/StatusListSuccessResponse.java  |   3 -
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  12 +
 .../openid/vci/credentials/credentials-beans.xml   |   3 +-
 .../idp/service/relying-party/postconfig.xml       |  25 +-
 .../openidvci/profile/impl/ParseProofTest.java     |  22 ++
 .../security/impl/KeyAttestationValidatorTest.java | 185 ++++++++++
 17 files changed, 1568 insertions(+), 25 deletions(-)

diff --git a/README.md b/README.md
index 10adc8a..764ef0c 100644
--- a/README.md
+++ b/README.md
@@ -1009,6 +1009,126 @@ for a month that has ended is `410 retired_status_list` and asking for something
 month at all is `404 unknown_status_list`, so a verifier is able to tell a withdrawn list from
 a wrong URL.
 
+## Two more installations
+
+The installation above is a DIIP deployment, issuing `GeantIncubatorDiploma_SDJWT` and
+`GeantIncubatorDiploma_W3C` with both flows. These two carry it further, and state only what
+changes.
+
+### The deployment in an OpenID Federation
+
+The federation itself is configured in the plugins of [Dependencies](#dependencies), see
+[OIDFEDCommon](https://shibboleth.atlassian.net/wiki/spaces/IDPPLUGINS/pages/5601296385/OIDFEDCommon)
+of the Shibboleth wiki.
+
+To state this deployment as a Credential Issuer of the federation, import the file in
+*conf/global.xml*:
+
+```xml
+<import resource="openid-vci-oidfed.xml"/>
+```
+
+- **openidvci.SigningCredentials** has to be defined, see [Signing keys](#signing-keys).
+- `idp.oidfed.entityID` and `credential_issuer` have to name the same deployment. A mismatch is
+  logged and published.
+
+Credentials then carry `fed` and `termsOfUse`, per credential `"oidfed": false` leaves them out,
+see
+[Whether a credential names this deployment in a federation](#whether-a-credential-names-this-deployment-in-a-federation).
+
+### The deployment under HAIP
+
+To publish the certificate chain in credentials, Status List Tokens and signed metadata, name
+the issuer with a url in *conf/openid-vci.properties*:
+
+```properties
+openidvci.issuer = https://issuer.example.org
+```
+
+To sign with a certificate rather than with a bare key, define the credential from the key and
+the certificates in *conf/openid-vci-credentials.xml*, the certificate of this deployment first
+and the certificate of the trust anchor left out:
+
+```xml
+<bean id="openidvci.DefaultESSigningCredential" parent="shibboleth.BasicX509CredentialFactoryBean"
+    p:privateKey="%{idp.home}/credentials/vci-signing.key">
+    <property name="certificates">
+        <list>
+            <value>%{idp.home}/credentials/vci-signing.crt</value>
+            <value>%{idp.home}/credentials/issuing-ca.crt</value>
+        </list>
+    </property>
+</bean>
+```
+
+and list it in **openidvci.SigningCredentials** and in
+**openidvci.issuerMetadata.SigningCredentials**.
+
+To require a key attestation of the wallet, add the requirement to the credential in
+*metadata/verifiable-credentials.json*:
+
+```json
+"proof_types_supported": {
+  "jwt": {
+    "proof_signing_alg_values_supported": [ "ES256" ],
+    "key_attestations_required": {
+      "key_storage": [ "iso_18045_moderate" ],
+      "user_authentication": [ "iso_18045_moderate" ]
+    }
+  }
+}
+```
+
+To accept key attestations and Wallet Attestations, name the certificates they are signed under
+in *conf/global.xml*:
+
+```xml
+<util:list id="openidvci.KeyAttestationTrustAnchors">
+    <bean parent="shibboleth.BasicX509CredentialFactoryBean"
+        p:entity="%{idp.home}/credentials/attester-ca.crt"
+        p:certificates="#{{'%{idp.home}/credentials/attester-ca.crt'}}" />
+</util:list>
+
+<util:list id="openidvci.ClientAttestationTrustAnchors">
+    <bean parent="shibboleth.BasicX509CredentialFactoryBean"
+        p:entity="%{idp.home}/credentials/attester-ca.crt"
+        p:certificates="#{{'%{idp.home}/credentials/attester-ca.crt'}}" />
+</util:list>
+```
+
+To authenticate the wallet with a Wallet Attestation, add the validator to the login flow in
+*conf/authn/oauth2client-authn-config.xml*, before the validator that authenticates a public
+client:
+
+```xml
+<util:list id="shibboleth.authn.OAuth2Client.Validators">
+    <ref bean="shibboleth.OIDCClientInfoValidator" />
+    <ref bean="shibboleth.JWTValidator" />
+    <ref bean="openidvci.ClientAttestationValidator" />
+    <ref bean="openidvci.PublicClientValidator" />
+</util:list>
+```
+
+The same bean serves the PAR endpoint of the OP when you wire it there.
+
+To name this deployment in the authorization response, set it on the wallet's **OIDC.SSO** in
+*conf/relying-party.xml*, beside the PKCE and PAR settings of the
+[Authorization code flow](#authorization-code-flow):
+
+```xml
+<bean parent="OIDC.SSO" p:authorizationCodeClaimsSetManipulationStrategy-ref="openidvci.TokenManipulationStrategy"
+    p:forcePKCE="true" p:requirePushedAuthorizationRequest="true" p:includeIssuerInResponse="true" />
+```
+
+To advertise all of it, add the members in *static/oauth-authorization-server.json*:
+
+```json
+"token_endpoint_auth_methods_supported":[ "attest_jwt_client_auth" ],
+"client_attestation_signing_alg_values_supported":[ "ES256" ],
+"client_attestation_pop_signing_alg_values_supported":[ "ES256" ],
+"authorization_response_iss_parameter_supported":true
+```
+
 ## Reference
 
 **File(s):** *conf/relying-party.xml*
@@ -1669,6 +1789,26 @@ A worked example with every claim and with a Transaction Code is in
 | `preAuthorizedCodeLifetime` | Duration | `PT10M` | Lifetime of the pre-authorized code. |
 | `preAuthorizedCodeLength` | Integer | `0` | Length of the pre-authorized code. `0` is a self-contained code, `10` or more is a code that refers to an offer in storage. |
 
+#### Wallet Attestation as client authentication
+
+A wallet authenticates with the `OAuth-Client-Attestation` and `OAuth-Client-Attestation-PoP`
+headers when you add **openidvci.ClientAttestationValidator** to the validators of the
+`OAuth2Client` login flow, in *conf/authn/oauth2client-authn-config.xml*:
+
+```xml
+<util:list id="shibboleth.authn.OAuth2Client.Validators">
+    <ref bean="shibboleth.OIDCClientInfoValidator" />
+    <ref bean="shibboleth.JWTValidator" />
+    <ref bean="openidvci.ClientAttestationValidator" />
+</util:list>
+```
+
+The attestation is accepted when its `x5c` chain validates to one of the certificates of
+**openidvci.ClientAttestationTrustAnchors**, the proof of possession verifies with the key of
+the attestation's `cnf`, its `aud` is the issuer of this deployment and its `jti` has not been
+seen before. The client identifier is the `sub` of the attestation. The same validator serves
+the PAR endpoint of the OP when you wire it there.
+
 ### OpenID.VCI.Token
 
 Turns a code into an access token. A `POST` of `application/x-www-form-urlencoded` to
@@ -1729,6 +1869,25 @@ Issues the credential. A `POST` of `application/json` to
 
 </details>
 
+A Credential Configuration that carries `key_attestations_required` in its
+`proof_types_supported.jwt` makes a key attestation mandatory in every key proof of that
+credential. The attestation is read from the `key_attestation` header of the proof, its `x5c`
+chain has to validate to one of the certificates of **openidvci.KeyAttestationTrustAnchors**,
+and the proof itself has to be signed by one of the keys the attestation attests. The
+`key_storage` and `user_authentication` values you require are checked against the ones the
+attestation asserts. Define the anchors in *conf/global.xml* or in any other imported file:
+
+```xml
+<util:list id="openidvci.KeyAttestationTrustAnchors">
+    <bean parent="shibboleth.BasicX509CredentialFactoryBean"
+        p:entity="%{idp.home}/credentials/key-attestation-ca.crt"
+        p:certificates="#{{'%{idp.home}/credentials/key-attestation-ca.crt'}}" />
+</util:list>
+```
+
+Publishing `key_attestations_required` without anchors is a configuration error and the request
+is refused, so publish the member and the anchors together.
+
 The wallet forms the request and its parameters are the ones of the specification. Two things
 of it are worth knowing. Only key proofs of type `jwt` are supported and one credential is
 issued per proof, each of them taking a status list slot of its own when the credential takes
@@ -2122,6 +2281,10 @@ definitions in *conf/global.xml* or in any other location that is imported.
 | **openidvci.issuerMetadata.TemplateContext** | Map | Define to add or replace the `$issuer`, `$baseUrl` and `$host` Velocity variables. |
 | **openidvci.jwtVcIssuer.TemplateContext** | Map | The same for the JWT VC issuer metadata document. |
 | **openidvci.authorizationServer.TemplateContext** | Map | The same for the authorization server metadata document. |
+| **openidvci.KeyAttestationTrustAnchors** | List<Credential> | Certificates a key attestation is accepted under. Define to accept key attestations. |
+| **openidvci.KeyAttestationClaimsValidator** | ClaimsValidator | Define to validate the claims of a key attestation, the `nonce` of it among them. |
+| **openidvci.ClientAttestationValidator** | CredentialValidator | Authenticates a wallet with the `OAuth-Client-Attestation` headers. Add it to **shibboleth.authn.OAuth2Client.Validators**. |
+| **openidvci.ClientAttestationTrustAnchors** | List<Credential> | Certificates a client attestation is accepted under. Define to use the validator above. |
 | **openidvci.SigningCredentials** | List<Credential> | Define in *conf/openid-vci-credentials.xml* to sign credentials with your own key. Replaces the OP's credentials. |
 | **openidvci.status-list.SigningCredentials** | List<Credential> | Define to sign Status List Tokens with a key of their own. |
 | **openidvci.issuerMetadata.SigningCredentials** | List<Credential> | Define to enable signed issuer metadata. |
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 9166c26..e0ebaac 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
@@ -178,6 +178,18 @@ public interface OpenIDVCIConfiguration extends ConditionalProfileConfiguration
     @Nullable
     ClaimsValidator getProofClaimsValidator(@Nullable final ProfileRequestContext profileRequestContext);
 
+    /**
+     * Get the {@link ClaimsValidator} to apply to the key attestations of Proof JWTs being
+     * validated by this profile.
+     * 
+     * @param profileRequestContext current profile request context
+     * 
+     * @return the validator to use
+     */
+    @ConfigurationSetting(name = "keyAttestationClaimsValidator")
+    @Nullable
+    ClaimsValidator getKeyAttestationClaimsValidator(@Nullable final ProfileRequestContext profileRequestContext);
+
     /**
      * Get the {@link Function} to create nonces to be used with Proof JWTs.
      * 
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
new file mode 100644
index 0000000..d9b14ad
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/authn/impl/ClientAttestationCredentialValidator.java
@@ -0,0 +1,372 @@
+/*
+ * 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.text.ParseException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Date;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.security.auth.Subject;
+
+import org.geant.shibboleth.plugin.openidvci.security.impl.CertificateChainTrust;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.storage.ReplayCache;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory;
+import com.nimbusds.jose.jwk.AsymmetricJWK;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.oauth2.sdk.AbstractOptionallyIdentifiedRequest;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.authn.AbstractCredentialValidator;
+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.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Credential validator of the Wallet Attestation of OpenID4VCI, the 'OAuth-Client-Attestation' and
+ * 'OAuth-Client-Attestation-PoP' headers.
+ */
+public class ClientAttestationCredentialValidator extends AbstractCredentialValidator {
+
+    /** Header carrying the client attestation. */
+    @Nonnull
+    public static final String ATTESTATION_HEADER = "OAuth-Client-Attestation";
+
+    /** Header carrying the proof of possession of the client attestation. */
+    @Nonnull
+    public static final String POP_HEADER = "OAuth-Client-Attestation-PoP";
+
+    /** Type of a client attestation. */
+    @Nonnull
+    public static final JOSEObjectType ATTESTATION_TYPE = new JOSEObjectType("oauth-client-attestation+jwt");
+
+    /** Type of a proof of possession of a client attestation. */
+    @Nonnull
+    public static final JOSEObjectType POP_TYPE = new JOSEObjectType("oauth-client-attestation-pop+jwt");
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(ClientAttestationCredentialValidator.class);
+
+    /** Trust of the chain of a client attestation. */
+    @Nonnull
+    private final CertificateChainTrust chainTrust = new CertificateChainTrust();
+
+    /** Supplier of the servlet request the headers are read from. */
+    @NonnullAfterInit
+    private Supplier<HttpServletRequest> httpServletRequestSupplier;
+
+    /** Replay cache of the proofs of possession. */
+    @NonnullAfterInit
+    private ReplayCache replayCache;
+
+    /** Strategy used to obtain the audience a proof of possession is for. */
+    @NonnullAfterInit
+    private Function<ProfileRequestContext, String> audienceLookupStrategy;
+
+    /** Tolerance of the times of the attestation and of its proof of possession. */
+    @Nonnull
+    private Duration clockSkew = Duration.ofMinutes(5);
+
+    /**
+     * Set the credentials a chain of a client attestation is accepted under.
+     *
+     * @param credentials credentials of the trust anchors
+     */
+    public void setTrustAnchors(@Nullable final Collection<Credential> credentials) {
+        checkSetterPreconditions();
+
+        chainTrust.setTrustAnchors(credentials);
+    }
+
+    /**
+     * Set the supplier of the servlet request.
+     *
+     * @param supplier supplier of the servlet request
+     */
+    public void setHttpServletRequestSupplier(@Nonnull final Supplier<HttpServletRequest> supplier) {
+        checkSetterPreconditions();
+
+        httpServletRequestSupplier = Constraint.isNotNull(supplier, "Servlet request supplier cannot be null");
+    }
+
+    /**
+     * Set the replay cache of the proofs of possession.
+     *
+     * @param cache replay cache to use
+     */
+    public void setReplayCache(@Nonnull final ReplayCache cache) {
+        checkSetterPreconditions();
+
+        replayCache = Constraint.isNotNull(cache, "ReplayCache cannot be null");
+    }
+
+    /**
+     * Set the strategy used to obtain the audience a proof of possession is for, the issuer of the
+     * Authorization Server.
+     *
+     * @param strategy strategy to obtain the audience
+     */
+    public void setAudienceLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+        checkSetterPreconditions();
+
+        audienceLookupStrategy = Constraint.isNotNull(strategy, "Audience lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the tolerance of the times of the attestation and of its proof of possession.
+     *
+     * @param skew tolerance of the times
+     */
+    public void setClockSkew(@Nonnull final Duration skew) {
+        checkSetterPreconditions();
+
+        clockSkew = Constraint.isNotNull(skew, "Clock skew cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (httpServletRequestSupplier == null) {
+            throw new ComponentInitializationException("Servlet request supplier cannot be null");
+        }
+        if (replayCache == null) {
+            throw new ComponentInitializationException("ReplayCache cannot be null");
+        }
+        if (audienceLookupStrategy == null) {
+            throw new ComponentInitializationException("Audience lookup strategy cannot be null");
+        }
+        chainTrust.initialize();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    protected Subject doValidate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final AuthenticationContext authenticationContext, @Nullable final WarningHandler warningHandler,
+            @Nullable final ErrorHandler errorHandler) throws Exception {
+
+        final HttpServletRequest request = httpServletRequestSupplier.get();
+        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");
+            return null;
+        }
+        if (attestationHeader == null || popHeader == null) {
+            log.warn("Request carries only one of '{}' and '{}'", 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 Subject subject = new Subject();
+        subject.getPrincipals().add(new UsernamePrincipal(clientId));
+        log.info("Client {} authenticated with a client attestation", clientId);
+        return super.populateSubject(subject);
+    }
+
+    /**
+     * Validate the client identifier of the request against the one the client attestation names.
+     *
+     * @param profileRequestContext profile request context
+     * @param clientId              client identifier of the attestation
+     *
+     * @throws JWTValidationException if the request names another client
+     */
+    private void validateRequestClientID(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final String clientId) throws JWTValidationException {
+
+        final MessageContext inbound = profileRequestContext.getInboundMessageContext();
+        final Object message = inbound != null ? inbound.getMessage() : null;
+        if (message instanceof AbstractOptionallyIdentifiedRequest request && request.getClientID() != null
+                && !clientId.equals(request.getClientID().getValue())) {
+            throw new JWTValidationException("Request names client " + request.getClientID().getValue()
+                    + ", the client attestation names " + clientId);
+        }
+    }
+
+    /**
+     * Validate a client attestation and its proof of possession.
+     *
+     * @param profileRequestContext profile request context
+     * @param attestation           client attestation
+     * @param pop                   proof of possession of the client attestation
+     *
+     * @return the client identifier the attestation names
+     *
+     * @throws JWTValidationException if either is not acceptable
+     */
+    @Nonnull
+    private String validate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final SignedJWT attestation, @Nonnull final SignedJWT pop) throws JWTValidationException {
+
+        if (!ATTESTATION_TYPE.equals(attestation.getHeader().getType())) {
+            throw new JWTValidationException("typ of a client attestation must be " + ATTESTATION_TYPE);
+        }
+        if (!POP_TYPE.equals(pop.getHeader().getType())) {
+            throw new JWTValidationException("typ of a proof of possession must be " + POP_TYPE);
+        }
+        chainTrust.verify(attestation);
+
+        final JWTClaimsSet attestationClaims = claims(attestation);
+        final JWTClaimsSet popClaims = claims(pop);
+        validateTimes(attestationClaims, "client attestation");
+        validateTimes(popClaims, "proof of possession");
+
+        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);
+        }
+        verifyProofOfPossession(attestationClaims, pop);
+        spend(popClaims);
+        return subject;
+    }
+
+    /**
+     * Verify the proof of possession with the key the client attestation confirms.
+     *
+     * @param attestationClaims claims of the client attestation
+     * @param pop               proof of possession to verify
+     *
+     * @throws JWTValidationException if the key is absent or the signature does not verify
+     */
+    private void verifyProofOfPossession(@Nonnull final JWTClaimsSet attestationClaims,
+            @Nonnull final SignedJWT pop) throws JWTValidationException {
+
+        final Object confirmation = attestationClaims.getClaim("cnf");
+        if (!(confirmation instanceof Map<?, ?> cnf) || !(cnf.get("jwk") instanceof Map<?, ?> jwk)) {
+            throw new JWTValidationException("Client attestation confirms no key in 'cnf'");
+        }
+        try {
+            final JWK key = JWK.parse(new ObjectMapper().writeValueAsString(jwk));
+            if (!(key instanceof AsymmetricJWK asymmetric)) {
+                throw new JWTValidationException("Key of 'cnf' is no asymmetric key");
+            }
+            if (!pop.verify(new DefaultJWSVerifierFactory().createJWSVerifier(pop.getHeader(),
+                    asymmetric.toPublicKey()))) {
+                throw new JWTValidationException("Signature of the proof of possession does not verify");
+            }
+        } catch (final JWTValidationException e) {
+            throw e;
+        } catch (final Exception e) {
+            throw new JWTValidationException("Unable to verify the proof of possession", e);
+        }
+    }
+
+    /**
+     * Spend the identifier of a proof of possession.
+     *
+     * @param popClaims claims of the proof of possession
+     *
+     * @throws JWTValidationException if the identifier is absent or spent already
+     */
+    private void spend(@Nonnull final JWTClaimsSet popClaims) throws JWTValidationException {
+
+        final String jti = popClaims.getJWTID();
+        if (jti == null || jti.isEmpty()) {
+            throw new JWTValidationException("Proof of possession carries no 'jti'");
+        }
+        final Date expires = popClaims.getExpirationTime();
+        final String context = getClass().getName();
+        assert context != null;
+        if (!replayCache.check(context, jti, expires.toInstant())) {
+            throw new JWTValidationException("Replay detected of a proof of possession");
+        }
+    }
+
+    /**
+     * Read the claims of a JWT.
+     *
+     * @param jwt JWT to read
+     *
+     * @return the claims
+     *
+     * @throws JWTValidationException if the claims cannot be read
+     */
+    @Nonnull
+    private JWTClaimsSet claims(@Nonnull final SignedJWT jwt) throws JWTValidationException {
+        try {
+            return jwt.getJWTClaimsSet();
+        } catch (final ParseException e) {
+            throw new JWTValidationException("Unable to read the claims", e);
+        }
+    }
+
+    /**
+     * Validate the times of a JWT, an expiration time being required of both the client attestation and its
+     * proof of possession.
+     *
+     * @param claims claims to validate
+     * @param what   what is validated, for the message
+     *
+     * @throws JWTValidationException if the times are not acceptable
+     */
+    private void validateTimes(@Nonnull final JWTClaimsSet claims, @Nonnull final String what)
+            throws JWTValidationException {
+
+        final Instant now = Instant.now();
+        final Date expires = claims.getExpirationTime();
+        if (expires == null) {
+            throw new JWTValidationException("The " + what + " carries no 'exp'");
+        }
+        if (expires.toInstant().isBefore(now.minus(clockSkew))) {
+            throw new JWTValidationException("The " + what + " is expired");
+        }
+        final Date notBefore = claims.getNotBeforeTime();
+        if (notBefore != null && notBefore.toInstant().isAfter(now.plus(clockSkew))) {
+            throw new JWTValidationException("The " + what + " is not yet valid");
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialSuccessResponse.java
index d4ebd46..4943af8 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialSuccessResponse.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialSuccessResponse.java
@@ -16,8 +16,6 @@
 
 package org.geant.shibboleth.plugin.openidvci.messaging.impl;
 
-import java.time.ZonedDateTime;
-import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -90,8 +88,6 @@ public class CredentialSuccessResponse implements SuccessResponse {
         httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
         httpResponse.setCacheControl("no-store");
         httpResponse.setPragma("no-cache");
-
-        httpResponse.setHeader("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()));
         try {
             httpResponse.setContent(toJSONString());
         } catch (final JsonProcessingException e) {
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCITokenRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCITokenRequest.java
index 922fbff..374370d 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCITokenRequest.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/OpenIDVCITokenRequest.java
@@ -35,6 +35,7 @@ import com.nimbusds.oauth2.sdk.TokenRequest;
 import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
 import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
 import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.jwt.SignedJWT;
 import com.nimbusds.oauth2.sdk.id.ClientID;
 import com.nimbusds.oauth2.sdk.pkce.CodeVerifier;
 import com.nimbusds.oauth2.sdk.util.MultivaluedMapUtils;
@@ -49,6 +50,10 @@ public class OpenIDVCITokenRequest extends TokenRequest {
     /** Grant Type value for pre-auth flow. */
     public static final String GRANT_TYPE_VALUE_PRE_AUTH = "urn:ietf:params:oauth:grant-type:pre-authorized_code";
 
+    /** Header carrying a client attestation, the client identifier of an attested request. */
+    @Nonnull
+    private static final String CLIENT_ATTESTATION_HEADER = "OAuth-Client-Attestation";
+
     /** Grant Type value for code flow. */
     public static final String GRANT_TYPE_VALUE_CODE = "authorization_code";
 
@@ -252,6 +257,27 @@ public class OpenIDVCITokenRequest extends TokenRequest {
         return null;
     }
 
+    /**
+     * Read the client identifier a client attestation names, the 'sub' of the
+     * 'OAuth-Client-Attestation' header.
+     * 
+     * @param httpRequest request to read
+     * @return the client identifier, or null if the request carries no readable attestation
+     */
+    @Nullable
+    private static String attestedClientID(@Nonnull final HTTPRequest httpRequest) {
+
+        final String attestation = httpRequest.getHeaderValue(CLIENT_ATTESTATION_HEADER);
+        if (attestation == null || attestation.isEmpty()) {
+            return null;
+        }
+        try {
+            return SignedJWT.parse(attestation).getJWTClaimsSet().getSubject();
+        } catch (final java.text.ParseException e) {
+            return null;
+        }
+    }
+
     /**
      * Parses request from http request.
      * 
@@ -299,7 +325,10 @@ public class OpenIDVCITokenRequest extends TokenRequest {
             return new OpenIDVCITokenRequest(uri, clientAuth, grantType, preAuthorizedCode, code, txCode, codeVerifier,
                     authorizationDetails);
         }
-        final String clientIDString = MultivaluedMapUtils.getFirstValue(params, "client_id");
+        String clientIDString = MultivaluedMapUtils.getFirstValue(params, "client_id");
+        if (StringUtils.isBlank(clientIDString)) {
+            clientIDString = attestedClientID(httpRequest);
+        }
         if (StringUtils.isBlank(clientIDString)) {
             return new OpenIDVCITokenRequest(uri, (ClientID) null, grantType, preAuthorizedCode, code, txCode,
                     codeVerifier, authorizationDetails);
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialIssuerIdentifierLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialIssuerIdentifierLookupFunction.java
new file mode 100644
index 0000000..bace19c
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialIssuerIdentifierLookupFunction.java
@@ -0,0 +1,68 @@
+/*
+ * 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.metadata.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/** Function returning the Credential Issuer Identifier of the published metadata. */
+public class CredentialIssuerIdentifierLookupFunction implements Function<ProfileRequestContext, String> {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(CredentialIssuerIdentifierLookupFunction.class);
+
+    /** Resolver of the published Credential Issuer metadata. */
+    @Nonnull
+    private final CredentialIssuerMetadataResolver metadataResolver;
+
+    /**
+     * Constructor.
+     *
+     * @param resolver resolver of the published Credential Issuer metadata
+     */
+    public CredentialIssuerIdentifierLookupFunction(@Nonnull final CredentialIssuerMetadataResolver resolver) {
+        metadataResolver = Constraint.isNotNull(resolver, "Metadata resolver cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public String apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        try {
+            final CredentialIssuerMetadata metadata = metadataResolver.resolveSingle(profileRequestContext);
+            return metadata != null && metadata.getCredentialIssuer() != null
+                    ? metadata.getCredentialIssuer().getValue()
+                    : null;
+        } catch (final ResolverException e) {
+            log.error("Unable to resolve the Credential Issuer metadata", e);
+            return null;
+        }
+    }
+
+}
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 494a7b8..0d6eafd 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
@@ -68,6 +68,10 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
     @Nonnull
     private Function<ProfileRequestContext, ClaimsValidator> proofClaimsValidatorLookupStrategy;
 
+    /** Lookup function to retrieve the validator of the claims of a key attestation. */
+    @Nonnull
+    private Function<ProfileRequestContext, ClaimsValidator> keyAttestationClaimsValidatorLookupStrategy;
+
     /** Lookup function to retrieve nonce generator for proofs. */
     @Nonnull
     private Function<ProfileRequestContext, Function<ProfileRequestContext, String>> proofNonceGeneratorLookupStrategy;
@@ -86,6 +90,7 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
         proofSignatureValidationConfigurationLookupStrategy = FunctionSupport.constant(null);
         credentialSignatureSigningConfigurationLookupStrategy = FunctionSupport.constant(null);
         proofClaimsValidatorLookupStrategy = FunctionSupport.constant(null);
+        keyAttestationClaimsValidatorLookupStrategy = FunctionSupport.constant(null);
         proofNonceGeneratorLookupStrategy = FunctionSupport.constant(null);
     }
 
@@ -265,6 +270,34 @@ public abstract class AbstractOpenIDVCIConfiguration extends AbstractOIDCSSOConf
         return proofClaimsValidatorLookupStrategy.apply(profileRequestContext);
     }
 
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public ClaimsValidator getKeyAttestationClaimsValidator(
+            @Nullable final ProfileRequestContext profileRequestContext) {
+        return keyAttestationClaimsValidatorLookupStrategy.apply(profileRequestContext);
+    }
+
+    /**
+     * Set the validator of the claims of a key attestation.
+     * 
+     * @param validator validator of the claims of a key attestation
+     */
+    public void setKeyAttestationClaimsValidator(@Nullable final ClaimsValidator validator) {
+        keyAttestationClaimsValidatorLookupStrategy = FunctionSupport.constant(validator);
+    }
+
+    /**
+     * Set the lookup strategy of the validator of the claims of a key attestation.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setKeyAttestationClaimsValidatorLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, ClaimsValidator> strategy) {
+        keyAttestationClaimsValidatorLookupStrategy =
+                Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+    }
+
     /**
      * Set the {@link ClaimsValidator} to apply to Proof JWT.
      * 
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 e7d97f6..5813604 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
@@ -17,7 +17,10 @@
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
 import java.text.ParseException;
+import java.time.Duration;
+import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Function;
@@ -26,25 +29,31 @@ import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.credential.KeyAttestationsRequired;
+import org.geant.shibboleth.plugin.openidvci.credential.ProofTypeSupported;
 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.geant.shibboleth.plugin.openidvci.security.impl.KeyAttestationValidator;
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.nimbusds.jose.Algorithm;
 import com.nimbusds.jose.JOSEObjectType;
 import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jose.JWSObject;
 import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jose.jwk.JWK;
 import com.nimbusds.jwt.SignedJWT;
 
 import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
 import net.shibboleth.idp.plugin.oidc.op.security.jwt.claims.impl.DPoPProofNonceJWTValidationException;
 import net.shibboleth.oidc.jwt.claims.ClaimsValidator;
 import net.shibboleth.oidc.jwt.claims.JWTValidationException;
@@ -56,6 +65,14 @@ import net.shibboleth.shared.resolver.ResolverException;
 /** Action that parses the key proofs of a Credential request. */
 public class ParseProof extends AbstractProfileAction {
 
+    /** Proof type this action reads. */
+    @Nonnull
+    private static final String PROOF_TYPE_JWT = "jwt";
+
+    /** Header of a key proof carrying a key attestation. */
+    @Nonnull
+    private static final String KEY_ATTESTATION_HEADER = "key_attestation";
+
     /** Class logger. */
     @Nonnull
     private Logger log = LoggerFactory.getLogger(ParseProof.class);
@@ -86,6 +103,30 @@ public class ParseProof extends AbstractProfileAction {
     /** Maximum number of key proofs this request may carry. */
     private int batchSize;
 
+    /** Validator of the key attestations of the proofs. */
+    @Nullable
+    private KeyAttestationValidator keyAttestationValidator;
+
+    /** Key attestations the requested credential configuration requires, null when it requires none. */
+    @Nullable
+    private KeyAttestationsRequired keyAttestationsRequired;
+
+    /** Algorithms the requested credential configuration accepts a proof to be signed with. */
+    @Nullable
+    private List<String> proofSigningAlgValuesSupported;
+
+    /** Validator of the claims of a key attestation. */
+    @Nullable
+    private ClaimsValidator keyAttestationClaimsValidator;
+
+    /** Largest age of a key proof. */
+    @Nonnull
+    private Duration proofMaximumAge = Duration.ofMinutes(10);
+
+    /** Tolerance of the issue time of a key proof. */
+    @Nonnull
+    private Duration clockSkew = Duration.ofMinutes(5);
+
     /**
      * Constructor.
      */
@@ -119,6 +160,39 @@ public class ParseProof extends AbstractProfileAction {
         metadataResolver = resolver;
     }
 
+    /**
+     * Set the validator of the key attestations of the proofs.
+     * 
+     * @param validator validator of the key attestations
+     */
+    public void setKeyAttestationValidator(@Nullable final KeyAttestationValidator validator) {
+        checkSetterPreconditions();
+
+        keyAttestationValidator = validator;
+    }
+
+    /**
+     * Set the largest age of a key proof.
+     * 
+     * @param age largest age of a key proof
+     */
+    public void setProofMaximumAge(@Nonnull final Duration age) {
+        checkSetterPreconditions();
+
+        proofMaximumAge = Constraint.isNotNull(age, "Maximum age cannot be null");
+    }
+
+    /**
+     * Set the tolerance of the issue time of a key proof.
+     * 
+     * @param skew tolerance of the issue time
+     */
+    public void setClockSkew(@Nonnull final Duration skew) {
+        checkSetterPreconditions();
+
+        clockSkew = Constraint.isNotNull(skew, "Clock skew cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
@@ -128,6 +202,7 @@ public class ParseProof extends AbstractProfileAction {
         rpCtx = relyingPartyContextLookupStrategy.apply(profileRequestContext);
         if (rpCtx != null && rpCtx.getProfileConfig() instanceof OpenIDVCIConfiguration configuration) {
             validator = configuration.getProofClaimsValidator(profileRequestContext);
+            keyAttestationClaimsValidator = configuration.getKeyAttestationClaimsValidator(profileRequestContext);
             batchSize = configuration.getBatchSize(profileRequestContext);
         } else {
             batchSize = 1;
@@ -143,6 +218,15 @@ public class ParseProof extends AbstractProfileAction {
                 .getMessage() instanceof OpenIDVCICredentialsRequest request) {
             proof = request.getProofs();
         }
+        final ProofTypeSupported jwtProofs = resolveJwtProofs(profileRequestContext);
+        keyAttestationsRequired = jwtProofs != null ? jwtProofs.getKeyAttestationsRequired() : null;
+        proofSigningAlgValuesSupported = jwtProofs != null ? jwtProofs.getProofSigningAlgValuesSupported() : null;
+        if (keyAttestationsRequired != null && keyAttestationValidator == null) {
+            log.error("{} Requested credential configuration requires key attestations, no validator is configured",
+                    getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+            return false;
+        }
         if (proof == null) {
             if (isProofRequired(profileRequestContext)) {
                 log.error("{} Requested credential configuration requires a key proof, request carries none",
@@ -159,7 +243,7 @@ public class ParseProof extends AbstractProfileAction {
     /** {@inheritDoc} */
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
-        final Object proofToken = proof.get("jwt");
+        final Object proofToken = proof.get(PROOF_TYPE_JWT);
         if (proofToken == null) {
             log.error("{} Only proofs of type 'jwt' are supported {}", getLogPrefix(), proof);
             ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.PROOF_TYPE_UNSUPPORTED);
@@ -186,6 +270,7 @@ public class ParseProof extends AbstractProfileAction {
             try {
                 final SignedJWT singleProof = SignedJWT.parse(strToken);
                 validateJWTProof(singleProof, profileRequestContext);
+                validateKeyAttestation(singleProof, profileRequestContext);
                 proofs.add(singleProof);
             } catch (final DPoPProofNonceJWTValidationException e) {
                 log.error("{} proof carries an invalid nonce.", getLogPrefix(), e);
@@ -231,11 +316,113 @@ public class ParseProof extends AbstractProfileAction {
         if (fields != 1) {
             throw new IllegalArgumentException("Exactly one of jwk, x5c, or kid must be present");
         }
-        // Validate nonce, audience and iat.
-        // TODO: iat is not verified per spec.
+        validateAlgorithm(jwtProof.getHeader().getAlgorithm());
+        final JWTClaimsSet claims = JWTClaimsSet.parse(jwtProof.getPayload().toJSONObject());
+        validateIssueTime(claims.getIssueTime());
         if (validator != null) {
-            validator.validate(JWTClaimsSet.parse(jwtProof.getPayload().toJSONObject()), profileRequestContext);
+            validator.validate(claims, profileRequestContext);
+        }
+    }
+
+    /**
+     * Validate the key attestation of a key proof, the 'key_attestation' header of the proof.
+     *
+     * @param jwtProof              key proof to read the attestation from
+     * @param profileRequestContext profile request context
+     *
+     * @throws JWTValidationException if the attestation is absent where it is required, or not acceptable
+     */
+    private void validateKeyAttestation(@Nonnull final SignedJWT jwtProof,
+            @Nonnull final ProfileRequestContext profileRequestContext) throws JWTValidationException {
+
+        final Object header = jwtProof.getHeader().getCustomParam(KEY_ATTESTATION_HEADER);
+        if (header == null) {
+            if (keyAttestationsRequired != null) {
+                throw new JWTValidationException("Requested credential configuration requires a key attestation, "
+                        + "the proof carries none");
+            }
+            return;
+        }
+        if (keyAttestationValidator == null) {
+            return;
+        }
+        if (!(header instanceof String attestation)) {
+            throw new JWTValidationException("'" + KEY_ATTESTATION_HEADER + "' header is no string");
+        }
+        final SignedJWT parsed;
+        try {
+            parsed = SignedJWT.parse(attestation);
+        } catch (final ParseException e) {
+            throw new JWTValidationException("Unable to read the key attestation", e);
+        }
+        validateAlgorithm(parsed.getHeader().getAlgorithm());
+        final List<JWK> attested = keyAttestationValidator.validate(profileRequestContext, parsed,
+                keyAttestationsRequired, keyAttestationClaimsValidator);
+        if (!KeyAttestationValidator.attests(attested, jwtProof)) {
+            throw new JWTValidationException("Key proof is signed by a key the key attestation does not attest");
+        }
+    }
+
+    /**
+     * Validate a signature algorithm against the ones the requested credential configuration
+     * advertises.
+     *
+     * @param algorithm algorithm to validate
+     *
+     * @throws IllegalArgumentException if the algorithm is not one of the advertised ones
+     */
+    private void validateAlgorithm(@Nullable final Algorithm algorithm) {
+
+        if (proofSigningAlgValuesSupported == null || proofSigningAlgValuesSupported.isEmpty()) {
+            return;
+        }
+        if (algorithm == null || !proofSigningAlgValuesSupported.contains(algorithm.getName())) {
+            throw new IllegalArgumentException("alg " + algorithm + " is none of the advertised "
+                    + proofSigningAlgValuesSupported);
+        }
+    }
+
+    /**
+     * Validate the issue time of a key proof.
+     *
+     * @param issued issue time of the proof
+     *
+     * @throws IllegalArgumentException if the time is absent or outside the window this issuer accepts
+     */
+    private void validateIssueTime(@Nullable final Date issued) {
+
+        if (issued == null) {
+            throw new IllegalArgumentException("Key proof carries no 'iat'");
+        }
+        final Instant now = Instant.now();
+        if (issued.toInstant().isAfter(now.plus(clockSkew))) {
+            throw new IllegalArgumentException("Key proof is issued in the future");
+        }
+        if (issued.toInstant().isBefore(now.minus(proofMaximumAge))) {
+            throw new IllegalArgumentException("Key proof is older than " + proofMaximumAge);
+        }
+    }
+
+    /**
+     * Read what the requested credential configuration says of 'jwt' key proofs.
+     *
+     * @param profileRequestContext profile request context
+     *
+     * @return the proof type, or null when the configuration declares none
+     */
+    @Nullable
+    private ProofTypeSupported resolveJwtProofs(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        final CredentialsContext credentialsContext = profileRequestContext.getInboundMessageContext() != null
+                ? profileRequestContext.getInboundMessageContext().getSubcontext(CredentialsContext.class)
+                : null;
+        final CredentialConfiguration configuration = credentialsContext != null
+                ? credentialsContext.getCredentialConfiguration()
+                : null;
+        if (configuration == null || configuration.getProofTypesSupported() == null) {
+            return null;
         }
+        return configuration.getProofTypesSupported().get(PROOF_TYPE_JWT);
     }
 
     /**
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrust.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrust.java
new file mode 100644
index 0000000..c0d7088
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/CertificateChainTrust.java
@@ -0,0 +1,129 @@
+/*
+ * 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.security.impl;
+
+import java.security.cert.CertPath;
+import java.security.cert.CertPathValidator;
+import java.security.cert.CertificateFactory;
+import java.security.cert.PKIXParameters;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.x509.X509Credential;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jose.util.X509CertUtils;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+
+/**
+ * Verifies a JWT signed by a key named in the 'x5c' header, the chain validating to one of the certificates
+ * this component is configured with.
+ */
+public class CertificateChainTrust extends AbstractInitializableComponent {
+
+    /** Certificates a chain is accepted under. */
+    @Nonnull
+    private Collection<X509Certificate> trustAnchors = new ArrayList<>();
+
+    /**
+     * Set the credentials a chain is accepted under.
+     *
+     * @param credentials credentials of the trust anchors
+     */
+    public void setTrustAnchors(@Nullable final Collection<Credential> credentials) {
+        checkSetterPreconditions();
+
+        final Collection<X509Certificate> certificates = new ArrayList<>();
+        if (credentials != null) {
+            credentials.stream().filter(X509Credential.class::isInstance).map(X509Credential.class::cast)
+                    .forEach(credential -> certificates.addAll(credential.getEntityCertificateChain()));
+        }
+        trustAnchors = certificates;
+    }
+
+    /**
+     * Whether this component is able to accept a chain.
+     *
+     * @return true if trust anchors are configured
+     */
+    public boolean isConfigured() {
+        return !trustAnchors.isEmpty();
+    }
+
+    /**
+     * Verify the signature of a JWT, the chain of its 'x5c' header validating to one of the trust anchors.
+     *
+     * @param jwt JWT to verify
+     *
+     * @return the certificate chain of the JWT, the leaf first
+     *
+     * @throws JWTValidationException if the chain or the signature is not acceptable
+     */
+    @Nonnull
+    public List<X509Certificate> verify(@Nonnull final SignedJWT jwt) throws JWTValidationException {
+
+        final List<Base64> encoded = jwt.getHeader().getX509CertChain();
+        if (encoded == null || encoded.isEmpty()) {
+            throw new JWTValidationException("JWT carries no 'x5c' header");
+        }
+        if (trustAnchors.isEmpty()) {
+            throw new JWTValidationException("No trust anchors to accept a chain under");
+        }
+        final List<X509Certificate> chain = new ArrayList<>();
+        for (final Base64 certificate : encoded) {
+            final X509Certificate parsed = X509CertUtils.parse(certificate.decode());
+            if (parsed == null) {
+                throw new JWTValidationException("Unable to read a certificate of the 'x5c' header");
+            }
+            chain.add(parsed);
+        }
+        try {
+            final Set<TrustAnchor> anchors = new HashSet<>();
+            trustAnchors.forEach(certificate -> anchors.add(new TrustAnchor(certificate, null)));
+            final CertPath path = CertificateFactory.getInstance("X.509").generateCertPath(chain);
+            final PKIXParameters parameters = new PKIXParameters(anchors);
+            parameters.setRevocationEnabled(false);
+            CertPathValidator.getInstance("PKIX").validate(path, parameters);
+        } catch (final Exception e) {
+            throw new JWTValidationException("Chain of the JWT does not validate", e);
+        }
+        try {
+            if (!jwt.verify(new DefaultJWSVerifierFactory().createJWSVerifier(jwt.getHeader(),
+                    chain.get(0).getPublicKey()))) {
+                throw new JWTValidationException("Signature of the JWT does not verify");
+            }
+        } catch (final JOSEException e) {
+            throw new JWTValidationException("Unable to verify the signature of the JWT", e);
+        }
+        return chain;
+    }
+
+}
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
new file mode 100644
index 0000000..ae90d7c
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidator.java
@@ -0,0 +1,333 @@
+/*
+ * 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.security.impl;
+
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.credential.KeyAttestationsRequired;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jose.util.X509CertUtils;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.oidc.jwt.claims.ClaimsValidator;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Validator of the key attestations of OpenID4VCI, the JWT format of section D.1.
+ */
+public class KeyAttestationValidator extends AbstractInitializableComponent {
+
+    /** Type of a key attestation. */
+    @Nonnull
+    public static final JOSEObjectType TYPE = new JOSEObjectType("key-attestation+jwt");
+
+    /** Claim carrying the attested keys. */
+    @Nonnull
+    public static final String ATTESTED_KEYS_CLAIM = "attested_keys";
+
+    /** Claim carrying the resistance of the key storage component. */
+    @Nonnull
+    public static final String KEY_STORAGE_CLAIM = "key_storage";
+
+    /** Claim carrying the resistance of the user authentication methods. */
+    @Nonnull
+    public static final String USER_AUTHENTICATION_CLAIM = "user_authentication";
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(KeyAttestationValidator.class);
+
+    /** Trust of the chain of a key attestation. */
+    @Nonnull
+    private final CertificateChainTrust chainTrust = new CertificateChainTrust();
+
+    /** Validator of the claims of a key attestation. */
+    @Nullable
+    private ClaimsValidator claimsValidator;
+
+    /** Tolerance of the issue time. */
+    @Nonnull
+    private Duration clockSkew = Duration.ofMinutes(5);
+
+    /**
+     * Set the credentials a chain of a key attestation is accepted under.
+     *
+     * @param credentials credentials of the trust anchors
+     */
+    public void setTrustAnchors(@Nullable final Collection<Credential> credentials) {
+        checkSetterPreconditions();
+
+        chainTrust.setTrustAnchors(credentials);
+    }
+
+    /**
+     * Set the validator of the claims of a key attestation.
+     *
+     * @param validator validator of the claims
+     */
+    public void setClaimsValidator(@Nullable final ClaimsValidator validator) {
+        checkSetterPreconditions();
+
+        claimsValidator = validator;
+    }
+
+    /**
+     * Set the tolerance of the issue time.
+     *
+     * @param skew tolerance of the issue time
+     */
+    public void setClockSkew(@Nonnull final Duration skew) {
+        checkSetterPreconditions();
+
+        clockSkew = Constraint.isNotNull(skew, "Clock skew cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        chainTrust.initialize();
+    }
+
+    /**
+     * Validate a key attestation and read the keys it attests.
+     *
+     * @param profileRequestContext profile request context
+     * @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
+     *
+     * @return the attested keys
+     *
+     * @throws JWTValidationException if the attestation is not acceptable
+     */
+    @Nonnull
+    public List<JWK> validate(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final SignedJWT attestation, @Nullable final KeyAttestationsRequired required,
+            @Nullable final ClaimsValidator validator) throws JWTValidationException {
+
+        final JWSHeader header = attestation.getHeader();
+        if (!TYPE.equals(header.getType())) {
+            throw new JWTValidationException("typ of a key attestation must be " + TYPE + ", was " + header.getType());
+        }
+        if (JWSAlgorithm.Family.HMAC_SHA.contains(header.getAlgorithm())) {
+            throw new JWTValidationException("HMAC algorithm used: " + header.getAlgorithm().getName());
+        }
+        chainTrust.verify(attestation);
+
+        final JWTClaimsSet claims;
+        try {
+            claims = attestation.getJWTClaimsSet();
+        } catch (final java.text.ParseException e) {
+            throw new JWTValidationException("Unable to read the claims of the key attestation", e);
+        }
+        validateTimes(claims);
+        final ClaimsValidator effective = validator != null ? validator : claimsValidator;
+        if (effective != null) {
+            effective.validate(claims, profileRequestContext);
+        }
+        validateResistance(claims, required);
+        return attestedKeys(claims);
+    }
+
+    /**
+     * Whether a key proof is signed by one of the attested keys.
+     *
+     * @param attestedKeys keys the attestation attests
+     * @param proof        key proof to check
+     *
+     * @return true if the proof names one of the attested keys
+     */
+    public static boolean attests(@Nonnull final List<JWK> attestedKeys, @Nonnull final SignedJWT proof) {
+
+        final JWK proofKey = proofKey(proof.getHeader());
+        if (proofKey == null) {
+            return false;
+        }
+        try {
+            final String thumbprint = proofKey.computeThumbprint().toString();
+            for (final JWK attested : attestedKeys) {
+                if (thumbprint.equals(attested.computeThumbprint().toString())) {
+                    return true;
+                }
+            }
+        } catch (final com.nimbusds.jose.JOSEException e) {
+            return false;
+        }
+        return false;
+    }
+
+    /**
+     * Read the key a key proof is signed with.
+     *
+     * @param header header of the key proof
+     *
+     * @return the key, or null if the header names none this issuer reads
+     */
+    @Nullable
+    private static JWK proofKey(@Nonnull final JWSHeader header) {
+
+        if (header.getJWK() != null) {
+            return header.getJWK();
+        }
+        final String kid = header.getKeyID();
+        if (kid != null && kid.startsWith(DidSupport.DID_JWK_PREFIX)) {
+            final String encoded = kid.substring(DidSupport.DID_JWK_PREFIX.length()).split("#")[0];
+            try {
+                return JWK.parse(new String(java.util.Base64.getUrlDecoder().decode(encoded)));
+            } catch (final IllegalArgumentException | java.text.ParseException e) {
+                return null;
+            }
+        }
+        if (header.getX509CertChain() != null && !header.getX509CertChain().isEmpty()) {
+            final X509Certificate certificate = X509CertUtils.parse(header.getX509CertChain().get(0).decode());
+            if (certificate != null) {
+                try {
+                    return JWK.parse(certificate);
+                } catch (final com.nimbusds.jose.JOSEException e) {
+                    return null;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Validate the issue and expiration times of a key attestation.
+     *
+     * @param claims claims of the key attestation
+     *
+     * @throws JWTValidationException if the times are not acceptable
+     */
+    private void validateTimes(@Nonnull final JWTClaimsSet claims) throws JWTValidationException {
+
+        final Date issued = claims.getIssueTime();
+        if (issued == null) {
+            throw new JWTValidationException("Key attestation carries no 'iat'");
+        }
+        final Instant now = Instant.now();
+        if (issued.toInstant().isAfter(now.plus(clockSkew))) {
+            throw new JWTValidationException("Key attestation is issued in the future");
+        }
+        final Date expires = claims.getExpirationTime();
+        if (expires == null) {
+            throw new JWTValidationException("Key attestation carries no 'exp'");
+        }
+        if (expires.toInstant().isBefore(now.minus(clockSkew))) {
+            throw new JWTValidationException("Key attestation is expired");
+        }
+    }
+
+    /**
+     * Validate the attack potential resistance of a key attestation.
+     *
+     * @param claims   claims of the key attestation
+     * @param required requirements of the credential configuration, or null
+     *
+     * @throws JWTValidationException if the attestation asserts none of the required values
+     */
+    private void validateResistance(@Nonnull final JWTClaimsSet claims,
+            @Nullable final KeyAttestationsRequired required) throws JWTValidationException {
+
+        if (required == null) {
+            return;
+        }
+        validateResistance(claims, KEY_STORAGE_CLAIM, required.getKeyStorage());
+        validateResistance(claims, USER_AUTHENTICATION_CLAIM, required.getUserAuthentication());
+    }
+
+    /**
+     * Validate one attack potential resistance claim.
+     *
+     * @param claims   claims of the key attestation
+     * @param claim    claim to read
+     * @param accepted values the credential configuration accepts, or null
+     *
+     * @throws JWTValidationException if the claim asserts none of the accepted values
+     */
+    private void validateResistance(@Nonnull final JWTClaimsSet claims, @Nonnull final String claim,
+            @Nullable final List<String> accepted) throws JWTValidationException {
+
+        if (accepted == null || accepted.isEmpty()) {
+            return;
+        }
+        final List<String> asserted;
+        try {
+            asserted = claims.getStringListClaim(claim);
+        } catch (final java.text.ParseException e) {
+            throw new JWTValidationException("Unable to read '" + claim + "' of the key attestation", e);
+        }
+        if (asserted == null || asserted.stream().noneMatch(accepted::contains)) {
+            throw new JWTValidationException(
+                    "Key attestation asserts no accepted '" + claim + "', accepted are " + accepted);
+        }
+    }
+
+    /**
+     * Read the attested keys of a key attestation.
+     *
+     * @param claims claims of the key attestation
+     *
+     * @return the attested keys
+     *
+     * @throws JWTValidationException if the claim is absent or unreadable
+     */
+    @Nonnull
+    private List<JWK> attestedKeys(@Nonnull final JWTClaimsSet claims) throws JWTValidationException {
+
+        final Object claim = claims.getClaim(ATTESTED_KEYS_CLAIM);
+        if (!(claim instanceof List<?> keys) || keys.isEmpty()) {
+            throw new JWTValidationException("Key attestation carries no '" + ATTESTED_KEYS_CLAIM + "'");
+        }
+        final List<JWK> attested = new ArrayList<>();
+        for (final Object key : keys) {
+            try {
+                attested.add(JWK.parse(new ObjectMapper().writeValueAsString(key)));
+            } catch (final Exception e) {
+                throw new JWTValidationException("Unable to read an attested key", e);
+            }
+        }
+        log.debug("Key attestation attests {} key(s)", attested.size());
+        return attested;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java
index 0e06350..e3fd8c4 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java
@@ -16,8 +16,6 @@
 
 package org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl;
 
-import java.time.ZonedDateTime;
-import java.time.format.DateTimeFormatter;
 
 import javax.annotation.Nonnull;
 
@@ -71,7 +69,6 @@ public class StatusListAssignmentsSuccessResponse implements SuccessResponse {
         httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
         httpResponse.setCacheControl("no-store");
         httpResponse.setPragma("no-cache");
-        httpResponse.setHeader("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()));
         try {
             httpResponse.setContent(records.serialize());
         } catch (final JsonProcessingException e) {
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListSuccessResponse.java
index 1cdb5e4..b5d8d64 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListSuccessResponse.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListSuccessResponse.java
@@ -17,8 +17,6 @@
 package org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl;
 
 import java.text.ParseException;
-import java.time.ZonedDateTime;
-import java.time.format.DateTimeFormatter;
 
 import javax.annotation.Nonnull;
 
@@ -71,7 +69,6 @@ public class StatusListSuccessResponse implements SuccessResponse {
     public HTTPResponse toHTTPResponse() {
         final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
         httpResponse.setEntityContentType(CONTENT_TYPE);
-        httpResponse.setHeader("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()));
         // TODO: Cache-Control belongs here
         httpResponse.setContent(token);
         return httpResponse;
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 4971545..d69fecd 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
@@ -23,6 +23,18 @@
 
     <bean id="openidvci.PublicClientValidator"
         class="org.geant.shibboleth.plugin.openidvci.authn.impl.WalletCredentialValidator" />
+
+    <bean id="openidvci.DefaultKeyAttestationValidator" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.security.impl.KeyAttestationValidator"
+        p:trustAnchors="#{getObject('openidvci.KeyAttestationTrustAnchors')}"
+        p:claimsValidator="#{getObject('openidvci.KeyAttestationClaimsValidator')}" />
+
+    <bean id="openidvci.ClientAttestationValidator" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.authn.impl.ClientAttestationCredentialValidator"
+        p:trustAnchors="#{getObject('openidvci.ClientAttestationTrustAnchors')}"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        p:replayCache-ref="shibboleth.ReplayCache"
+        p:audienceLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple" />
         
     <bean id="openidvci.TokenManipulationStrategy"
         class="org.geant.shibboleth.plugin.openidvci.profile.logic.OpenIDVCITokenManipulationStrategy" />
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 ddeb85a..7b867fb 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
@@ -38,7 +38,8 @@
         
   <bean id="ParseProof"
         class="org.geant.shibboleth.plugin.openidvci.profile.impl.ParseProof" scope="prototype"
-        p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}" />
+        p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}"
+        p:keyAttestationValidator="#{getObject('%{openidvci.keyAttestationValidator:openidvci.DefaultKeyAttestationValidator}'.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 ba997df..62b5df2 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
@@ -35,7 +35,8 @@
         p:credentialSignatureSigningConfiguration="#{getObject('openidvci.SigningConfiguration')}"
         p:proofNonceGenerator="#{getObject('openidvci.DefaultOpenIDVCINonceGenerator')}"
         p:batchSize="%{openidvci.batchSize:2}"
-        p:proofClaimsValidator="#{getObject('openidvci.DefaultProofBodyClaimsValidator')}" />
+        p:proofClaimsValidator="#{getObject('openidvci.DefaultProofBodyClaimsValidator')}"
+        p:keyAttestationClaimsValidator="#{getObject('openidvci.ProofNonceValidator')}" />
         
     <bean id="OpenID.VCI.CredentialOffer" parent="AbstractVCIProfile" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultOpenIDVCICredentialOfferConfiguration" />
@@ -112,25 +113,31 @@
         class="org.geant.shibboleth.plugin.openidvci.profile.config.navigate.ProofNonceGeneratorLookupFunction" />    
 
     <util:list id="openidvci.ProofBodyClaimsValidator" value-type="net.shibboleth.oidc.jwt.claims.ClaimsValidator">
-        <bean id="openidvci.ProofNonceValidator"
-            class="net.shibboleth.idp.plugin.oidc.op.security.jwt.claims.impl.DPoPProofNonceClaimsValidator"
-            p:objectMapper-ref="shibboleth.oidc.JSONObjectMapper"
-            c:sealer-ref="DefaultDPoPNonceSealer"
-            p:dpopProofNonceGeneratorLookupStrategy-ref="openidvci.ProofNonceGeneratorLookupFunction"
-            p:relyingPartyIdLookupStrategy-ref="openidvci.RelyingPartyForNonce">
-        </bean>
+        <ref bean="openidvci.ProofNonceValidator" />
         <ref bean="openidvci.ProofAudienceClaimsValidator" />
     </util:list>
 
+    <bean id="openidvci.ProofNonceValidator"
+        class="net.shibboleth.idp.plugin.oidc.op.security.jwt.claims.impl.DPoPProofNonceClaimsValidator"
+        p:objectMapper-ref="shibboleth.oidc.JSONObjectMapper"
+        c:sealer-ref="DefaultDPoPNonceSealer"
+        p:dpopProofNonceGeneratorLookupStrategy-ref="openidvci.ProofNonceGeneratorLookupFunction"
+        p:relyingPartyIdLookupStrategy-ref="openidvci.RelyingPartyForNonce" />
+
+
     <bean id="openidvci.ProofAudienceClaimsValidator"
         class="net.shibboleth.oidc.security.jwt.claims.impl.AudienceClaimsValidator">
          <property name="audienceLookupStrategy">
             <bean parent="shibboleth.BiFunctions.Expression"
                 c:expression="#custom.apply(#input1)"
-                p:customObject-ref="shibboleth.ResponderIdLookup.Simple" />
+                p:customObject-ref="openidvci.CredentialIssuerIdentifierLookup" />
         </property>
     </bean>
 
+    <bean id="openidvci.CredentialIssuerIdentifierLookup"
+        class="org.geant.shibboleth.plugin.openidvci.metadata.impl.CredentialIssuerIdentifierLookupFunction"
+        c:resolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}" />
+
     <bean id="openidvci.SigningConfiguration"
         parent="shibboleth.oidc.BasicSignatureSigningConfiguration"
         p:signingCredentials="#{getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}">
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 814ec03..90b9cc3 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
@@ -17,6 +17,7 @@
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
 import java.net.URI;
+import java.time.Duration;
 
 import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
@@ -34,6 +35,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.nimbusds.oauth2.sdk.ParseException;
 import com.nimbusds.oauth2.sdk.http.HTTPRequest;
 
+import net.shibboleth.idp.profile.IdPEventIds;
 import net.shibboleth.idp.profile.testing.ActionTestingSupport;
 import net.shibboleth.idp.profile.testing.RequestContextBuilder;
 import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
@@ -72,6 +74,7 @@ public class ParseProofTest {
         profileRequestCtx.setInboundMessageContext(new MessageContext());
         profileRequestCtx.getInboundMessageContext().setMessage(OpenIDVCICredentialsRequest.parse(httpRequest));
         action = new ParseProof();
+        action.setProofMaximumAge(Duration.ofDays(36500));
         action.initialize();
     }
     
@@ -158,6 +161,25 @@ public class ParseProofTest {
                 profileRequestCtx.getInboundMessageContext().getSubcontext(CredentialsContext.class).getProofs());
     }
 
+    @Test
+    public void testKeyAttestationRequiredWithoutValidator() throws Exception {
+        profileRequestCtx.getInboundMessageContext().ensureSubcontext(CredentialsContext.class)
+                .setCredentialConfiguration(new ObjectMapper()
+                        .readValue("{\"format\":\"dc+sd-jwt\",\"vct\":\"TestCredential\","
+                                + "\"proof_types_supported\":{\"jwt\":{\"proof_signing_alg_values_supported\":"
+                                + "[\"ES256\"],\"key_attestations_required\":{}}}}",
+                                CredentialConfiguration.class));
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), IdPEventIds.INVALID_PROFILE_CONFIG);
+    }
+
+    @Test
+    public void testProofTooOld() throws Exception {
+        final ParseProof aged = new ParseProof();
+        aged.setProofMaximumAge(Duration.ofSeconds(1));
+        aged.initialize();
+        ActionTestingSupport.assertEvent(aged.execute(requestCtx), OpenIDVCIEventIds.INVALID_PROOF);
+    }
+
     @Test
     public void testEmptyProofs() throws ParseException {
         httpRequest.setQuery("{\n" + "  \"credential_configuration_id\": \"org.iso.18013.5.1.mDL\",\n"
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
new file mode 100644
index 0000000..4d70617
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/KeyAttestationValidatorTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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.security.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.List;
+import java.util.Map;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.opensaml.security.crypto.KeySupport;
+import org.opensaml.security.x509.BasicX509Credential;
+import org.opensaml.security.x509.X509Support;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.JOSEObjectType;
+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.JWK;
+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.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.shared.collection.CollectionSupport;
+
+/** Unit tests for {@link KeyAttestationValidator}. */
+public class KeyAttestationValidatorTest {
+
+    private KeyAttestationValidator validator;
+
+    private X509Certificate signerCertificate;
+
+    private PrivateKey signerKey;
+
+    private ECKey attestedKey;
+
+    private ProfileRequestContext prc;
+
+    private X509Certificate read(final String name) throws Exception {
+        try (InputStream in = getClass().getResourceAsStream("/credentials/" + name)) {
+            return X509Support.decodeCertificate(in.readAllBytes());
+        }
+    }
+
+    private Credential anchor(final X509Certificate certificate) {
+        return new BasicX509Credential(certificate);
+    }
+
+    @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);
+        }
+        attestedKey = new ECKeyGenerator(Curve.P_256).generate();
+        prc = new ProfileRequestContext();
+        validator = validatorTrusting("attestation-ca.crt");
+    }
+
+    private KeyAttestationValidator validatorTrusting(final String anchorName) throws Exception {
+        final KeyAttestationValidator trusting = new KeyAttestationValidator();
+        trusting.setTrustAnchors(CollectionSupport.listOf(anchor(read(anchorName))));
+        trusting.initialize();
+        return trusting;
+    }
+
+    private SignedJWT attestation(final JOSEObjectType type, final Instant issued, final Instant expires,
+            final Object attestedKeys, final Map<String, Object> extra) throws Exception {
+        final JWSHeader.Builder header = new JWSHeader.Builder(JWSAlgorithm.ES256).type(type)
+                .x509CertChain(Arrays.asList(Base64.encode(signerCertificate.getEncoded())));
+        final JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder();
+        if (issued != null) {
+            claims.issueTime(Date.from(issued));
+        }
+        if (expires != null) {
+            claims.expirationTime(Date.from(expires));
+        }
+        if (attestedKeys != null) {
+            claims.claim(KeyAttestationValidator.ATTESTED_KEYS_CLAIM, attestedKeys);
+        }
+        if (extra != null) {
+            extra.forEach(claims::claim);
+        }
+        final SignedJWT jwt = new SignedJWT(header.build(), claims.build());
+        jwt.sign(new ECDSASigner(signerKey, Curve.P_256));
+        return jwt;
+    }
+
+    private SignedJWT validAttestation() throws Exception {
+        return attestation(KeyAttestationValidator.TYPE, Instant.now(), Instant.now().plusSeconds(300),
+                List.of(attestedKey.toPublicJWK().toJSONObject()), null);
+    }
+
+    private SignedJWT proofOf(final JWK key) throws Exception {
+        final SignedJWT proof = new SignedJWT(
+                new JWSHeader.Builder(JWSAlgorithm.ES256).type(new JOSEObjectType("openid4vci-proof+jwt"))
+                        .jwk(key.toPublicJWK()).build(),
+                new JWTClaimsSet.Builder().audience("https://issuer.example.org").build());
+        proof.sign(new ECDSASigner(((ECKey) key).toECPrivateKey()));
+        return proof;
+    }
+
+    @Test
+    public void testValid() throws Exception {
+        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null);
+        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);
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testNoAnchors() throws Exception {
+        final KeyAttestationValidator trusting = new KeyAttestationValidator();
+        trusting.initialize();
+        trusting.validate(prc, validAttestation(), null, null);
+    }
+
+    @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);
+    }
+
+    @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);
+    }
+
+    @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);
+    }
+
+    @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);
+    }
+
+    @Test
+    public void testAttestsProofOfAttestedKey() throws Exception {
+        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null);
+        Assert.assertTrue(KeyAttestationValidator.attests(attested, proofOf(attestedKey)));
+    }
+
+    @Test
+    public void testDoesNotAttestProofOfOtherKey() throws Exception {
+        final List<JWK> attested = validator.validate(prc, validAttestation(), null, null);
+        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