[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