Scaffold verification of SD-JWT VP token (#29859) (#33752)

Closes #29859

Signed-off-by: Ingrid Kamga <Ingrid.Kamga@adorsys.com>
This commit is contained in:
Ingrid Kamga 2024-10-25 13:49:25 +01:00 committed by GitHub
parent f994cc54d5
commit c4d6979907
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1788 additions and 145 deletions

View file

@ -0,0 +1,43 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.crypto;
public enum ECCurve {
P256,
P384,
P521;
/**
* Convert standard EC curve names (and aliases) into this enum.
*/
public static ECCurve fromStdCrv(String crv) {
switch (crv) {
case "P-256":
case "secp256r1":
return P256;
case "P-384":
case "secp384r1":
return P384;
case "P-521":
case "secp521r1":
return P521;
default:
throw new IllegalArgumentException("Unexpected EC curve: " + crv);
}
}
}

View file

@ -17,34 +17,25 @@
package org.keycloak.sdjwt;
import org.keycloak.crypto.SignatureVerifierContext;
/**
* Options for Issuer-signed JWT verification.
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class IssuerSignedJwtVerificationOpts {
private final SignatureVerifierContext verifier;
private final boolean validateIssuedAtClaim;
private final boolean validateExpirationClaim;
private final boolean validateNotBeforeClaim;
public IssuerSignedJwtVerificationOpts(
SignatureVerifierContext verifier,
boolean validateIssuedAtClaim,
boolean validateExpirationClaim,
boolean validateNotBeforeClaim) {
this.verifier = verifier;
this.validateIssuedAtClaim = validateIssuedAtClaim;
this.validateExpirationClaim = validateExpirationClaim;
this.validateNotBeforeClaim = validateNotBeforeClaim;
}
public SignatureVerifierContext getVerifier() {
return verifier;
}
public boolean mustValidateIssuedAtClaim() {
return validateIssuedAtClaim;
}
@ -62,16 +53,10 @@ public class IssuerSignedJwtVerificationOpts {
}
public static class Builder {
private SignatureVerifierContext verifier;
private boolean validateIssuedAtClaim;
private boolean validateExpirationClaim = true;
private boolean validateNotBeforeClaim = true;
public Builder withVerifier(SignatureVerifierContext verifier) {
this.verifier = verifier;
return this;
}
public Builder withValidateIssuedAtClaim(boolean validateIssuedAtClaim) {
this.validateIssuedAtClaim = validateIssuedAtClaim;
return this;
@ -89,7 +74,6 @@ public class IssuerSignedJwtVerificationOpts {
public IssuerSignedJwtVerificationOpts build() {
return new IssuerSignedJwtVerificationOpts(
verifier,
validateIssuedAtClaim,
validateExpirationClaim,
validateNotBeforeClaim

View file

@ -0,0 +1,98 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt;
import com.fasterxml.jackson.databind.JsonNode;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.AsymmetricSignatureVerifierContext;
import org.keycloak.crypto.ECCurve;
import org.keycloak.crypto.ECDSASignatureVerifierContext;
import org.keycloak.crypto.KeyType;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.util.JWKSUtils;
import java.util.Objects;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class JwkParsingUtils {
public static SignatureVerifierContext convertJwkNodeToVerifierContext(JsonNode jwkNode) {
JWK jwk;
try {
jwk = SdJwtUtils.mapper.convertValue(jwkNode, JWK.class);
} catch (Exception e) {
throw new IllegalArgumentException("Malformed JWK");
}
return convertJwkToVerifierContext(jwk);
}
public static SignatureVerifierContext convertJwkToVerifierContext(JWK jwk) {
// Wrap JWK
KeyWrapper keyWrapper;
try {
keyWrapper = JWKSUtils.getKeyWrapper(jwk);
Objects.requireNonNull(keyWrapper);
} catch (Exception e) {
throw new IllegalArgumentException("Unsupported or invalid JWK");
}
// Build verifier
// KeyType.EC
if (keyWrapper.getType().equals(KeyType.EC)) {
if (keyWrapper.getAlgorithm() == null) {
Objects.requireNonNull(keyWrapper.getCurve());
String alg = null;
switch (ECCurve.fromStdCrv(keyWrapper.getCurve())) {
case P256:
alg = Algorithm.ES256;
break;
case P384:
alg = Algorithm.ES384;
break;
case P521:
alg = Algorithm.ES512;
break;
}
keyWrapper.setAlgorithm(alg);
}
return new ECDSASignatureVerifierContext(keyWrapper);
}
// KeyType.RSA
if (keyWrapper.getType().equals(KeyType.RSA)) {
return new AsymmetricSignatureVerifierContext(keyWrapper);
}
// KeyType is not supported
// This is unreachable as of now given that `JWKSUtils.getKeyWrapper` will fail
// on JWKs with key type not equal to EC or RSA.
throw new IllegalArgumentException("Unexpected key type: " + keyWrapper.getType());
}
}

View file

@ -38,6 +38,9 @@ import com.fasterxml.jackson.databind.JsonNode;
*
*/
public abstract class SdJws {
public static final String CLAIM_NAME_ISSUER = "iss";
private final JWSInput jwsInput;
private final JsonNode payload;
@ -158,7 +161,7 @@ public abstract class SdJws {
* @param issuers List of trusted issuers
*/
public void verifyIssClaim(List<String> issuers) throws VerificationException {
verifyClaimAgainstTrustedValues(issuers, "iss");
verifyClaimAgainstTrustedValues(issuers, CLAIM_NAME_ISSUER);
}
/**

View file

@ -26,6 +26,7 @@ import java.util.stream.IntStream;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.sdjwt.vp.KeyBindingJWT;
import com.fasterxml.jackson.databind.JsonNode;
@ -44,6 +45,7 @@ public class SdJwt {
private final IssuerSignedJWT issuerSignedJWT;
private final List<SdJwtClaim> claims;
private final List<String> disclosures = new ArrayList<>();
private final SdJwtVerificationContext sdJwtVerificationContext;
private SdJwt(DisclosureSpec disclosureSpec, JsonNode claimSet, List<SdJwt> nesteSdJwts,
Optional<KeyBindingJWT> keyBindingJWT,
@ -65,6 +67,12 @@ public class SdJwt {
nesteSdJwts.stream().forEach(nestedJwt -> this.disclosures.addAll(nestedJwt.getDisclosures()));
this.disclosures.addAll(getDisclosureStrings(claims));
// Instantiate context for verification
this.sdJwtVerificationContext = new SdJwtVerificationContext(
this.issuerSignedJWT,
this.disclosures
);
}
private Optional<String> sdJwtString = Optional.empty();
@ -198,14 +206,21 @@ public class SdJwt {
/**
* Verifies SD-JWT as to whether the Issuer-signed JWT's signature and disclosures are valid.
*
* @param verificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
* must be specified for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that associated public keys
* belong to the intended issuer.
* @param issuerVerifyingKeys Verifying keys for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that the keys belong
* to the intended issuer.
* @param verificationOpts Options to parameterize the Issuer-Signed JWT verification.
* @throws VerificationException if verification failed
*/
public void verify(IssuerSignedJwtVerificationOpts verificationOpts) throws VerificationException {
new SdJwtVerificationContext(issuerSignedJWT, disclosures).verifyIssuance(verificationOpts);
public void verify(
List<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts verificationOpts
) throws VerificationException {
sdJwtVerificationContext.verifyIssuance(
issuerVerifyingKeys,
verificationOpts,
null
);
}
// builder for SdJwt

View file

@ -20,22 +20,19 @@ package org.keycloak.sdjwt;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.jboss.logging.Logger;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.AsymmetricSignatureVerifierContext;
import org.keycloak.crypto.ECDSASignatureVerifierContext;
import org.keycloak.crypto.KeyType;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.sdjwt.consumer.PresentationRequirements;
import org.keycloak.sdjwt.vp.KeyBindingJWT;
import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts;
import org.keycloak.util.JWKSUtils;
import java.time.Instant;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -48,6 +45,9 @@ import java.util.stream.Collectors;
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class SdJwtVerificationContext {
private static final Logger logger = Logger.getLogger(SdJwtVerificationContext.class.getName());
private String sdJwtVpString;
private final IssuerSignedJWT issuerSignedJwt;
@ -79,7 +79,7 @@ public class SdJwtVerificationContext {
.map(disclosureString -> {
String digest = SdJwtUtils.hashAndBase64EncodeNoPad(
disclosureString.getBytes(), issuerSignedJwt.getSdHashAlg());
return new AbstractMap.SimpleEntry<String,String>(digest, disclosureString);
return new AbstractMap.SimpleEntry<>(digest, disclosureString);
})
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
@ -92,17 +92,21 @@ public class SdJwtVerificationContext {
* - all Disclosures are valid and correspond to a respective digest value in the Issuer-signed JWT
* (directly in the payload or recursively included in the contents of other Disclosures).
*
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
* must be specified for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that associated public keys
* belong to the intended issuer.
* @param issuerVerifyingKeys Verifying keys for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that the keys belong
* to the intended issuer.
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification.
* @param presentationRequirements If set, the presentation requirements will be enforced upon fully
* disclosing the Issuer-signed JWT during the verification.
* @throws VerificationException if verification failed
*/
public void verifyIssuance(
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts
List<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
PresentationRequirements presentationRequirements
) throws VerificationException {
// Validate the Issuer-signed JWT.
validateIssuerSignedJwt(issuerSignedJwtVerificationOpts.getVerifier());
validateIssuerSignedJwt(issuerVerifyingKeys);
// Validate disclosures.
JsonNode disclosedPayload = validateDisclosuresDigests();
@ -112,6 +116,11 @@ public class SdJwtVerificationContext {
// SD-JWT payload, but there is no guarantee they would do so. Therefore, Verifiers cannot reliably
// depend on that and need to operate as though security-critical claims might be selectively disclosable.
validateIssuerSignedJwtTimeClaims(disclosedPayload, issuerSignedJwtVerificationOpts);
// Enforce presentation requirements.
if (presentationRequirements != null) {
presentationRequirements.checkIfSatisfiedBy(disclosedPayload);
}
}
/**
@ -122,18 +131,22 @@ public class SdJwtVerificationContext {
* to ensure that if Key Binding is required, the Key Binding JWT is signed by the Holder and valid.
* </p>
*
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
* must be specified for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that associated public keys
* belong to the intended issuer.
* @param issuerVerifyingKeys Verifying keys for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that the keys belong
* to the intended issuer.
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification.
* @param keyBindingJwtVerificationOpts Options to parameterize the Key Binding JWT verification.
* Must, among others, specify the Verifier's policy whether
* to check Key Binding.
* @param presentationRequirements If set, the presentation requirements will be enforced upon fully
* disclosing the Issuer-signed JWT during the verification.
* @throws VerificationException if verification failed
*/
public void verifyPresentation(
List<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts,
PresentationRequirements presentationRequirements
) throws VerificationException {
// If Key Binding is required and a Key Binding JWT is not provided,
// the Verifier MUST reject the Presentation.
@ -142,7 +155,7 @@ public class SdJwtVerificationContext {
}
// Upon receiving a Presentation, in addition to the checks in {@link #verifyIssuance}...
verifyIssuance(issuerSignedJwtVerificationOpts);
verifyIssuance(issuerVerifyingKeys, issuerSignedJwtVerificationOpts, presentationRequirements);
// Validate Key Binding JWT if required
if (keyBindingJwtVerificationOpts.isKeyBindingRequired()) {
@ -158,18 +171,32 @@ public class SdJwtVerificationContext {
* - the Issuer-signed JWT is valid, i.e., it is signed by the Issuer and the signature is valid
* </p>
*
* @param verifiers Verifying keys for validating the Issuer-signed JWT.
* @throws VerificationException if verification failed
*/
private void validateIssuerSignedJwt(SignatureVerifierContext verifier) throws VerificationException {
private void validateIssuerSignedJwt(
List<SignatureVerifierContext> verifiers
) throws VerificationException {
// Check that the _sd_alg claim value is understood and the hash algorithm is deemed secure
issuerSignedJwt.verifySdHashAlgorithm();
// Validate the signature over the Issuer-signed JWT
try {
issuerSignedJwt.verifySignature(verifier);
} catch (VerificationException e) {
throw new VerificationException("Invalid Issuer-Signed JWT", e);
Iterator<SignatureVerifierContext> iterator = verifiers.iterator();
while (iterator.hasNext()) {
try {
SignatureVerifierContext verifier = iterator.next();
issuerSignedJwt.verifySignature(verifier);
return;
} catch (VerificationException e) {
logger.debugf(e, "Issuer-signed JWT's signature verification failed against one potential verifying key");
if (iterator.hasNext()) {
logger.debugf("Retrying Issuer-signed JWT's signature verification with next potential verifying key");
}
}
}
// No potential verifier could verify the JWT's signature
throw new VerificationException("Invalid Issuer-Signed JWT: Signature could not be verified");
}
/**
@ -241,51 +268,12 @@ public class SdJwtVerificationContext {
throw new UnsupportedOperationException("Only cnf/jwk claim supported");
}
// Parse JWK
KeyWrapper keyWrapper;
// Convert JWK
try {
JWK jwk = SdJwtUtils.mapper.convertValue(cnfJwk, JWK.class);
keyWrapper = JWKSUtils.getKeyWrapper(jwk);
Objects.requireNonNull(keyWrapper);
return JwkParsingUtils.convertJwkNodeToVerifierContext(cnfJwk);
} catch (Exception e) {
throw new VerificationException("Malformed or unsupported cnf/jwk claim");
throw new VerificationException("Could not process cnf/jwk", e);
}
// Build verifier
// KeyType.EC
if (keyWrapper.getType().equals(KeyType.EC)) {
if (keyWrapper.getAlgorithm() == null) {
Objects.requireNonNull(keyWrapper.getCurve());
String alg = null;
switch (keyWrapper.getCurve()) {
case "P-256":
alg = "ES256";
break;
case "P-384":
alg = "ES384";
break;
case "P-521":
alg = "ES512";
break;
}
keyWrapper.setAlgorithm(alg);
}
return new ECDSASignatureVerifierContext(keyWrapper);
}
// KeyType.RSA
if (keyWrapper.getType().equals(KeyType.RSA)) {
return new AsymmetricSignatureVerifierContext(keyWrapper);
}
// KeyType is not supported
// This is unreachable as of now given that `JWKSUtils.getKeyWrapper` will fail
// on JWKs with key type not equal to EC or RSA.
throw new VerificationException("cnf/jwk alg is unsupported or deemed not secure");
}
/**
@ -586,10 +574,10 @@ public class SdJwtVerificationContext {
// If the claim name is _sd or ..., the SD-JWT MUST be rejected.
List<String> denylist = Arrays.asList(new String[]{
List<String> denylist = Arrays.asList(
IssuerSignedJWT.CLAIM_NAME_SELECTIVE_DISCLOSURE,
UndisclosedArrayElement.SD_CLAIM_NAME
});
);
String claimName = arrayNode.get(1).asText();
if (denylist.contains(claimName)) {

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public interface HttpDataFetcher {
/**
* Performs an HTTP GET at the URI and parses the response as JSON
* @throws IOException if I/O error or HTTP status not OK (200)
*/
JsonNode fetchJsonData(String uri) throws IOException;
}

View file

@ -0,0 +1,65 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.jose.jwk.JSONWebKeySet;
/**
* POJO for JWT VC Metadata
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc-05#name-jwt-vc-issuer-metadata-resp">
* JWT VC Issuer Metadata Response
* </a>
*/
public class JwtVcMetadata {
@JsonProperty("issuer")
private String issuer;
@JsonProperty("jwks_uri")
private String jwksUri;
@JsonProperty("jwks")
private JSONWebKeySet jwks;
public String getIssuer() {
return issuer;
}
public void setIssuer(String issuer) {
this.issuer = issuer;
}
public String getJwksUri() {
return jwksUri;
}
public void setJwksUri(String jwksUri) {
this.jwksUri = jwksUri;
}
public JSONWebKeySet getJwks() {
return jwks;
}
public void setJwks(JSONWebKeySet jwks) {
this.jwks = jwks;
}
}

View file

@ -0,0 +1,228 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.JwkParsingUtils;
import org.keycloak.sdjwt.SdJws;
import org.keycloak.sdjwt.SdJwtUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* A trusted Issuer for running SD-JWT VP verification.
*
* <p>
* This implementation targets issuers exposing verifying keys on a normalized JWT VC Issuer metadata endpoint.
* </p>
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc-05#name-issuer-signed-jwt-verificat">
* JWT VC Issuer Metadata
* </a>
*/
public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
private static final String JWT_VC_ISSUER_END_POINT = "/.well-known/jwt-vc-issuer";
private final Pattern issuerUriPattern;
private final HttpDataFetcher httpDataFetcher;
/**
* @param issuerUri a trusted issuer URI
*/
public JwtVcMetadataTrustedSdJwtIssuer(String issuerUri, HttpDataFetcher httpDataFetcher) {
try {
validateHttpsIssuerUri(issuerUri);
} catch (VerificationException e) {
throw new IllegalArgumentException(e);
}
// Build a Regex pattern to only match the argument URI
this.issuerUriPattern = Pattern.compile(Pattern.quote(issuerUri));
// Assign HttpDataFetcher implementation
this.httpDataFetcher = httpDataFetcher;
}
/**
* @param issuerUriPattern a regex pattern for trusted issuer URIs
*/
public JwtVcMetadataTrustedSdJwtIssuer(Pattern issuerUriPattern, HttpDataFetcher httpDataFetcher) {
this.issuerUriPattern = issuerUriPattern;
this.httpDataFetcher = httpDataFetcher;
}
@Override
public List<SignatureVerifierContext> resolveIssuerVerifyingKeys(IssuerSignedJWT issuerSignedJWT)
throws VerificationException {
// Read iss (claim) and kid (header)
String iss = Optional.ofNullable(issuerSignedJWT.getPayload().get(SdJws.CLAIM_NAME_ISSUER))
.map(JsonNode::asText)
.orElse("");
String kid = issuerSignedJWT.getHeader().getKeyId();
// Match the read iss claim against the trusted pattern
Matcher matcher = issuerUriPattern.matcher(iss);
if (!matcher.matches()) {
throw new VerificationException(String.format(
"Unexpected Issuer URI claim. Expected=/%s/, Got=%s",
issuerUriPattern.pattern(), iss
));
}
// As per specs, only HTTPS URIs are supported
validateHttpsIssuerUri(iss);
// Fetch exposed JWKs
List<JWK> jwks = fetchIssuerMetadataJwks(iss);
if (jwks.isEmpty()) {
throw new VerificationException(
String.format("Issuer JWKs were unexpectedly resolved to an empty list. Issuer URI: %s", iss)
);
}
// If kid specified, only consider the (single) matching key
if (kid != null) {
List<JWK> matchingJwks = jwks.stream()
.filter(jwk -> {
String jwkKid = jwk.getKeyId();
return jwkKid != null && jwkKid.equals(kid);
})
.collect(Collectors.toList());
if (matchingJwks.isEmpty()) {
throw new VerificationException(
String.format("No published JWK was found to match kid: %s", kid)
);
}
if (matchingJwks.size() > 1) {
throw new VerificationException(
String.format("Cannot choose between multiple exposed JWKs with same kid: %s", kid)
);
}
jwks = Collections.singletonList(matchingJwks.get(0));
}
// Build SignatureVerifierContext's
List<SignatureVerifierContext> verifiers = new ArrayList<>();
for (JWK jwk : jwks) {
try {
verifiers.add(JwkParsingUtils.convertJwkToVerifierContext(jwk));
} catch (Exception e) {
throw new VerificationException("A potential JWK was retrieved but found invalid", e);
}
}
return verifiers;
}
private void validateHttpsIssuerUri(String issuerUri) throws VerificationException {
if (!issuerUri.startsWith("https://")) {
throw new VerificationException(
"HTTPS URI required to retrieve JWT VC Issuer Metadata"
);
}
}
private List<JWK> fetchIssuerMetadataJwks(String issuerUri) throws VerificationException {
// Build full URL to JWT VC metadata endpoint
issuerUri = normalizeUri(issuerUri);
String jwtVcIssuerUri = issuerUri
.concat(JWT_VC_ISSUER_END_POINT); // Append well-known path
// Fetch and parse metadata
JwtVcMetadata issuerMetadata;
JsonNode issuerMetadataNode = fetchData(jwtVcIssuerUri);
try {
issuerMetadata = SdJwtUtils.mapper.treeToValue(issuerMetadataNode, JwtVcMetadata.class);
} catch (JsonProcessingException e) {
throw new VerificationException("Failed to parse exposed JWT VC Metadata", e);
}
// Validate metadata
String exposedIssuerUri = normalizeUri(issuerMetadata.getIssuer());
if (!issuerUri.equals(exposedIssuerUri)) {
throw new VerificationException(String.format(
"Unexpected metadata's issuer. Expected=%s, Got=%s",
issuerUri, exposedIssuerUri
));
}
// Extract exposed JWKS (including dereferencing if necessary)
String jwksUri = issuerMetadata.getJwksUri();
JSONWebKeySet jwks = issuerMetadata.getJwks();
if (jwks == null && jwksUri != null) {
// Dereference JWKS URI
JsonNode jwksNode = fetchData(jwksUri);
// Parse fetched JWKS
try {
jwks = SdJwtUtils.mapper.treeToValue(jwksNode, JSONWebKeySet.class);
} catch (JsonProcessingException e) {
throw new VerificationException("Failed to parse exposed JWKS", e);
}
}
if (jwks == null || jwks.getKeys() == null) {
throw new VerificationException(
String.format("Could not resolve issuer JWKs with URI: %s", issuerUri));
}
return Arrays.asList(jwks.getKeys());
}
private JsonNode fetchData(String uri) throws VerificationException {
try {
return Objects.requireNonNull(httpDataFetcher.fetchJsonData(uri));
} catch (Exception exception) {
throw new VerificationException(
String.format("Could not fetch data from URI: %s", uri),
exception
);
}
}
private String normalizeUri(String uri) {
// Remove any trailing slash
return uri.replaceAll("/$", "");
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import com.fasterxml.jackson.databind.JsonNode;
import org.keycloak.common.VerificationException;
/**
* Presentation requirements to constrain the kind of credential expected.
*
* <p>
* This mirrors the idea of the expressive
* <a href="https://identity.foundation/presentation-exchange/#presentation-definition">DIF Presentation Definition</a>,
* while enabling simpler alternatives.
* </p>
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public interface PresentationRequirements {
/**
* Ensures that the configured requirements are satisfied by the presentation.
*
* @param disclosedPayload The fully disclosed Issuer-signed JWT of the presented token.
* @throws VerificationException if the configured requirements are not satisfied.
*/
void checkIfSatisfiedBy(JsonNode disclosedPayload) throws VerificationException;
}

View file

@ -0,0 +1,78 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts;
import org.keycloak.sdjwt.vp.SdJwtVP;
import java.util.ArrayList;
import java.util.List;
/**
* A component for consuming (verifying) SD-JWT presentations.
*
* <p>
* The purpose is to streamline SD-JWT VP verification beyond signature
* and disclosure checks of {@link org.keycloak.sdjwt.SdJwtVerificationContext}
* </p>
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class SdJwtPresentationConsumer {
/**
* Verify SD-JWT presentation against specific requirements.
*
* @param sdJwtVP the presentation to verify
* @param presentationRequirements the requirements on presented claims
* @param trustedSdJwtIssuers trusted issuers for the verification
* @param issuerSignedJwtVerificationOpts policy for Issuer-signed JWT verification
* @param keyBindingJwtVerificationOpts policy for Key-binding JWT verification
* @throws VerificationException if the verification fails for some reason
*/
public void verifySdJwtPresentation(
SdJwtVP sdJwtVP,
PresentationRequirements presentationRequirements,
List<TrustedSdJwtIssuer> trustedSdJwtIssuers,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
) throws VerificationException {
// Retrieve verifying keys for Issuer-signed JWT
IssuerSignedJWT issuerSignedJWT = sdJwtVP.getIssuerSignedJWT();
List<SignatureVerifierContext> issuerVerifyingKeys = new ArrayList<>();
for (TrustedSdJwtIssuer trustedSdJwtIssuer : trustedSdJwtIssuers) {
List<SignatureVerifierContext> keys = trustedSdJwtIssuer
.resolveIssuerVerifyingKeys(issuerSignedJWT);
issuerVerifyingKeys.addAll(keys);
}
// Verify the SD-JWT token cryptographically
// Pass presentation requirements to enforce that the presented token meets them
sdJwtVP.getSdJwtVerificationContext()
.verifyPresentation(
issuerVerifyingKeys,
issuerSignedJwtVerificationOpts,
keyBindingJwtVerificationOpts,
presentationRequirements
);
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import com.fasterxml.jackson.databind.JsonNode;
import org.keycloak.common.VerificationException;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* A simple presentation definition of the kind of credential expected.
*
* <p>
* The credential's type and required claims are configured using regex patterns.
* The values of these fields are JSON-ified prior to matching the regex pattern.
* </p>
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class SimplePresentationDefinition implements PresentationRequirements {
private final Map<String, Pattern> requirements;
public SimplePresentationDefinition(Map<String, Pattern> requirements) {
this.requirements = requirements;
}
/**
* Checks if the provided JSON payload satisfies all required field patterns.
*
* <p>
* For each required field, the corresponding JSON field value in the disclosed Issuer-signed JWT's payload
* is matched against the associated regex pattern. If any required field is missing or does not match the
* pattern, a {@link VerificationException} is thrown.
* </p>
*
* @param disclosedPayload The fully disclosed Issuer-signed JWT of the presented token.
* @throws VerificationException If any required field is missing or fails the pattern check.
*/
@Override
public void checkIfSatisfiedBy(JsonNode disclosedPayload) throws VerificationException {
for (Map.Entry<String, Pattern> requirement : requirements.entrySet()) {
String field = requirement.getKey();
Pattern pattern = requirement.getValue();
// Retrieve the value of the required field from the payload
JsonNode presented = disclosedPayload.get(field);
// Check if the required field is present in the payload
if (presented == null || presented.isNull()) {
throw new VerificationException(
String.format("A required field was not presented: `%s`", field)
);
}
// Extract the JSON representation of the field's value
String json = presented.toString();
// Match the field value against the configured regex pattern
Matcher matcher = pattern.matcher(json);
if (!matcher.matches()) {
throw new VerificationException(String.format(
"Pattern matching failed for required field: `%s`. Expected pattern: /%s/, but got: %s",
field, pattern.pattern(), json
));
}
}
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final Map<String, Pattern> requirements = new HashMap<>();
public Builder addClaimRequirement(String field, String regexPattern) {
this.requirements.put(field, Pattern.compile(regexPattern));
return this;
}
public SimplePresentationDefinition build() {
return new SimplePresentationDefinition(requirements);
}
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.sdjwt.IssuerSignedJWT;
import java.util.List;
/**
* A trusted Issuer for running SD-JWT VP verification.
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class StaticTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
private final List<SignatureVerifierContext> signatureVerifierContexts;
public StaticTrustedSdJwtIssuer(List<SignatureVerifierContext> signatureVerifierContexts) {
this.signatureVerifierContexts = signatureVerifierContexts;
}
@Override
public List<SignatureVerifierContext> resolveIssuerVerifyingKeys(IssuerSignedJWT issuerSignedJWT) {
return signatureVerifierContexts;
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.sdjwt.IssuerSignedJWT;
import java.util.List;
/**
* A trusted Issuer for running SD-JWT VP verification.
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public interface TrustedSdJwtIssuer {
/**
* Resolves potential verifying keys to validate the Issuer-signed JWT.
* The method ensures that the resolved public keys can be trusted.
*
* @param issuerSignedJWT The Issuer-signed JWT to validate.
* @return trusted verifying keys
* @throws VerificationException if no trustworthy verifying key could be resolved
*/
List<SignatureVerifierContext> resolveIssuerVerifyingKeys(IssuerSignedJWT issuerSignedJWT)
throws VerificationException;
}

View file

@ -30,6 +30,7 @@ import java.util.Set;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Base64Url;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
import org.keycloak.sdjwt.SdJwt;
@ -54,6 +55,7 @@ public class SdJwtVP {
private final String hashAlgorithm;
private final Optional<KeyBindingJWT> keyBindingJWT;
private final SdJwtVerificationContext sdJwtVerificationContext;
public Map<String, ArrayNode> getClaims() {
return claims;
@ -98,6 +100,14 @@ public class SdJwtVP {
this.recursiveDigests = Collections.unmodifiableMap(recursiveDigests);
this.ghostDigests = Collections.unmodifiableList(ghostDigests);
this.keyBindingJWT = keyBindingJWT;
// Instantiate context for verification
this.sdJwtVerificationContext = new SdJwtVerificationContext(
this.sdJwtVpString,
this.issuerSignedJWT,
this.disclosures,
this.keyBindingJWT.orElse(null)
);
}
public static SdJwtVP of(String sdJwtString) {
@ -217,21 +227,33 @@ public class SdJwtVP {
/**
* Verifies SD-JWT presentation.
*
* @param issuerSignedJwtVerificationOpts Options to parameterize the verification. A verifier must be specified
* for validating the Issuer-signed JWT. The caller is responsible for
* establishing trust in that associated public keys belong to the
* intended issuer.
* @param issuerVerifyingKeys Verifying keys for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that the keys belong
* to the intended issuer.
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification.
* @param keyBindingJwtVerificationOpts Options to parameterize the Key Binding JWT verification.
* Must, among others, specify the Verifier's policy whether
* to check Key Binding.
* @throws VerificationException if verification failed
*/
public void verify(
List<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
) throws VerificationException {
new SdJwtVerificationContext(sdJwtVpString, issuerSignedJWT, disclosures, keyBindingJWT.orElse(null))
.verifyPresentation(issuerSignedJwtVerificationOpts, keyBindingJwtVerificationOpts);
sdJwtVerificationContext.verifyPresentation(
issuerVerifyingKeys,
issuerSignedJwtVerificationOpts,
keyBindingJwtVerificationOpts,
null
);
}
/**
* Retrieve verification context for advanced scenarios.
*/
public SdJwtVerificationContext getSdJwtVerificationContext() {
return sdJwtVerificationContext;
}
// Recursively searches the node with the given value.

View file

@ -23,14 +23,16 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.ClassRule;
import org.junit.Test;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.rule.CryptoInitRule;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.keycloak.crypto.SignatureSignerContext;
import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
@ -57,38 +59,61 @@ public abstract class SdJwtVerificationTest {
@Test
public void testSdJwtVerification_FlatSdJwt() throws VerificationException {
for (String hashAlg : Arrays.asList(new String[]{"sha-256", "sha-384", "sha-512"})) {
for (String hashAlg : Arrays.asList("sha-256", "sha-384", "sha-512")) {
SdJwt sdJwt = exampleFlatSdJwtV1()
.withHashAlgorithm(hashAlg)
.build();
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
);
}
}
@Test
public void testSdJwtVerification_EnforceIdempotence() throws VerificationException {
SdJwt sdJwt = exampleFlatSdJwtV1().build();
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
SdJwt sdJwt = exampleFlatSdJwtV1().build();
sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
);
sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
);
}
@Test
public void testSdJwtVerification_SdJwtWithUndisclosedNestedFields() throws VerificationException {
SdJwt sdJwt = exampleSdJwtWithUndisclosedNestedFieldsV1().build();
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
);
}
@Test
public void testSdJwtVerification_SdJwtWithUndisclosedArrayElements() throws Exception {
SdJwt sdJwt = exampleSdJwtWithUndisclosedArrayElementsV1().build();
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
);
}
@Test
public void testSdJwtVerification_RecursiveSdJwt() throws Exception {
SdJwt sdJwt = exampleRecursiveSdJwtV1().build();
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
);
}
@Test
@ -99,7 +124,10 @@ public abstract class SdJwtVerificationTest {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
)
);
assertEquals("Unexpected or insecure hash algorithm: sha-224", exception.getMessage());
@ -110,13 +138,13 @@ public abstract class SdJwtVerificationTest {
SdJwt sdJwt = exampleFlatSdJwtV1().build();
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
.withVerifier(testSettings.holderVerifierContext) // wrong verifier
.build())
() -> sdJwt.verify(
Collections.singletonList(testSettings.holderVerifierContext), // wrong verifier
defaultIssuerSignedJwtVerificationOpts().build()
)
);
assertThat(exception.getMessage(), is("Invalid Issuer-Signed JWT"));
assertThat(exception.getCause().getMessage(), endsWith("Invalid jws signature"));
assertThat(exception.getMessage(), is("Invalid Issuer-Signed JWT: Signature could not be verified"));
}
@Test
@ -135,12 +163,15 @@ public abstract class SdJwtVerificationTest {
.withUndisclosedClaim("exp", "eluV5Og3gSNII8EYnsxA_A")
.build()).build();
for (SdJwt sdJwt : Arrays.asList(new SdJwt[]{sdJwtV1, sdJwtV2})) {
for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
.withValidateExpirationClaim(true)
.build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts()
.withValidateExpirationClaim(true)
.build()
)
);
assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage());
@ -166,12 +197,15 @@ public abstract class SdJwtVerificationTest {
SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet1, disclosureSpec).build();
SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet2, disclosureSpec).build();
for (SdJwt sdJwt : Arrays.asList(new SdJwt[]{sdJwtV1, sdJwtV2})) {
for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
.withValidateExpirationClaim(true)
.build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts()
.withValidateExpirationClaim(true)
.build()
)
);
assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage());
@ -195,12 +229,15 @@ public abstract class SdJwtVerificationTest {
.withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A")
.build()).build();
for (SdJwt sdJwt : Arrays.asList(new SdJwt[]{sdJwtV1, sdJwtV2})) {
for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
.withValidateIssuedAtClaim(true)
.build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts()
.withValidateIssuedAtClaim(true)
.build()
)
);
assertEquals("Issuer-Signed JWT: Invalid `iat` claim", exception.getMessage());
@ -224,12 +261,15 @@ public abstract class SdJwtVerificationTest {
.withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A")
.build()).build();
for (SdJwt sdJwt : Arrays.asList(new SdJwt[]{sdJwtV1, sdJwtV2})) {
for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
.withValidateNotBeforeClaim(true)
.build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts()
.withValidateNotBeforeClaim(true)
.build()
)
);
assertEquals("Issuer-Signed JWT: Invalid `nbf` claim", exception.getMessage());
@ -247,8 +287,10 @@ public abstract class SdJwtVerificationTest {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
.build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
)
);
assertEquals("Unexpected non-string element inside _sd array: 123", exception.getMessage());
@ -256,7 +298,7 @@ public abstract class SdJwtVerificationTest {
@Test
public void sdJwtVerificationShouldFail_IfForbiddenClaimNames() {
for (String forbiddenClaimName : Arrays.asList(new String[]{"_sd", "..."})) {
for (String forbiddenClaimName : Arrays.asList("_sd", "...")) {
ObjectNode claimSet = mapper.createObjectNode();
claimSet.put(forbiddenClaimName, "Value");
@ -266,7 +308,10 @@ public abstract class SdJwtVerificationTest {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
)
);
assertEquals("Disclosure claim name must not be '_sd' or '...'", exception.getMessage());
@ -286,7 +331,10 @@ public abstract class SdJwtVerificationTest {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
)
);
assertTrue(exception.getMessage().startsWith("A digest was encountered more than once:"));
@ -307,15 +355,21 @@ public abstract class SdJwtVerificationTest {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build())
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build()
)
);
assertEquals("A salt value was reused: " + salt, exception.getMessage());
}
private List<SignatureVerifierContext> defaultIssuerVerifyingKeys() {
return Collections.singletonList(testSettings.issuerVerifierContext);
}
private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() {
return IssuerSignedJwtVerificationOpts.builder()
.withVerifier(testSettings.issuerVerifierContext)
.withValidateIssuedAtClaim(false)
.withValidateExpirationClaim(false)
.withValidateNotBeforeClaim(false);

View file

@ -0,0 +1,419 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.ClassRule;
import org.junit.Test;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.rule.CryptoInitRule;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.SdJws;
import org.keycloak.sdjwt.SdJwtUtils;
import org.keycloak.sdjwt.TestUtils;
import org.keycloak.sdjwt.vp.SdJwtVP;
import java.rmi.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public abstract class JwtVcMetadataTrustedSdJwtIssuerTest {
@ClassRule
public static CryptoInitRule cryptoInitRule = new CryptoInitRule();
@Test
public void shouldResolveIssuerVerifyingKeys() throws Exception {
String issuerUri = "https://issuer.example.com";
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerUri, mockHttpDataFetcher());
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt();
List<SignatureVerifierContext> keys = trustedIssuer
.resolveIssuerVerifyingKeys(issuerSignedJWT);
// There three keys exposed on the metadata endpoint.
assertEquals(3, keys.size());
}
@Test
public void shouldResolveKeys_WhenIssuerTrustedOnRegexPattern() throws Exception {
Pattern issuerUriRegex = Pattern.compile("https://.*\\.example\\.com");
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerUriRegex, mockHttpDataFetcher());
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt();
trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT);
}
@Test
public void shouldResolveKeys_WhenJwtSpecifiesKid() throws VerificationException, JsonProcessingException {
String issuerUri = "https://issuer.example.com";
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerUri, mockHttpDataFetcher());
// This JWT specifies a key ID in its header
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb--explicit-kid.txt");
// Act
List<SignatureVerifierContext> keys = trustedIssuer
.resolveIssuerVerifyingKeys(issuerSignedJWT);
// Despite three keys exposed on the metadata endpoint,
// only the key matching the JWT's kid is resolved.
assertEquals(1, keys.size());
assertEquals("doc-signer-05-25-2022", keys.get(0).getKid());
}
@Test
public void shouldRejectNonHttpsIssuerURIs() {
String issuerUri = "http://issuer.example.com"; // not https
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> new JwtVcMetadataTrustedSdJwtIssuer(issuerUri, mockHttpDataFetcher())
);
assertThat(exception.getMessage(), endsWith("HTTPS URI required to retrieve JWT VC Issuer Metadata"));
}
@Test
public void shouldRejectNonHttpsIssuerURIs_EvenIfIssuerBuiltWithRegexPattern() throws JsonProcessingException {
Pattern issuerUriRegex = Pattern.compile(".*\\.example\\.com");
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerUriRegex, mockHttpDataFetcher());
String issuerUri = "http://issuer.example.com"; // not https
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwtWithIssuer(issuerUri);
VerificationException exception = assertThrows(VerificationException.class,
() -> trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT)
);
assertThat(exception.getMessage(), endsWith("HTTPS URI required to retrieve JWT VC Issuer Metadata"));
}
@Test
public void shouldRejectJwtsWithUnexpectedIssuerClaims() throws JsonProcessingException {
String regex = "https://.*\\.example\\.com";
Pattern issuerUriRegex = Pattern.compile(regex);
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerUriRegex, mockHttpDataFetcher());
String issuerUri = "https://trial.authlete.com"; // does not match the regex above
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwtWithIssuer(issuerUri);
VerificationException exception = assertThrows(VerificationException.class,
() -> trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT)
);
assertThat(exception.getMessage(),
endsWith(String.format("Unexpected Issuer URI claim. Expected=/%s/, Got=%s", regex, issuerUri)));
}
@Test
public void shouldFailOnInvalidJwkExposed() throws JsonProcessingException {
String issuerUri = "https://issuer.example.com";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", issuerUri);
metadata.set("jwks", SdJwtUtils.mapper.readTree(
"{\n" +
" \"keys\": [\n" +
" {\n" +
" \"kty\": \"EC\",\n" +
" \"x\": \"invalid\",\n" +
" \"y\": \"invalid\"\n" +
" }\n" +
" ]\n" +
"}"
));
genericTestShouldFail(
exampleIssuerSignedJwt(),
mockHttpDataFetcherWithMetadata(issuerUri, metadata),
"A potential JWK was retrieved but found invalid",
"Unsupported or invalid JWK"
);
}
@Test
public void shouldFailOnUnexpectedIssuerExposed() throws JsonProcessingException {
String issuerUri = "https://issuer.example.com";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", "https://another-issuer.example.com");
genericTestShouldFail(
exampleIssuerSignedJwt(),
mockHttpDataFetcherWithMetadata(issuerUri, metadata),
"Unexpected metadata's issuer",
null
);
}
@Test
public void shouldFailOnMalformedJwtVcMetadataExposed() throws JsonProcessingException {
String issuerUri = "https://issuer.example.com";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
// This is malformed because the issuer must be a string
metadata.set("issuer", SdJwtUtils.mapper.readTree("{}"));
genericTestShouldFail(
exampleIssuerSignedJwt(),
mockHttpDataFetcherWithMetadata(issuerUri, metadata),
"Failed to parse exposed JWT VC Metadata",
null
);
}
@Test
public void shouldFailOnMalformedJwksExposed() throws JsonProcessingException {
List<JsonNode> malformedJwks = Arrays.asList(
SdJwtUtils.mapper.readTree("[]"),
SdJwtUtils.mapper.readTree("{\"keys\": {}}")
);
for (JsonNode jwks : malformedJwks) {
String issuerUri = "https://issuer.example.com";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", issuerUri);
metadata.put("jwks_uri", "https://issuer.example.com/api/vci/jwks");
genericTestShouldFail(
exampleIssuerSignedJwt(),
mockHttpDataFetcherWithMetadataAndJwks(issuerUri, metadata, jwks),
"Failed to parse exposed JWKS",
null
);
}
}
@Test
public void shouldFailOnMissingJwksOnMetadataEndpoint() throws JsonProcessingException {
String issuerUri = "https://issuer.example.com";
// There are no means to resolve JWKS with these metadata
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", issuerUri);
genericTestShouldFail(
exampleIssuerSignedJwt(),
mockHttpDataFetcherWithMetadata(issuerUri, metadata),
String.format("Could not resolve issuer JWKs with URI: %s", issuerUri),
null
);
}
@Test
public void shouldFailOnEmptyPublishedJwkList() throws Exception {
// This JWKS to publish has an empty list of keys, which is unexpected.
JsonNode jwks = SdJwtUtils.mapper.readTree("{\"keys\": []}");
// JWT VC Metadata
String issuerUri = "https://issuer.example.com";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", issuerUri);
metadata.set("jwks", jwks);
// Act and assert
genericTestShouldFail(
exampleIssuerSignedJwt(),
mockHttpDataFetcherWithMetadataAndJwks(issuerUri, metadata, jwks),
String.format("Issuer JWKs were unexpectedly resolved to an empty list. Issuer URI: %s", issuerUri),
null
);
}
@Test
public void shouldFailOnNoPublishedJwkMatchingJwtKid() throws Exception {
// Alter kid fields of all JWKs to publish, so as none is a match
JsonNode jwks = exampleJwks();
for (JsonNode jwk : jwks.get("keys")) {
((ObjectNode) jwk).put("kid", jwk.get("kid").asText() + "-wont-match");
}
// JWT VC Metadata
String issuerUri = "https://issuer.example.com";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", issuerUri);
metadata.set("jwks", jwks);
// This JWT specifies a key ID in its header
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb--explicit-kid.txt");
String kid = issuerSignedJWT.getHeader().getKeyId();
// Act and assert
genericTestShouldFail(
issuerSignedJWT,
mockHttpDataFetcherWithMetadataAndJwks(issuerUri, metadata, jwks),
String.format("No published JWK was found to match kid: %s", kid),
null
);
}
@Test
public void shouldFailOnPublishedJwksWithDuplicateKid() throws JsonProcessingException {
// This JWT specifies a key ID in its header
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb--explicit-kid.txt");
// Set the same kid to all JWKs to publish, which is problematic
String kid = issuerSignedJWT.getHeader().getKeyId();
JsonNode jwks = exampleJwks();
for (JsonNode jwk : jwks.get("keys")) {
((ObjectNode) jwk).put("kid", kid);
}
// JWT VC Metadata
String issuerUri = "https://issuer.example.com";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", issuerUri);
metadata.set("jwks", jwks);
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerUri, mockHttpDataFetcherWithMetadata(issuerUri, metadata));
// Act and assert
VerificationException exception = assertThrows(VerificationException.class,
() -> trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT)
);
assertThat(exception.getMessage(),
endsWith(String.format("Cannot choose between multiple exposed JWKs with same kid: %s", kid)));
}
@Test
public void shouldFailOnIOErrorWhileFetchingMetadata() throws JsonProcessingException {
String issuerUri = "https://issuer.example.com";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", issuerUri);
HttpDataFetcher mockFetcher = mockHttpDataFetcherWithMetadata(
// HTTP can only mock an unrelated issuer
"https://another-issuer.example.com",
metadata
);
genericTestShouldFail(
exampleIssuerSignedJwt(),
mockFetcher,
String.format("Could not fetch data from URI: %s", issuerUri),
null
);
}
private void genericTestShouldFail(
IssuerSignedJWT issuerSignedJWT,
HttpDataFetcher mockFetcher,
String errorMessage,
String causeErrorMessage
) {
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerSignedJWT.getPayload().get(SdJws.CLAIM_NAME_ISSUER).asText(),
mockFetcher
);
VerificationException exception = assertThrows(VerificationException.class,
() -> trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT)
);
assertTrue(exception.getMessage().contains(errorMessage));
if (causeErrorMessage != null) {
assertTrue(exception.getCause().getMessage().contains(causeErrorMessage));
}
}
private IssuerSignedJWT exampleIssuerSignedJwt() {
return exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb.txt");
}
private IssuerSignedJWT exampleIssuerSignedJwt(String sdJwtVector) {
return exampleIssuerSignedJwt(sdJwtVector, "https://issuer.example.com");
}
private IssuerSignedJWT exampleIssuerSignedJwtWithIssuer(String issuerUri) {
return exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb.txt", issuerUri);
}
private IssuerSignedJWT exampleIssuerSignedJwt(String sdJwtVector, String issuerUri) {
String sdJwtVPString = TestUtils.readFileAsString(getClass(), sdJwtVector);
IssuerSignedJWT issuerSignedJWT = SdJwtVP.of(sdJwtVPString).getIssuerSignedJWT();
((ObjectNode) issuerSignedJWT.getPayload()).put("iss", issuerUri);
return issuerSignedJWT;
}
private JsonNode exampleJwks() throws JsonProcessingException {
return SdJwtUtils.mapper.readTree(
TestUtils.readFileAsString(getClass(),
"sdjwt/s30.1-jwt-vc-metadata-jwks.json"
)
);
}
private HttpDataFetcher mockHttpDataFetcher() throws JsonProcessingException {
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", "https://issuer.example.com");
metadata.put("jwks_uri", "https://issuer.example.com/api/vci/jwks");
return mockHttpDataFetcherWithMetadata(
metadata.get("issuer").asText(),
metadata
);
}
private HttpDataFetcher mockHttpDataFetcherWithMetadata(
String issuer, JsonNode metadata
) throws JsonProcessingException {
return mockHttpDataFetcherWithMetadataAndJwks(issuer, metadata, exampleJwks());
}
private HttpDataFetcher mockHttpDataFetcherWithMetadataAndJwks(
String issuer, JsonNode metadata, JsonNode jwks
) {
return uri -> {
if (!uri.startsWith(issuer)) {
throw new UnknownHostException("Unavailable URI");
}
if (uri.endsWith("/.well-known/jwt-vc-issuer")) {
return metadata;
} else if (uri.endsWith("/api/vci/jwks")) {
return jwks;
} else {
throw new UnknownHostException("Unavailable URI");
}
};
}
}

View file

@ -0,0 +1,119 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import org.junit.ClassRule;
import org.junit.Test;
import org.keycloak.common.VerificationException;
import org.keycloak.rule.CryptoInitRule;
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
import org.keycloak.sdjwt.TestSettings;
import org.keycloak.sdjwt.TestUtils;
import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts;
import org.keycloak.sdjwt.vp.SdJwtVP;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public abstract class SdJwtPresentationConsumerTest {
@ClassRule
public static CryptoInitRule cryptoInitRule = new CryptoInitRule();
SdJwtPresentationConsumer sdJwtPresentationConsumer = new SdJwtPresentationConsumer();
static TestSettings testSettings = TestSettings.getInstance();
@Test
public void shouldVerifySdJwtPresentation() throws VerificationException {
sdJwtPresentationConsumer.verifySdJwtPresentation(
exampleSdJwtVP(),
examplePresentationRequirements(),
exampleTrustedSdJwtIssuers(),
defaultIssuerSignedJwtVerificationOpts(),
defaultKeyBindingJwtVerificationOpts()
);
}
@Test
public void shouldFail_IfPresentationRequirementsNotMet() {
SimplePresentationDefinition definition = SimplePresentationDefinition.builder()
.addClaimRequirement("vct", ".*diploma.*")
.build();
VerificationException exception = assertThrows(VerificationException.class,
() -> sdJwtPresentationConsumer.verifySdJwtPresentation(
exampleSdJwtVP(),
definition,
exampleTrustedSdJwtIssuers(),
defaultIssuerSignedJwtVerificationOpts(),
defaultKeyBindingJwtVerificationOpts()
)
);
assertTrue(exception.getMessage()
.contains("A required field was not presented: `vct`"));
}
private SdJwtVP exampleSdJwtVP() {
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s20.1-sdjwt+kb.txt");
return SdJwtVP.of(sdJwtVPString);
}
private PresentationRequirements examplePresentationRequirements() {
return SimplePresentationDefinition.builder()
.addClaimRequirement("sub", "\"user_[0-9]+\"")
.addClaimRequirement("given_name", ".*")
.build();
}
private List<TrustedSdJwtIssuer> exampleTrustedSdJwtIssuers() {
return Arrays.asList(
new StaticTrustedSdJwtIssuer(
Collections.singletonList(testSettings.holderVerifierContext)
),
new StaticTrustedSdJwtIssuer(
Collections.singletonList(testSettings.issuerVerifierContext)
)
);
}
private IssuerSignedJwtVerificationOpts defaultIssuerSignedJwtVerificationOpts() {
return IssuerSignedJwtVerificationOpts.builder()
.withValidateIssuedAtClaim(false)
.withValidateNotBeforeClaim(false)
.build();
}
private KeyBindingJwtVerificationOpts defaultKeyBindingJwtVerificationOpts() {
return KeyBindingJwtVerificationOpts.builder()
.withKeyBindingRequired(true)
.withAllowedMaxAge(Integer.MAX_VALUE)
.withNonce("1234567890")
.withAud("https://verifier.example.org")
.withValidateExpirationClaim(false)
.withValidateNotBeforeClaim(false)
.build();
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.sdjwt.consumer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.keycloak.common.VerificationException;
import org.keycloak.sdjwt.SdJwtUtils;
import org.keycloak.sdjwt.TestUtils;
import static org.hamcrest.CoreMatchers.startsWith;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class SimplePresentationDefinitionTest {
private final ObjectMapper mapper = SdJwtUtils.mapper;
@Test
public void testCheckIfSatisfiedBy() throws VerificationException, JsonProcessingException {
SimplePresentationDefinition definition = SimplePresentationDefinition.builder()
.addClaimRequirement("vct", ".*identity_credential.*")
.addClaimRequirement("given_name", "\"John\"")
.addClaimRequirement("cat", "123")
.addClaimRequirement("addr", ".*\"(Douala|Berlin)\".*")
.addClaimRequirement("colors", "\\[\"red\",.*")
.build();
definition.checkIfSatisfiedBy(exampleDisclosedPayload());
}
@Test
public void testCheckIfSatisfiedBy_shouldFailOnRequiredFieldMissing() {
SimplePresentationDefinition definition = SimplePresentationDefinition.builder()
.addClaimRequirement("family_name", ".*")
.build();
VerificationException exception = assertThrows(VerificationException.class,
() -> definition.checkIfSatisfiedBy(exampleDisclosedPayload()));
assertEquals("A required field was not presented: `family_name`", exception.getMessage());
}
@Test
public void testCheckIfSatisfiedBy_shouldFailOnNonMatchingPattern() {
SimplePresentationDefinition definition = SimplePresentationDefinition.builder()
.addClaimRequirement("vct", ".*diploma.*")
.build();
VerificationException exception = assertThrows(VerificationException.class,
() -> definition.checkIfSatisfiedBy(exampleDisclosedPayload()));
assertThat(exception.getMessage(), startsWith("Pattern matching failed for required field"));
}
private JsonNode exampleDisclosedPayload() throws JsonProcessingException {
String content = TestUtils.readFileAsString(getClass(),
"sdjwt/s7.4-sample-disclosed-issuer-payload.json");
return mapper.readTree(content);
}
}

View file

@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.ClassRule;
import org.junit.Test;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.rule.CryptoInitRule;
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
import org.keycloak.sdjwt.SdJwt;
@ -34,6 +35,7 @@ import org.keycloak.sdjwt.vp.SdJwtVP;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.hamcrest.CoreMatchers.containsString;
@ -63,6 +65,7 @@ public abstract class SdJwtVPVerificationTest {
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts().build()
);
@ -70,15 +73,17 @@ public abstract class SdJwtVPVerificationTest {
@Test
public void testVerif_s20_8_sdjwt_with_kb__AltCnfCurves() throws VerificationException {
List<String> entries = Arrays.asList(new String[]{
"sdjwt/s20.8-sdjwt+kb--es384.txt", "sdjwt/s20.8-sdjwt+kb--es512.txt"
});
List<String> entries = Arrays.asList(
"sdjwt/s20.8-sdjwt+kb--es384.txt",
"sdjwt/s20.8-sdjwt+kb--es512.txt"
);
for (String entry : entries) {
String sdJwtVPString = TestUtils.readFileAsString(getClass(), entry);
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts().build()
);
@ -87,18 +92,19 @@ public abstract class SdJwtVPVerificationTest {
@Test
public void testVerif_s20_8_sdjwt_with_kb__CnfRSA() throws VerificationException {
List<String> entries = Arrays.asList(new String[]{
List<String> entries = Arrays.asList(
"sdjwt/s20.8-sdjwt+kb--cnf-rsa-rs256.txt",
"sdjwt/s20.8-sdjwt+kb--cnf-rsa-ps256.txt",
"sdjwt/s20.8-sdjwt+kb--cnf-rsa-ps384.txt",
"sdjwt/s20.8-sdjwt+kb--cnf-rsa-ps512.txt"
});
);
for (String entry : entries) {
String sdJwtVPString = TestUtils.readFileAsString(getClass(), entry);
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts().build()
);
@ -111,6 +117,7 @@ public abstract class SdJwtVPVerificationTest {
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts()
.withKeyBindingRequired(false)
@ -327,6 +334,7 @@ public abstract class SdJwtVPVerificationTest {
UnsupportedOperationException exception = assertThrows(
UnsupportedOperationException.class,
() -> sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts().build()
)
@ -341,8 +349,8 @@ public abstract class SdJwtVPVerificationTest {
// The cnf/jwk object has an unrecognized key type
"sdjwt/s20.8-sdjwt+kb--cnf-jwk-is-malformed.txt",
defaultKeyBindingJwtVerificationOpts().build(),
"Malformed or unsupported cnf/jwk claim",
null
"Could not process cnf/jwk",
"Unsupported or invalid JWK"
);
}
@ -352,8 +360,8 @@ public abstract class SdJwtVPVerificationTest {
// HMAC cnf/jwk parsing is not supported
"sdjwt/s20.8-sdjwt+kb--cnf-hmac.txt",
defaultKeyBindingJwtVerificationOpts().build(),
"Malformed or unsupported cnf/jwk claim",
null
"Could not process cnf/jwk",
"Unsupported or invalid JWK"
);
}
@ -369,6 +377,7 @@ public abstract class SdJwtVPVerificationTest {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
keyBindingJwtVerificationOpts
)
@ -405,6 +414,7 @@ public abstract class SdJwtVPVerificationTest {
VerificationException exception = assertThrows(
VerificationException.class,
() -> sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
keyBindingJwtVerificationOpts
)
@ -416,9 +426,12 @@ public abstract class SdJwtVPVerificationTest {
}
}
private List<SignatureVerifierContext> defaultIssuerVerifyingKeys() {
return Collections.singletonList(testSettings.issuerVerifierContext);
}
private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() {
return IssuerSignedJwtVerificationOpts.builder()
.withVerifier(testSettings.issuerVerifierContext)
.withValidateIssuedAtClaim(false)
.withValidateNotBeforeClaim(false);
}

View file

@ -0,0 +1,6 @@
eyJhbGciOiJFUzI1NiIsInR5cCI6ImV4YW1wbGUrc2Qtand0Iiwia2lkIjoiZG9jLXNpZ25lci0wNS0yNS0yMDIyIn0.eyJfc2QiOlsiQ3JRZTdTNWtxQkFIdC1uTVlYZ2M2YmR0MlNINWFUWTFzVV9NLVBna2pQSSIsIkp6WWpINHN2bGlIMFIzUHlFTWZlWnU2SnQ2OXU1cWVoWm83RjdFUFlsU0UiLCJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwiVEdmNG9MYmd3ZDVKUWFIeUtWUVpVOVVkR0UwdzVydERzclp6ZlVhb21MbyIsIlhRXzNrUEt0MVh5WDdLQU5rcVZSNnlaMlZhNU5yUEl2UFlieU12UktCTU0iLCJYekZyendzY002R242Q0pEYzZ2Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwiZ2JPc0k0RWRxMngyS3ctdzV3UEV6YWtvYjloVjFjUkQwQVROM29RTDlKTSIsImpzdTl5VnVsd1FRbGhGbE1fM0psek1hU0Z6Z2xoUUcwRHBmYXlRd0xVSzQiXSwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS5jb20iLCJpYXQiOjE2ODMwMDAwMDAsImV4cCI6MTg4MzAwMDAwMCwic3ViIjoidXNlcl80MiIsIm5hdGlvbmFsaXRpZXMiOlt7Ii4uLiI6InBGbmRqa1pfVkN6bXlUYTZVamxabzNkaC1rbzhhSUtRYzlEbEd6aGFWWW8ifSx7Ii4uLiI6IjdDZjZKa1B1ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sIl9zZF9hbGciOiJzaGEtMjU2IiwiY25mIjp7Imp3ayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlRDQUVSMTladnUzT0hGNGo0VzR2ZlNWb0hJUDFJTGlsRGxzN3ZDZUdlbWMiLCJ5IjoiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlGMkhaUSJ9fX0.N0xUjkyxK6q-uvDF0bLpOSq8XI-QXZ9iI5U4w4GSx9NwDZQfg4P9SffgjQ11LwZKKfLprNernp53-oRBWaOuDA
~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0
~
eyJraWQiOiJob2xkZXIiLCJ0eXAiOiJrYitqd3QiLCJhbGciOiJFUzI1NiJ9.eyJub25jZSI6IjEyMzQ1Njc4OTAiLCJhdWQiOiJodHRwczovL3ZlcmlmaWVyLmV4YW1wbGUub3JnIiwiaWF0IjoxNzAyMzE1Njc5LCJzZF9oYXNoIjoiSEtwV1JKMmtqaXluSjBBbDlNTFJ5dmFjSS1HSVpmMHN5SUVvUnB2VktESSJ9.YFBWGvdAq8UIz7Y3b2lVMaQAFCkS02qkClGOPsn9qE-xDOgqT6VYx2D9-nSAU69dvkTdq6ynPMutlCYNtvtZ6w

View file

@ -0,0 +1,31 @@
{
"keys": [
{
"kid": "doc-signer-05-25-2022",
"kty": "EC",
"d": "Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g",
"crv": "P-256",
"x": "b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ",
"y": "Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8"
},
{
"kty": "EC",
"crv": "P-256",
"kid": "J1FwJP87C6-QN_WSIOmJAQc6n5CQ_bZdaFJ5GDnW1Rk",
"x5c": [
"MIIBXTCCAQSgAwIBAgIGAYyR2cIZMAoGCCqGSM49BAMCMDYxNDAyBgNVBAMMK0oxRndKUDg3QzYtUU5fV1NJT21KQVFjNm41Q1FfYlpkYUZKNUdEblcxUmswHhcNMjMxMjIyMTQwNjU2WhcNMjQxMDE3MTQwNjU2WjA2MTQwMgYDVQQDDCtKMUZ3SlA4N0M2LVFOX1dTSU9tSkFRYzZuNUNRX2JaZGFGSjVHRG5XMVJrMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAopVeboJpYRycw1YKkkROXfCpKEKl9Y1YPFhOGj4xTg2UOunxTxSIVkT94qFVIuu1hkEoE2NxelZo3+yTFUODDAKBggqhkjOPQQDAgNHADBEAiBnFjScBcvERleLjMCu5NbxJKkNsa/gQhkXTfDmbq+T3gIgVazbsVdQvZgluc9nJYQxWlzXT9i6f+wgUKx0KCYbj3A="
],
"x": "AopVeboJpYRycw1YKkkROXfCpKEKl9Y1YPFhOGj4xTg",
"y": "NlDrp8U8UiFZE_eKhVSLrtYZBKBNjcXpWaN_skxVDgw",
"alg": "ES256"
},
{
"kty": "EC",
"crv": "P-256",
"kid": "ZbAAKwhynrqBnYlHdEkBIvNJFZZH_bRg1KIopKfZ6O8",
"x": "eshMYyyoEsH_Eb85a7o76msXFPokfvNaeyY3u5qDm3M",
"y": "q0lGMn_UXiWJdgJtSCNzh9zPC6s7qKqQMo4V1i-69jA",
"alg": "ES256"
}
]
}

View file

@ -0,0 +1,7 @@
{
"vct": "https://credentials.example.com/identity_credential",
"given_name": "John",
"cat": 123,
"addr": {"city": "Douala", "country": "CM"},
"colors": ["red", "green"]
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.crypto.def.test.sdjwt;
import org.junit.Assume;
import org.junit.Before;
import org.keycloak.common.util.Environment;
import org.keycloak.sdjwt.consumer.JwtVcMetadataTrustedSdJwtIssuerTest;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class DefaultCryptoJwtVcMetadataTrustedSdJwtIssuerTest extends JwtVcMetadataTrustedSdJwtIssuerTest {
@Before
public void before() {
// Run this test just if java is not in FIPS mode
Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode());
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.crypto.def.test.sdjwt;
import org.junit.Assume;
import org.junit.Before;
import org.keycloak.common.util.Environment;
import org.keycloak.sdjwt.consumer.SdJwtPresentationConsumerTest;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class DefaultCryptoSdJwtPresentationConsumerTest extends SdJwtPresentationConsumerTest {
@Before
public void before() {
// Run this test just if java is not in FIPS mode
Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode());
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.crypto.elytron.test.sdjwt;
import org.keycloak.sdjwt.consumer.JwtVcMetadataTrustedSdJwtIssuerTest;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class ElytronCryptoJwtVcMetadataTrustedSdJwtIssuerTest extends JwtVcMetadataTrustedSdJwtIssuerTest {
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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.keycloak.crypto.elytron.test.sdjwt;
import org.keycloak.sdjwt.consumer.SdJwtPresentationConsumerTest;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class ElytronCryptoSdJwtPresentationConsumerTest extends SdJwtPresentationConsumerTest {
}