diff --git a/core/src/main/java/org/keycloak/OID4VCConstants.java b/core/src/main/java/org/keycloak/OID4VCConstants.java index 99a43cce089..7504e06b52e 100644 --- a/core/src/main/java/org/keycloak/OID4VCConstants.java +++ b/core/src/main/java/org/keycloak/OID4VCConstants.java @@ -23,6 +23,8 @@ public class OID4VCConstants { public static final String CLAIM_NAME_CNF = "cnf"; public static final String CLAIM_NAME_JWK = "jwk"; + public static final String KEYBINDING_JWT_TYP = "kb+jwt"; + public static final String SD_HASH_DEFAULT_ALGORITHM = "sha-256"; public static final int SD_JWT_KEY_BINDING_DEFAULT_ALLOWED_MAX_AGE = 5 * 60; // 5 minutes public static final int SD_JWT_DEFAULT_CLOCK_SKEW_SECONDS = 10; diff --git a/core/src/main/java/org/keycloak/TokenVerifier.java b/core/src/main/java/org/keycloak/TokenVerifier.java index dbda9c947eb..90b6e3e1053 100755 --- a/core/src/main/java/org/keycloak/TokenVerifier.java +++ b/core/src/main/java/org/keycloak/TokenVerifier.java @@ -248,7 +248,7 @@ public class TokenVerifier { * * @return This token verifier. diff --git a/core/src/main/java/org/keycloak/jose/jws/JWSHeader.java b/core/src/main/java/org/keycloak/jose/jws/JWSHeader.java index f3327964b4e..8517b1b1c41 100755 --- a/core/src/main/java/org/keycloak/jose/jws/JWSHeader.java +++ b/core/src/main/java/org/keycloak/jose/jws/JWSHeader.java @@ -18,16 +18,22 @@ package org.keycloak.jose.jws; import java.io.IOException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.util.Base64; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.keycloak.jose.JOSEHeader; import org.keycloak.jose.jwk.JWK; +import org.keycloak.util.JsonSerialization; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.ObjectMapper; /** * @author Bill Burke @@ -53,6 +59,8 @@ public class JWSHeader implements JOSEHeader { @JsonProperty("x5c") private List x5c; + private Map otherClaims = new HashMap<>(); + public JWSHeader() { } @@ -73,6 +81,10 @@ public class JWSHeader implements JOSEHeader { return algorithm; } + public void setAlgorithm(Algorithm algorithm) { + this.algorithm = algorithm; + } + @JsonIgnore @Override public String getRawAlgorithm() { @@ -83,37 +95,74 @@ public class JWSHeader implements JOSEHeader { return type; } + public void setType(String type) { + this.type = type; + } + public String getContentType() { return contentType; } + public void setContentType(String contentType) { + this.contentType = contentType; + } + public String getKeyId() { return keyId; } + public void setKeyId(String keyId) { + this.keyId = keyId; + } + public JWK getKey() { return key; } + public void setKey(JWK key) { + this.key = key; + } + public List getX5c() { return x5c; } - private static final ObjectMapper mapper = new ObjectMapper(); + public void setX5c(List x5c) { + this.x5c = x5c; + } - static { - mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + public void addX5c(String x5c) { + this.x5c.add(x5c); + } + public void addX5c(Certificate x5c) { + try { + this.x5c.add(Base64.getEncoder().encodeToString(x5c.getEncoded())); + } catch (CertificateEncodingException e) { + throw new RuntimeException(e); + } + } + + /** + * This is a map of any other claims and data that might be in the header. Could be custom claims set up by a custom + * implementation or the auth server + */ + @JsonAnyGetter + public Map getOtherClaims() { + return otherClaims; + } + + @JsonAnySetter + public void setOtherClaims(String name, Object value) { + otherClaims.put(name, value); } public String toString() { try { - return mapper.writeValueAsString(this); + return JsonSerialization.writeValueAsString(this); } catch (IOException e) { throw new RuntimeException(e); } - - } } diff --git a/core/src/main/java/org/keycloak/representations/JsonWebToken.java b/core/src/main/java/org/keycloak/representations/JsonWebToken.java index e5bf33de9d4..d1d935d7e3d 100755 --- a/core/src/main/java/org/keycloak/representations/JsonWebToken.java +++ b/core/src/main/java/org/keycloak/representations/JsonWebToken.java @@ -17,17 +17,20 @@ package org.keycloak.representations; +import java.io.IOException; import java.io.Serializable; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import org.keycloak.Token; import org.keycloak.TokenCategory; import org.keycloak.common.util.Time; import org.keycloak.json.StringOrArrayDeserializer; import org.keycloak.json.StringOrArraySerializer; +import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; @@ -263,4 +266,47 @@ public class JsonWebToken implements Serializable, Token { public TokenCategory getCategory() { return TokenCategory.INTERNAL; } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof JsonWebToken)) { + return false; + } + + JsonWebToken that = (JsonWebToken) o; + return Objects.equals(id, that.id) && // + Objects.equals(exp, that.exp) && // + Objects.equals(nbf, that.nbf) && // + Objects.equals(iat, that.iat) && // + Objects.equals(issuer, that.issuer) && // + Arrays.equals(audience, that.audience) && // + Objects.equals(subject, that.subject) && // + Objects.equals(type, that.type) && // + Objects.equals(issuedFor, that.issuedFor) && // + Objects.equals(otherClaims, that.otherClaims); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(id); + result = 31 * result + Objects.hashCode(exp); + result = 31 * result + Objects.hashCode(nbf); + result = 31 * result + Objects.hashCode(iat); + result = 31 * result + Objects.hashCode(issuer); + result = 31 * result + Arrays.hashCode(audience); + result = 31 * result + Objects.hashCode(subject); + result = 31 * result + Objects.hashCode(type); + result = 31 * result + Objects.hashCode(issuedFor); + result = 31 * result + Objects.hashCode(otherClaims); + return result; + } + + @Override + public String toString() { + try { + return JsonSerialization.writeValueAsString(this); + } catch (IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/core/src/main/java/org/keycloak/sdjwt/AbstractSdJwtClaim.java b/core/src/main/java/org/keycloak/sdjwt/AbstractSdJwtClaim.java index 48ec1d8704f..03da5405ef5 100644 --- a/core/src/main/java/org/keycloak/sdjwt/AbstractSdJwtClaim.java +++ b/core/src/main/java/org/keycloak/sdjwt/AbstractSdJwtClaim.java @@ -16,9 +16,11 @@ */ package org.keycloak.sdjwt; +import java.util.Objects; + /** * @author Francis Pouatcha - * + * */ public abstract class AbstractSdJwtClaim implements SdJwtClaim { private final SdJwtClaimName claimName; @@ -36,4 +38,19 @@ public abstract class AbstractSdJwtClaim implements SdJwtClaim { public String getClaimNameAsString() { return claimName.toString(); } + + @Override + public boolean equals(Object o) { + if (!(o instanceof AbstractSdJwtClaim)) { + return false; + } + + AbstractSdJwtClaim that = (AbstractSdJwtClaim) o; + return Objects.equals(claimName, that.claimName); + } + + @Override + public int hashCode() { + return Objects.hashCode(claimName); + } } diff --git a/core/src/main/java/org/keycloak/sdjwt/ArrayDisclosure.java b/core/src/main/java/org/keycloak/sdjwt/ArrayDisclosure.java index 35720db8175..b631bb0c067 100644 --- a/core/src/main/java/org/keycloak/sdjwt/ArrayDisclosure.java +++ b/core/src/main/java/org/keycloak/sdjwt/ArrayDisclosure.java @@ -27,9 +27,9 @@ import com.fasterxml.jackson.databind.node.ArrayNode; /** * Handles selective disclosure of elements within a top-level array claim, * supporting both visible and undisclosed elements. - * + * * @author Francis Pouatcha - * + * */ public class ArrayDisclosure extends AbstractSdJwtClaim { private final List elements; @@ -84,6 +84,26 @@ public class ArrayDisclosure extends AbstractSdJwtClaim { return disclosureStrings; } + @Override + public final boolean equals(Object o) { + if (!(o instanceof ArrayDisclosure)) { + return false; + } + + ArrayDisclosure that = (ArrayDisclosure) o; + return Objects.equals(elements, that.elements) && // + Objects.equals(visibleClaimValue, that.visibleClaimValue) && // + Objects.equals(decoyElements, that.decoyElements); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(elements); + result = 31 * result + Objects.hashCode(visibleClaimValue); + result = 31 * result + Objects.hashCode(decoyElements); + return result; + } + public static class Builder { private SdJwtClaimName claimName; private final List elements = new ArrayList<>(); diff --git a/core/src/main/java/org/keycloak/sdjwt/ClaimVerifier.java b/core/src/main/java/org/keycloak/sdjwt/ClaimVerifier.java new file mode 100644 index 00000000000..01b7d40a678 --- /dev/null +++ b/core/src/main/java/org/keycloak/sdjwt/ClaimVerifier.java @@ -0,0 +1,552 @@ +/* + * Copyright 2025 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 java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; + +import org.keycloak.OID4VCConstants; +import org.keycloak.common.VerificationException; +import org.keycloak.common.util.Time; +import org.keycloak.representations.JsonWebToken; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Module for validating JWT based claims.
+ * Time-checks include a small tolerance to account for clock skew. + * + * @author Ingrid Kamga + */ +public class ClaimVerifier { + + private final List> headerVerifiers; + private final List> contentVerifiers; + + public ClaimVerifier(List> headerVerifiers, + List> contentVerifiers) { + this.headerVerifiers = headerVerifiers; + this.contentVerifiers = contentVerifiers; + } + + public void verifyClaims(ObjectNode header, ObjectNode body) throws VerificationException { + verifyHeaderClaims(header); + verifyBodyClaims(body); + } + + public void verifyHeaderClaims(ObjectNode header) throws VerificationException { + for (Predicate verifier : headerVerifiers) { + verifier.test(header); + } + } + + public void verifyBodyClaims(ObjectNode body) throws VerificationException { + for (Predicate verifier : contentVerifiers) { + verifier.test(body); + } + } + + public List> getContentVerifiers() { + return contentVerifiers; + } + + public static ClaimVerifier.Builder builder() { + return new ClaimVerifier.Builder(); + } + + /** + * Functional interface of checks that verify some part of a JWT. + * + * @param Type of the token handled by this predicate. + */ + public interface Predicate { + /** + * Performs a single check on the given token verifier. + * + * @param t Token, guaranteed to be non-null. + * @return + * @throws VerificationException + */ + boolean test(T t) throws VerificationException; + + default Instant getCurrentTimestamp() { + return Instant.ofEpochSecond(Time.currentTime()); + } + } + + public static abstract class TimeCheck { + + protected int clockSkewSeconds; + + public TimeCheck(int clockSkewSeconds) { + this.clockSkewSeconds = Math.max(0, clockSkewSeconds); + } + + public int getClockSkewSeconds() { + return clockSkewSeconds; + } + + public void setClockSkewSeconds(int clockSkewSeconds) { + this.clockSkewSeconds = clockSkewSeconds; + } + } + + public static class ClaimCheck implements Predicate { + + private final String claimName; + + private final String expectedClaimValue; + + private final BiFunction stringComparator; + + + private final boolean isOptional; + + public ClaimCheck(String claimName, + String expectedClaimValue) { + this(claimName, expectedClaimValue, false); + } + + public ClaimCheck(String claimName, String expectedClaimValue, boolean isOptional) { + this(claimName, expectedClaimValue, getDefaultComparator(), isOptional); + } + + public ClaimCheck(String claimName, + String expectedClaimValue, + BiFunction stringComparator) { + this(claimName, expectedClaimValue, stringComparator, false); + } + + public ClaimCheck(String claimName, + String expectedClaimValue, + BiFunction stringComparator, + boolean isOptional) { + this.claimName = claimName; + this.expectedClaimValue = expectedClaimValue; + this.stringComparator = Optional.ofNullable(stringComparator).orElseGet(ClaimCheck::getDefaultComparator); + this.isOptional = isOptional; + } + + /** + * @return a simple equals-check for two strings + */ + protected static BiFunction getDefaultComparator() { + return Objects::equals; + } + + @Override + public boolean test(ObjectNode t) throws VerificationException { + if (expectedClaimValue == null) { + throw new VerificationException(String.format("Missing expected value for claim '%s'", claimName)); + } + + String claimValue = Optional.ofNullable(t.get(claimName)).map(JsonNode::asText).map(String::valueOf) + .orElse(null); + if (claimValue == null && !isOptional) { + throw new VerificationException(String.format("Missing claim '%s' in token", claimName)); + } + + boolean checkSuccessful = stringComparator.apply(expectedClaimValue, claimValue); + + if (!checkSuccessful) { + String errorMessage = String.format("Expected value '%s' in token for claim '%s' does " + + "not match actual value '%s'", + expectedClaimValue, + claimName, + claimValue); + throw new VerificationException(errorMessage); + } + + return true; + } + + public String getClaimName() { + return claimName; + } + + public String getExpectedClaimValue() { + return expectedClaimValue; + } + + public boolean isOptional() { + return isOptional; + } + } + + public static class NegatedClaimCheck extends ClaimCheck { + + public NegatedClaimCheck(String claimName, String expectedClaimValue) { + super(claimName, expectedClaimValue); + } + + public NegatedClaimCheck(String claimName, String expectedClaimValue, boolean isOptional) { + super(claimName, expectedClaimValue, isOptional); + } + + public NegatedClaimCheck(String claimName, + String expectedClaimValue, + BiFunction stringComparator) { + super(claimName, expectedClaimValue, stringComparator); + } + + public NegatedClaimCheck(String claimName, + String expectedClaimValue, + BiFunction stringComparator, + boolean isOptional) { + super(claimName, expectedClaimValue, stringComparator, isOptional); + } + + @Override + public boolean test(ObjectNode t) throws VerificationException { + String claimValue = Optional.ofNullable(t.get(getClaimName())).map(JsonNode::asText).map(String::valueOf) + .orElse(null); + if (claimValue == null && !isOptional()) { + throw new VerificationException(String.format("Missing claim '%s' in token", getClaimName())); + } + if (claimValue == null && isOptional()) { + // if optional and not present we do not want to execute the check of the parent. + return true; + } + boolean isParentCheckSuccessful; + try { + isParentCheckSuccessful = super.test(t); + } catch(VerificationException ve) { + return true; // parent-check failed so the negation is successful + } + if (isParentCheckSuccessful) + { + throw new VerificationException(String.format("Value '%s' is not allowed for claim '%s'!", + claimValue, getClaimName())); + } + return true; + } + } + + public static class IatLifetimeCheck extends TimeCheck implements Predicate { + + private final long maxLifetime; + + private boolean isOptional; + + public IatLifetimeCheck(int clockSkewSeconds, long maxLifetime) { + this(clockSkewSeconds, maxLifetime, false); + } + + public IatLifetimeCheck(int clockSkewSeconds, long maxLifetime, boolean isOptional) { + super(Math.max(0, clockSkewSeconds)); + this.maxLifetime = Math.max(0, maxLifetime); + this.isOptional = isOptional; + } + + @Override + public boolean test(ObjectNode jsonWebToken) throws VerificationException { + Long iat = Optional.ofNullable(jsonWebToken.get(OID4VCConstants.CLAIM_NAME_IAT)) + .filter(node -> !node.isNull()) + .map(JsonNode::asLong) + .orElse(null); + if (iat == null) { + if (isOptional) { + return true; + } + else { + throw new VerificationException("Missing required claim 'iat'"); + } + } + + long now = getCurrentTimestamp().getEpochSecond(); + + if (now + clockSkewSeconds < iat) { + throw new VerificationException(String.format("Token was issued in the future: now: '%s', iat: '%s'", + now, + iat)); + } + + long expiration = iat + maxLifetime; + + if (expiration < now - clockSkewSeconds) { + throw new VerificationException(String.format("Token has expired by iat: now: '%s', expired at: '%s', " + + "iat: '%s', maxLifetime: '%s'", + now, + expiration, + iat, + maxLifetime)); + } + return true; + } + } + + public static class NbfCheck extends TimeCheck implements Predicate { + + private boolean isOptional; + + public NbfCheck(int clockSkewSeconds) { + this(clockSkewSeconds, false); + } + + public NbfCheck(int clockSkewSeconds, boolean isOptional) { + super(Math.max(0, clockSkewSeconds)); + this.isOptional = isOptional; + } + + @Override + public boolean test(ObjectNode jsonWebToken) throws VerificationException { + Long notBefore = Optional.ofNullable(jsonWebToken.get(OID4VCConstants.CLAIM_NAME_NBF)) + .filter(node -> !node.isNull()) + .map(JsonNode::asLong) + .orElse(null); + if (notBefore == null) { + if (isOptional) { + return true; + } + else { + throw new VerificationException("Missing required claim 'nbf'"); + } + } + long now = getCurrentTimestamp().getEpochSecond(); + + if (notBefore > now + clockSkewSeconds) { + throw new VerificationException(String.format("Token is not yet valid: now: '%s', nbf: '%s'", + now, + notBefore)); + } + return true; + } + } + + public static class ExpCheck extends TimeCheck implements Predicate { + + private boolean isOptional; + + public ExpCheck(int clockSkewSeconds) { + this(clockSkewSeconds, false); + } + + public ExpCheck(int clockSkewSeconds, boolean isOptional) { + super(Math.max(0, clockSkewSeconds)); + this.isOptional = isOptional; + } + + @Override + public boolean test(ObjectNode jsonWebToken) throws VerificationException { + Long expiration = Optional.ofNullable(jsonWebToken.get(OID4VCConstants.CLAIM_NAME_EXP)) + .filter(node -> !node.isNull()) + .map(JsonNode::asLong) + .orElse(null); + if (expiration == null) { + if (isOptional) { + return true; + } + else { + throw new VerificationException("Missing required claim 'exp'"); + } + } + long now = getCurrentTimestamp().getEpochSecond(); + + if (expiration < now - clockSkewSeconds) { + throw new VerificationException(String.format("Token has expired by exp: now: '%s', exp: '%s'", + now, + expiration)); + } + return true; + } + } + + public static class AudienceCheck implements Predicate { + + private final String expectedAudience; + + public AudienceCheck(String expectedAudience) { + this.expectedAudience = expectedAudience; + } + + @Override + public boolean test(ObjectNode t) throws VerificationException { + if (expectedAudience == null) { + throw new VerificationException("Missing expected audience"); + } + + JsonNode audienceArray = t.get("aud"); + if (audienceArray == null) { + throw new VerificationException("No audience in the token"); + } + + Set audiences = new HashSet<>(); + if (audienceArray.isArray()) { + for (JsonNode audienceNode : audienceArray) { + audiences.add(audienceNode.textValue()); + } + } + else { + audiences.add(audienceArray.textValue()); + } + + if (audiences.contains(expectedAudience)) { + return true; + } + + throw new VerificationException(String.format("Expected audience '%s' not available in the token. " + + "Present values are '%s'", + expectedAudience, audiences)); + } + } + + public static class Builder { + + protected Integer clockSkew = OID4VCConstants.SD_JWT_DEFAULT_CLOCK_SKEW_SECONDS; + protected Integer allowedMaxAge = OID4VCConstants.SD_JWT_KEY_BINDING_DEFAULT_ALLOWED_MAX_AGE; + protected List> headerVerifiers = new ArrayList<>(); + protected List> contentVerifiers = new ArrayList<>(); + + public Builder() { + this(OID4VCConstants.SD_JWT_DEFAULT_CLOCK_SKEW_SECONDS); + } + + public Builder(Integer clockSkew) { + this.withClockSkew(Optional.ofNullable(clockSkew).orElse(OID4VCConstants.SD_JWT_DEFAULT_CLOCK_SKEW_SECONDS)); + this.withIatCheck(allowedMaxAge, false); + this.withExpCheck(false); + this.withNbfCheck(false); + + // add algorithm not "none"-check + { + boolean isOptional = false; + headerVerifiers.add(new NegatedClaimCheck("alg", "none", (s1, s2) -> { + // ignore upper and lowercase for comparison + return s1 != null && s1.equalsIgnoreCase(s2); + }, isOptional)); + } + } + + public Builder withClockSkew(int clockSkew) { + this.clockSkew = Math.max(0, clockSkew); + contentVerifiers.stream() + .filter(verifier -> verifier instanceof TimeCheck) + .forEach(timeCheckVerifier -> { + ((TimeCheck) timeCheckVerifier).setClockSkewSeconds(clockSkew); + }); + return this; + } + + public Builder withIatCheck(Integer allowedMaxAge) { + return withIatCheck(allowedMaxAge, false); + } + + public Builder withIatCheck(boolean isCheckOptional) { + return withIatCheck(allowedMaxAge, isCheckOptional); + } + + public Builder withIatCheck(Integer allowedMaxAge, boolean isCheckOptional) { + this.allowedMaxAge = Optional.ofNullable(allowedMaxAge).orElse(0); + contentVerifiers.removeIf(verifier -> { + return verifier instanceof ClaimVerifier.IatLifetimeCheck || + (verifier instanceof ClaimVerifier.ClaimCheck + && ((ClaimCheck) verifier).getClaimName().equalsIgnoreCase(OID4VCConstants.CLAIM_NAME_IAT)); + }); + if (allowedMaxAge != null) { + contentVerifiers.add(new ClaimVerifier.IatLifetimeCheck(Optional.ofNullable(clockSkew).orElse(0), + allowedMaxAge, + isCheckOptional)); + } + return this; + } + + public Builder withNbfCheck() { + withNbfCheck(false); + return this; + } + + public Builder withNbfCheck(boolean isCheckOptional) { + contentVerifiers.removeIf(verifier -> { + return verifier instanceof ClaimVerifier.NbfCheck || + (verifier instanceof ClaimVerifier.ClaimCheck + && ((ClaimCheck) verifier).getClaimName().equalsIgnoreCase(OID4VCConstants.CLAIM_NAME_NBF)); + }); + if (clockSkew != null) { + contentVerifiers.add(new ClaimVerifier.NbfCheck(clockSkew, isCheckOptional)); + } + return this; + } + + public Builder withExpCheck() { + withExpCheck(false); + return this; + } + + public Builder withExpCheck(boolean isCheckOptional) { + contentVerifiers.removeIf(verifier -> { + return verifier instanceof ClaimVerifier.ExpCheck || + (verifier instanceof ClaimVerifier.ClaimCheck + && ((ClaimCheck) verifier).getClaimName().equalsIgnoreCase(OID4VCConstants.CLAIM_NAME_EXP)); + }); + if (clockSkew != null) { + contentVerifiers.add(new ClaimVerifier.ExpCheck(clockSkew, isCheckOptional)); + } + return this; + } + + public Builder withAudCheck(String expectedAud) { + contentVerifiers.removeIf(verifier -> { + return verifier instanceof ClaimVerifier.AudienceCheck || + (verifier instanceof ClaimVerifier.ClaimCheck + && ((ClaimCheck) verifier).getClaimName().equalsIgnoreCase(JsonWebToken.AUD)); + }); + if (expectedAud != null) { + contentVerifiers.add(new ClaimVerifier.AudienceCheck(expectedAud)); + } + return this; + } + + public Builder withClaimCheck(String claimName, String expectedValue) { + return withClaimCheck(claimName, expectedValue, false); + } + + public Builder withClaimCheck(String claimName, String expectedValue, boolean isOptionalCheck) { + contentVerifiers.removeIf(verifier -> { + return verifier instanceof ClaimVerifier.ClaimCheck && + ((ClaimVerifier.ClaimCheck) verifier).getClaimName().equals(claimName); + }); + if (expectedValue != null) { + contentVerifiers.add(new ClaimVerifier.ClaimCheck(claimName, expectedValue, isOptionalCheck)); + } + return this; + } + + public Builder withContentVerifiers(List> contentVerifiers) { + this.contentVerifiers = contentVerifiers; + return this; + } + + public Builder addContentVerifiers(List> contentVerifiers) { + this.contentVerifiers = Optional.ofNullable(this.contentVerifiers).orElseGet(ArrayList::new); + this.contentVerifiers.addAll(contentVerifiers); + return this; + } + + public ClaimVerifier build() { + + return new ClaimVerifier(headerVerifiers, contentVerifiers); + } + } +} diff --git a/core/src/main/java/org/keycloak/sdjwt/DecoyArrayElement.java b/core/src/main/java/org/keycloak/sdjwt/DecoyArrayElement.java index 201ed787999..4aae5a38faa 100644 --- a/core/src/main/java/org/keycloak/sdjwt/DecoyArrayElement.java +++ b/core/src/main/java/org/keycloak/sdjwt/DecoyArrayElement.java @@ -16,12 +16,14 @@ */ package org.keycloak.sdjwt; +import java.util.Objects; + import com.fasterxml.jackson.databind.JsonNode; import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_UNDISCLOSED_ARRAY; /** - * + * * @author Francis Pouatcha */ public class DecoyArrayElement extends DecoyEntry { @@ -41,6 +43,26 @@ public class DecoyArrayElement extends DecoyEntry { return index; } + @Override + public boolean equals(Object o) { + if (!(o instanceof DecoyArrayElement)) { + return false; + } + if (!super.equals(o)) { + return false; + } + + DecoyArrayElement that = (DecoyArrayElement) o; + return Objects.equals(index, that.index); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(index); + return result; + } + public static class Builder { private SdJwtSalt salt; private Integer index; diff --git a/core/src/main/java/org/keycloak/sdjwt/DecoyEntry.java b/core/src/main/java/org/keycloak/sdjwt/DecoyEntry.java index e0c2fc59b97..879025ff8a1 100644 --- a/core/src/main/java/org/keycloak/sdjwt/DecoyEntry.java +++ b/core/src/main/java/org/keycloak/sdjwt/DecoyEntry.java @@ -22,19 +22,14 @@ import org.keycloak.jose.jws.crypto.HashUtils; /** * Handles hash production for a decoy entry from the given salt. - * + * * @author Francis Pouatcha - * + * */ -public abstract class DecoyEntry { - private final SdJwtSalt salt; +public abstract class DecoyEntry extends DisclosureSpec.DisclosureData { protected DecoyEntry(SdJwtSalt salt) { - this.salt = Objects.requireNonNull(salt, "DecoyEntry always requires a non null salt"); - } - - public SdJwtSalt getSalt() { - return salt; + super(Objects.requireNonNull(salt, "DecoyEntry always requires a non null salt")); } public String getDisclosureDigest(String hashAlg) { diff --git a/core/src/main/java/org/keycloak/sdjwt/Disclosable.java b/core/src/main/java/org/keycloak/sdjwt/Disclosable.java index 00c7a7a7d94..d5b9590b3ee 100644 --- a/core/src/main/java/org/keycloak/sdjwt/Disclosable.java +++ b/core/src/main/java/org/keycloak/sdjwt/Disclosable.java @@ -23,12 +23,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; /** * Handles undisclosed claims and array elements, providing functionality * to generate disclosure digests from Base64Url encoded strings. - * + * * Hiding claims and array elements occurs by including their digests * instead of plaintext in the signed verifiable credential. - * + * * @author Francis Pouatcha - * + * */ public abstract class Disclosable { private final SdJwtSalt salt; @@ -72,4 +72,19 @@ public abstract class Disclosable { public String toString() { return getDisclosureString(); } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Disclosable)) { + return false; + } + + Disclosable that = (Disclosable) o; + return salt.equals(that.salt); + } + + @Override + public int hashCode() { + return salt.hashCode(); + } } diff --git a/core/src/main/java/org/keycloak/sdjwt/DisclosureSpec.java b/core/src/main/java/org/keycloak/sdjwt/DisclosureSpec.java index 58c2caf8634..d6caf2fe884 100644 --- a/core/src/main/java/org/keycloak/sdjwt/DisclosureSpec.java +++ b/core/src/main/java/org/keycloak/sdjwt/DisclosureSpec.java @@ -21,12 +21,14 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; /** * Manages the specification of undisclosed claims and array elements. - * + * * @author Francis Pouatcha - * + * */ public class DisclosureSpec { @@ -45,9 +47,9 @@ public class DisclosureSpec { private final Map> decoyArrayElts; private DisclosureSpec(Map undisclosedClaims, - List decoyClaims, - Map> undisclosedArrayElts, - Map> decoyArrayElts) { + List decoyClaims, + Map> undisclosedArrayElts, + Map> decoyArrayElts) { this.undisclosedClaims = undisclosedClaims; this.decoyClaims = decoyClaims; this.undisclosedArrayElts = undisclosedArrayElts; @@ -80,6 +82,14 @@ public class DisclosureSpec { return undisclosedArrayElts.containsKey(claimName); } + public List createDecoyClaims() { + return this.getDecoyClaims().stream() + .map(disclosureData -> { + return DecoyClaim.builder().withSalt(disclosureData.getSalt()).build(); + }) + .collect(Collectors.toList()); + } + public static class Builder { private final Map undisclosedClaims = new HashMap<>(); private final List decoyClaims = new ArrayList<>(); @@ -170,16 +180,20 @@ public class DisclosureSpec { } public static class DisclosureData { - private final SdJwtSalt salt; + protected final SdJwtSalt salt; - private DisclosureData() { + public DisclosureData() { this.salt = null; } - private DisclosureData(String salt) { + public DisclosureData(String salt) { this.salt = salt == null ? null : SdJwtSalt.of(salt); } + public DisclosureData(SdJwtSalt salt) { + this.salt = salt; + } + public static DisclosureData of(String salt) { return salt == null ? new DisclosureData() : new DisclosureData(salt); } @@ -187,5 +201,20 @@ public class DisclosureSpec { public SdJwtSalt getSalt() { return salt; } + + @Override + public boolean equals(Object o) { + if (!(o instanceof DisclosureData)) { + return false; + } + + DisclosureData that = (DisclosureData) o; + return Objects.equals(salt, that.salt); + } + + @Override + public int hashCode() { + return Objects.hashCode(salt); + } } } diff --git a/core/src/main/java/org/keycloak/sdjwt/IssuerSignedJWT.java b/core/src/main/java/org/keycloak/sdjwt/IssuerSignedJWT.java index 060426cc475..eb4ee7d547a 100644 --- a/core/src/main/java/org/keycloak/sdjwt/IssuerSignedJWT.java +++ b/core/src/main/java/org/keycloak/sdjwt/IssuerSignedJWT.java @@ -16,8 +16,11 @@ */ package org.keycloak.sdjwt; +import java.security.cert.Certificate; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -29,10 +32,15 @@ import java.util.stream.Collectors; import org.keycloak.OID4VCConstants; import org.keycloak.common.VerificationException; import org.keycloak.crypto.SignatureSignerContext; -import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.sdjwt.vp.KeyBindingJWT; +import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.LongNode; import com.fasterxml.jackson.databind.node.ObjectNode; import static org.keycloak.OID4VCConstants.CLAIM_NAME_CNF; @@ -40,60 +48,165 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD; import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM; /** - * Handle verifiable credentials (SD-JWT VC), enabling the parsing - * of existing VCs as well as the creation and signing of new ones. - * It integrates with Keycloak's SignatureSignerContext to facilitate - * the generation of issuer signature. + * Handle verifiable credentials (SD-JWT VC), enabling the parsing of existing VCs as well as the creation and signing + * of new ones. It integrates with Keycloak's SignatureSignerContext to facilitate the generation of issuer signature. * * @author Francis Pouatcha */ -public class IssuerSignedJWT extends SdJws { +public class IssuerSignedJWT extends JwsToken { - public IssuerSignedJWT(JsonNode payload, SignatureSignerContext signer, String jwsType) { - super(payload, signer, jwsType); + private DisclosureSpec disclosureSpec; + + private List disclosureClaims; + + private List decoyClaims; + + + public IssuerSignedJWT(JWSHeader jwsHeader, + ObjectNode payload) { + super(jwsHeader, payload); + this.disclosureSpec = null; + this.disclosureClaims = new ArrayList<>(); + this.decoyClaims = new ArrayList<>(); } - public static IssuerSignedJWT fromJws(String jwsString) { - return new IssuerSignedJWT(jwsString); + public IssuerSignedJWT(JWSHeader jwsHeader, + ObjectNode payload, + SignatureSignerContext signer) { + super(jwsHeader, payload, signer); + this.disclosureSpec = null; + this.disclosureClaims = new ArrayList<>(); + this.decoyClaims = new ArrayList<>(); } - private IssuerSignedJWT(String jwsString) { + public IssuerSignedJWT(String jwsString) { super(jwsString); + this.disclosureSpec = null; + this.disclosureClaims = new ArrayList<>(); + this.decoyClaims = new ArrayList<>(); } - private IssuerSignedJWT(List claims, List decoyClaims, String hashAlg, - boolean nestedDisclosures) { - super(generatePayloadString(claims, decoyClaims, hashAlg, nestedDisclosures)); + public IssuerSignedJWT(DisclosureSpec disclosureSpec, + ObjectNode disclosureClaims) { + this(disclosureSpec, disclosureClaims, OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM); } - private IssuerSignedJWT(JsonNode payload, JWSInput jwsInput) { - super(payload, jwsInput); + public IssuerSignedJWT(DisclosureSpec disclosureSpec, + ObjectNode disclosureClaims, + String hashAlg) { + this(disclosureSpec, new JWSHeader(), disclosureClaims, null, hashAlg, false); } - private IssuerSignedJWT(List claims, List decoyClaims, String hashAlg, - boolean nestedDisclosures, SignatureSignerContext signer, String jwsType) { - super(generatePayloadString(claims, decoyClaims, hashAlg, nestedDisclosures), signer, jwsType); + public IssuerSignedJWT(DisclosureSpec disclosureSpec, + ObjectNode disclosureClaims, + String hashAlg, + boolean nestedDisclosures) { + this(disclosureSpec, new JWSHeader(), disclosureClaims, null, hashAlg, nestedDisclosures); + } + + public IssuerSignedJWT(DisclosureSpec disclosureSpec, + ObjectNode disclosureClaims, + List decoyClaims, + String hashAlg, + boolean nestedDisclosures) { + this(disclosureSpec, new JWSHeader(), disclosureClaims, decoyClaims, hashAlg, nestedDisclosures); + } + + public IssuerSignedJWT(List disclosureClaims, + List decoyClaims, + String hashAlg, + boolean nestedDisclosures) { + this(DisclosureSpec.builder().build(), new JWSHeader(), + disclosureClaims, decoyClaims, hashAlg, nestedDisclosures); + } + + public IssuerSignedJWT(DisclosureSpec disclosureSpec, + JWSHeader jwsHeader, + ObjectNode disclosureClaims, + List decoyClaims, + String hashAlg, + boolean nestedDisclosures) { + this(disclosureSpec, + jwsHeader, + SdJwtClaimFactory.parsePayload(disclosureClaims, disclosureSpec), + decoyClaims, + hashAlg, + nestedDisclosures); + } + + public IssuerSignedJWT(DisclosureSpec disclosureSpec, + JWSHeader jwsHeader, + ObjectNode disclosureClaims, + List decoyClaims, + String hashAlg, + boolean nestedDisclosures, + SignatureSignerContext signer) { + this(disclosureSpec, + jwsHeader, + SdJwtClaimFactory.parsePayload(disclosureClaims, disclosureSpec), + decoyClaims, + hashAlg, + nestedDisclosures, + signer); + } + + public IssuerSignedJWT(DisclosureSpec disclosureSpec, + JWSHeader jwsHeader, + List disclosureClaims, + List decoyClaims, + String hashAlg, + boolean nestedDisclosures) { + super(jwsHeader, generatePayloadString(disclosureClaims, decoyClaims, hashAlg, nestedDisclosures)); + this.disclosureSpec = disclosureSpec; + this.disclosureClaims = disclosureClaims; + this.decoyClaims = decoyClaims; + } + + public IssuerSignedJWT(List disclosureClaims, + List decoyClaims, + String hashAlg, + boolean nestedDisclosures, + SignatureSignerContext signer, + String jwsType) { + this(null, new JWSHeader(null, jwsType, null), + disclosureClaims, decoyClaims, hashAlg, nestedDisclosures, signer); + } + + public IssuerSignedJWT(DisclosureSpec disclosureSpec, + JWSHeader jwsHeader, + List disclosureClaims, + List decoyClaims, + String hashAlg, + boolean nestedDisclosures, + SignatureSignerContext signer) { + super(jwsHeader, + generatePayloadString(disclosureClaims, decoyClaims, hashAlg, nestedDisclosures), + signer); + this.disclosureSpec = disclosureSpec; + this.disclosureClaims = disclosureClaims; + this.decoyClaims = decoyClaims; } /* - * Generates the payload of the issuer signed jwt from the list - * of claims. + * Generates the payload of the issuer signed jwt from the list of claims. */ - private static JsonNode generatePayloadString(List claims, List decoyClaims, String hashAlg, - boolean nestedDisclosures) { + private static ObjectNode generatePayloadString(List claims, + List decoyClaims, + String hashAlg, + boolean nestedDisclosures) { SdJwtUtils.requireNonEmpty(hashAlg, "hashAlg must not be null or empty"); final List claimsInternal = claims == null ? Collections.emptyList() - : Collections.unmodifiableList(claims); + : Collections.unmodifiableList(claims); final List decoyClaimsInternal = decoyClaims == null ? Collections.emptyList() - : Collections.unmodifiableList(decoyClaims); + : Collections.unmodifiableList(decoyClaims); try { - // Check no dupplicate claim names + // Check no duplicate claim names claimsInternal.stream() - .filter(Objects::nonNull) - // is any duplicate, toMap will throw IllegalStateException - .collect(Collectors.toMap(SdJwtClaim::getClaimName, claim -> claim)); + .filter(Objects::nonNull) + // is any duplicate, toMap will throw IllegalStateException + .collect(Collectors.toMap(SdJwtClaim::getClaimName, claim -> claim)); } catch (IllegalStateException e) { throw new IllegalArgumentException("claims must not contain duplicate claim names", e); } @@ -102,16 +215,29 @@ public class IssuerSignedJWT extends SdJws { // first filter all UndisclosedClaim // then sort by salt // then push digest into the sdArray - List digests = claimsInternal.stream() - .filter(claim -> claim instanceof UndisclosedClaim) - .map(claim -> (UndisclosedClaim) claim) - .collect(Collectors.toMap(UndisclosedClaim::getSalt, claim -> claim)) - .entrySet().stream() - .sorted(Map.Entry.comparingByKey()) - .map(Map.Entry::getValue) - .filter(Objects::nonNull) - .map(od -> od.getDisclosureDigest(hashAlg)) - .collect(Collectors.toList()); + Map undisclosedClaimMap = new HashMap<>(); + claimsInternal.stream() + .filter(claim -> claim instanceof UndisclosedClaim) + .map(claim -> (UndisclosedClaim) claim) + .forEach(undisclosedClaim -> { + if (undisclosedClaimMap.containsKey(undisclosedClaim.getSalt())) { + String errorMessage = String.format("Salt value '%s' was reused for claims " + + "'%s' and '%s'", + undisclosedClaim.getSalt(), + undisclosedClaim.getClaimName(), + undisclosedClaimMap.get(undisclosedClaim.getSalt()) + .getClaimName()); + throw new IllegalArgumentException(errorMessage); + } + undisclosedClaimMap.put(undisclosedClaim.getSalt(), undisclosedClaim); + }); + + List digests = undisclosedClaimMap.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(Map.Entry::getValue) + .filter(Objects::nonNull) + .map(od -> od.getDisclosureDigest(hashAlg)) + .collect(Collectors.toList()); // add decoy claims decoyClaimsInternal.stream().map(claim -> claim.getDisclosureDigest(hashAlg)).forEach(digests::add); @@ -133,12 +259,13 @@ public class IssuerSignedJWT extends SdJws { // Disclosure of array of elements is handled // by the corresponding claim object. claimsInternal.stream() - .filter(Objects::nonNull) - .filter(claim -> !(claim instanceof UndisclosedClaim)) - .forEach(nullableClaim -> { - SdJwtClaim claim = Objects.requireNonNull(nullableClaim); - payload.set(claim.getClaimNameAsString(), claim.getVisibleClaimValue(hashAlg)); - }); + .filter(Objects::nonNull) + .filter(claim -> !(claim instanceof UndisclosedClaim)) + .forEach(nullableClaim -> { + SdJwtClaim claim = Objects.requireNonNull(nullableClaim); + payload.set(claim.getClaimNameAsString(), claim.getVisibleClaimValue(hashAlg)); + }); + return payload; } @@ -155,8 +282,10 @@ public class IssuerSignedJWT extends SdJws { * Returns declared hash algorithm from SD hash claim. */ public String getSdHashAlg() { - JsonNode hashAlgNode = getPayload().get(CLAIM_NAME_SD_HASH_ALGORITHM); - return hashAlgNode == null ? "sha-256" : hashAlgNode.asText(); + ObjectNode payload = getPayload(); + return Optional.ofNullable(payload.get(CLAIM_NAME_SD_HASH_ALGORITHM)) + .map(JsonNode::textValue) + .orElse(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM); } /** @@ -167,8 +296,8 @@ public class IssuerSignedJWT extends SdJws { public void verifySdHashAlgorithm() throws VerificationException { // Known secure algorithms final Set secureAlgorithms = new HashSet<>(Arrays.asList( - "sha-256", "sha-384", "sha-512", - "sha3-256", "sha3-384", "sha3-512" + OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM, "sha-384", "sha-512", + "sha3-256", "sha3-384", "sha3-512" )); // Read SD hash claim @@ -180,24 +309,85 @@ public class IssuerSignedJWT extends SdJws { } } + public DisclosureSpec getDisclosureSpec() { + return disclosureSpec; + } + + public List getDisclosureClaims() { + return disclosureClaims; + } + + public List getDecoyClaims() { + return decoyClaims; + } + + public void setDisclosureClaims(DisclosureSpec disclosureSpec, + List disclosureClaims, + List decoyClaims) { + setDisclosureClaims(disclosureSpec, disclosureClaims, decoyClaims, null); + } + + public void setDisclosureClaims(DisclosureSpec disclosureSpec, + List disclosureClaims, + List decoyClaims, + SignatureSignerContext signatureSignerContext) { + this.disclosureSpec = disclosureSpec; + this.disclosureClaims = disclosureClaims; + this.decoyClaims = decoyClaims; + super.setPayload(generatePayloadString(disclosureClaims, + decoyClaims, + getSdHashAlg(), + false/* TODO do we need this flag? */)); + setJws(null); + setJwsInput(null); + Optional.ofNullable(signatureSignerContext).ifPresent(super::sign); + } + // Builder public static Builder builder() { return new Builder(); } public static class Builder { + private DisclosureSpec disclosureSpec; private List claims; private String hashAlg; private SignatureSignerContext signer; private List decoyClaims; private boolean nestedDisclosures; - private String jwsType; + private JWSHeader jwsHeader = new JWSHeader(); + + private JWSHeader getJwsHeader() { + if (this.jwsHeader == null) { + this.jwsHeader = new JWSHeader(); + } + return jwsHeader; + } + + private List getClaims() { + if (this.claims == null) { + this.claims = new ArrayList<>(); + } + return claims; + } public Builder withClaims(List claims) { this.claims = claims; return this; } + public Builder withClaims(ObjectNode claimsNode) { + this.disclosureSpec = DisclosureSpec.builder().build(); + this.claims = SdJwtClaimFactory.parsePayload(claimsNode, disclosureSpec); + return this; + } + + public Builder withClaims(ObjectNode claimsNode, DisclosureSpec disclosureSpec) { + this.disclosureSpec = disclosureSpec; + this.claims = SdJwtClaimFactory.parsePayload(claimsNode, disclosureSpec); + return this; + } + public Builder withDecoyClaims(List decoyClaims) { this.decoyClaims = decoyClaims; return this; @@ -219,21 +409,112 @@ public class IssuerSignedJWT extends SdJws { } public Builder withJwsType(String jwsType) { - this.jwsType = jwsType; + if (this.jwsHeader == null) { + this.jwsHeader = new JWSHeader(); + } + this.jwsHeader.setType(jwsType); + return this; + } + + public Builder withJwsHeader(JWSHeader jwsHeader) { + // preserve the type in case that the method 'withJwsType' was called before this method. + String jwsType = Optional.ofNullable(this.jwsHeader).map(JWSHeader::getType).orElse(null); + this.jwsHeader = jwsHeader; + if (this.jwsHeader != null) { + this.jwsHeader.setType(jwsType); + } + return this; + } + + public Builder withKid(String kid) { + getJwsHeader().setKeyId(kid); + return this; + } + + public Builder withX5c(List x5c) { + getJwsHeader().setX5c(x5c); + return this; + } + + public Builder withX5c(String x5c) { + getJwsHeader().addX5c(x5c); + return this; + } + + public Builder withX5c(Certificate x5c) { + getJwsHeader().addX5c(x5c); + return this; + } + + public Builder withIat(long iat) { + getClaims().add(new VisibleSdJwtClaim(SdJwtClaimName.of(OID4VCConstants.CLAIM_NAME_IAT), new LongNode(iat))); + return this; + } + + public Builder withNbf(long nbf) { + getClaims().add(new VisibleSdJwtClaim(SdJwtClaimName.of(OID4VCConstants.CLAIM_NAME_NBF), new LongNode(nbf))); + return this; + } + + public Builder withExp(long exp) { + getClaims().add(new VisibleSdJwtClaim(SdJwtClaimName.of(OID4VCConstants.CLAIM_NAME_EXP), new LongNode(exp))); + return this; + } + + /** + * this method requires the public key to be present in the keybindingJwts header as "jwk" claim + */ + public Builder withKeyBinding(KeyBindingJWT keyBinding) { + ObjectNode cnf = JsonNodeFactory.instance.objectNode(); + Optional.ofNullable(keyBinding.getJwsHeader().getOtherClaims().get(OID4VCConstants.CLAIM_NAME_JWK)) + .map(map -> JsonSerialization.mapper.convertValue(map, ObjectNode.class)) + .ifPresent(jwkNode -> cnf.set(OID4VCConstants.CLAIM_NAME_JWK, jwkNode)); + if (!cnf.isEmpty()) { + getClaims().add(new VisibleSdJwtClaim(SdJwtClaimName.of(CLAIM_NAME_CNF), cnf)); + } + return this; + } + + public Builder withKeyBinding(JWK keyBinding) { + return withKeyBinding(JsonSerialization.mapper.convertValue(keyBinding, ObjectNode.class)); + } + + public Builder withKeyBinding(ObjectNode keyBinding) { + ObjectNode cnf = JsonNodeFactory.instance.objectNode(); + cnf.set("jwk", keyBinding); + getClaims().add(new VisibleSdJwtClaim(SdJwtClaimName.of(CLAIM_NAME_CNF), cnf)); + return this; + } + + public Builder withClaim(SdJwtClaim sdJwtClaim) { + getClaims().add(sdJwtClaim); return this; } public IssuerSignedJWT build() { // Preinitialize hashAlg to sha-256 if not provided hashAlg = hashAlg == null ? OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM : hashAlg; - jwsType = jwsType == null ? OID4VCConstants.SD_JWT_VC_FORMAT : jwsType; + jwsHeader.setType(jwsHeader.getType() == null ? OID4VCConstants.SD_JWT_VC_FORMAT : jwsHeader.getType()); + disclosureSpec = Optional.ofNullable(disclosureSpec).orElseGet(() -> DisclosureSpec.builder().build()); // send an empty lise if claims not set. - claims = claims == null ? Collections.emptyList() : claims; - decoyClaims = decoyClaims == null ? Collections.emptyList() : decoyClaims; + decoyClaims = decoyClaims == null ? disclosureSpec.createDecoyClaims() : decoyClaims; + if (signer != null) { - return new IssuerSignedJWT(claims, decoyClaims, hashAlg, nestedDisclosures, signer, jwsType); - } else { - return new IssuerSignedJWT(claims, decoyClaims, hashAlg, nestedDisclosures); + return new IssuerSignedJWT(disclosureSpec, + jwsHeader, + claims, + decoyClaims, + hashAlg, + nestedDisclosures, + signer); + } + else { + return new IssuerSignedJWT(disclosureSpec, + jwsHeader, + claims, + decoyClaims, + hashAlg, + nestedDisclosures); } } } diff --git a/core/src/main/java/org/keycloak/sdjwt/IssuerSignedJwtVerificationOpts.java b/core/src/main/java/org/keycloak/sdjwt/IssuerSignedJwtVerificationOpts.java index a133efff24f..ec009e74779 100644 --- a/core/src/main/java/org/keycloak/sdjwt/IssuerSignedJwtVerificationOpts.java +++ b/core/src/main/java/org/keycloak/sdjwt/IssuerSignedJwtVerificationOpts.java @@ -17,36 +17,112 @@ package org.keycloak.sdjwt; +import java.util.List; + +import org.keycloak.common.VerificationException; + +import com.fasterxml.jackson.databind.node.ObjectNode; + /** * Options for Issuer-signed JWT verification. * * @author Ingrid Kamga */ -public class IssuerSignedJwtVerificationOpts extends TimeClaimVerificationOpts { +public class IssuerSignedJwtVerificationOpts extends ClaimVerifier { - private IssuerSignedJwtVerificationOpts( - boolean validateIssuedAtClaim, - boolean validateExpirationClaim, - boolean validateNotBeforeClaim, - int allowedClockSkewSeconds - ) { - super(validateIssuedAtClaim, validateExpirationClaim, validateNotBeforeClaim, allowedClockSkewSeconds); + + public IssuerSignedJwtVerificationOpts(List> headerVerifiers, + List> contentVerifiers) { + super(headerVerifiers, contentVerifiers); } - public static Builder builder() { - return new Builder(); + public void verify(JwsToken tokenToVerify) throws VerificationException { + super.verifyClaims(tokenToVerify.getJwsHeaderAsNode(), tokenToVerify.getPayload()); } - public static class Builder extends TimeClaimVerificationOpts.Builder { + public static IssuerSignedJwtVerificationOpts.Builder builder() { + return new IssuerSignedJwtVerificationOpts.Builder(); + } + + public static class Builder extends ClaimVerifier.Builder { + + public Builder() { + } + + public Builder(Integer clockSkew) { + super(clockSkew); + } @Override + public IssuerSignedJwtVerificationOpts.Builder withIatCheck(Integer allowedMaxAge) { + return (Builder) super.withIatCheck(allowedMaxAge); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withIatCheck(boolean isCheckOptional) { + return (Builder) super.withIatCheck(isCheckOptional); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withIatCheck(Integer allowedMaxAge, boolean isCheckOptional) { + return (Builder) super.withIatCheck(allowedMaxAge, isCheckOptional); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withNbfCheck() { + return (Builder) super.withNbfCheck(); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withNbfCheck(boolean isCheckOptional) { + return (Builder) super.withNbfCheck(isCheckOptional); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withExpCheck() { + return (Builder) super.withExpCheck(); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withExpCheck(boolean isCheckOptional) { + return (Builder) super.withExpCheck(isCheckOptional); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withClockSkew(int clockSkew) { + return (Builder) super.withClockSkew(clockSkew); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withContentVerifiers(List> contentVerifiers) { + return (Builder) super.withContentVerifiers(contentVerifiers); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder addContentVerifiers(List> contentVerifiers) { + return (Builder) super.addContentVerifiers(contentVerifiers); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withAudCheck(String expectedAud) { + return (Builder) super.withAudCheck(expectedAud); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withClaimCheck(String claimName, String expectedValue) { + return (Builder) super.withClaimCheck(claimName, expectedValue); + } + + @Override + public IssuerSignedJwtVerificationOpts.Builder withClaimCheck(String claimName, + String expectedValue, + boolean isOptionalCheck) { + return (Builder) super.withClaimCheck(claimName, expectedValue, isOptionalCheck); + } + public IssuerSignedJwtVerificationOpts build() { - return new IssuerSignedJwtVerificationOpts( - requireIssuedAtClaim, - requireExpirationClaim, - requireNotBeforeClaim, - allowedClockSkewSeconds - ); + + return new IssuerSignedJwtVerificationOpts(headerVerifiers, contentVerifiers); } } } diff --git a/core/src/main/java/org/keycloak/sdjwt/JwsToken.java b/core/src/main/java/org/keycloak/sdjwt/JwsToken.java new file mode 100644 index 00000000000..77508e6a2f1 --- /dev/null +++ b/core/src/main/java/org/keycloak/sdjwt/JwsToken.java @@ -0,0 +1,150 @@ +/* + * 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 java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.Optional; + +import org.keycloak.OID4VCConstants; +import org.keycloak.common.VerificationException; +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.crypto.SignatureVerifierContext; +import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Handle jws, either the issuer jwt or the holder key binding jwt. + * + * @author Francis Pouatcha + * + */ +public abstract class JwsToken { + + protected JWSHeader jwsHeader; + + protected ObjectNode payload; + + protected String jws; + + protected JWSInput jwsInput; + + public JwsToken(String jws) { + parse(jws); + } + + public JwsToken(JWSHeader jwsHeader, ObjectNode payload) { + this.jwsHeader = jwsHeader; + this.payload = payload; + } + + public JwsToken(JWSHeader jwsHeader, ObjectNode payload, SignatureSignerContext signerContext) { + this.jwsHeader = jwsHeader; + this.payload = payload; + this.jws = sign(signerContext); + } + + public String sign(SignatureSignerContext signerContext) { + jws = new JWSBuilder().header(jwsHeader).jsonContent(payload).sign(signerContext); + try { + jwsInput = new JWSInput(jws); + jwsHeader = jwsInput.getHeader(); + payload = jwsInput.readJsonContent(ObjectNode.class); + } catch (JWSInputException e) { + throw new IllegalStateException(String.format("Got invalid JWS '%s'", jws), e); + } + return jws; + } + + public void verifySignature(SignatureVerifierContext verifier) throws VerificationException { + Objects.requireNonNull(verifier, "verifier must not be null"); + try { + if (!verifier.verify(jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), + jwsInput.getSignature())) { + throw new VerificationException("Invalid jws signature"); + } + } catch (Exception e) { + throw new VerificationException(e); + } + } + + public Optional getSdHashAlgorithm() { + return Optional.ofNullable(payload.get(OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM)) + .map(JsonNode::textValue); + } + + public String getJws() { + return jws; + } + + public void setJws(String jws) { + this.jws = jws; + } + + public JWSInput getJwsInput() { + return jwsInput; + } + + public void setJwsInput(JWSInput jwsInput) { + this.jwsInput = jwsInput; + if (jwsInput != null) { + setJwsHeader(jwsInput.getHeader()); + try { + setPayload(jwsInput.readJsonContent(ObjectNode.class)); + } catch (JWSInputException e) { + throw new RuntimeException(e); + } + } + } + + public JWSHeader getJwsHeader() { + return jwsHeader; + } + + public ObjectNode getJwsHeaderAsNode() { + return JsonSerialization.mapper.convertValue(jwsHeader, ObjectNode.class); + } + + public void setJwsHeader(JWSHeader jwsHeader) { + this.jwsHeader = jwsHeader; + } + + public ObjectNode getPayload() { + return payload; + } + + public void setPayload(ObjectNode payload) { + this.payload = payload; + } + + private void parse(String jwsString) { + try { + this.jws = jwsString; + this.jwsInput = new JWSInput(Objects.requireNonNull(jwsString, "jwsString must not be null")); + this.jwsHeader = jwsInput.getHeader(); + this.payload = JsonSerialization.mapper.readValue(jwsInput.getContent(), ObjectNode.class); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/core/src/main/java/org/keycloak/sdjwt/SdJws.java b/core/src/main/java/org/keycloak/sdjwt/SdJws.java deleted file mode 100644 index 99812bb6b5a..00000000000 --- a/core/src/main/java/org/keycloak/sdjwt/SdJws.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * 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 java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Objects; - -import org.keycloak.common.VerificationException; -import org.keycloak.crypto.SignatureSignerContext; -import org.keycloak.crypto.SignatureVerifierContext; -import org.keycloak.jose.jws.JWSBuilder; -import org.keycloak.jose.jws.JWSHeader; -import org.keycloak.jose.jws.JWSInput; -import org.keycloak.jose.jws.JWSInputException; - -import com.fasterxml.jackson.databind.JsonNode; - -import static org.keycloak.OID4VCConstants.CLAIM_NAME_ISSUER; - -/** - * Handle jws, either the issuer jwt or the holder key binding jwt. - * - * @author Francis Pouatcha - * - */ -public abstract class SdJws { - - private final JWSInput jwsInput; - private final JsonNode payload; - - public String toJws() { - if (jwsInput == null) { - throw new IllegalStateException("JWS not yet signed"); - } - return jwsInput.getWireString(); - } - - public JsonNode getPayload() { - return payload; - } - - // Constructor for unsigned JWS - protected SdJws(JsonNode payload) { - this.payload = payload; - this.jwsInput = null; - } - - // Constructor from jws string with all parts - protected SdJws(String jwsString) { - this.jwsInput = parse(jwsString); - this.payload = readPayload(jwsInput); - } - - // Constructor for signed JWS - protected SdJws(JsonNode payload, JWSInput jwsInput) { - this.payload = payload; - this.jwsInput = jwsInput; - } - - protected SdJws(JsonNode payload, SignatureSignerContext signer, String jwsType) { - this.payload = payload; - this.jwsInput = sign(payload, signer, jwsType); - } - - protected static JWSInput sign(JsonNode payload, SignatureSignerContext signer, String jwsType) { - String jwsString = new JWSBuilder().type(jwsType).jsonContent(payload).sign(signer); - return parse(jwsString); - } - - public void verifySignature(SignatureVerifierContext verifier) throws VerificationException { - Objects.requireNonNull(verifier, "verifier must not be null"); - try { - if (!verifier.verify(jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jwsInput.getSignature())) { - throw new VerificationException("Invalid jws signature"); - } - } catch (Exception e) { - throw new VerificationException(e); - } - } - - private static JWSInput parse(String jwsString) { - try { - return new JWSInput(Objects.requireNonNull(jwsString, "jwsString must not be null")); - } catch (JWSInputException e) { - throw new RuntimeException(e); - } - } - - private static JsonNode readPayload(JWSInput jwsInput) { - try { - return SdJwtUtils.mapper.readTree(jwsInput.getContent()); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - public JWSHeader getHeader() { - return this.jwsInput.getHeader(); - } - - /** - * Verifies that SD-JWT was issued by one of the provided issuers. - * @param issuers List of trusted issuers - */ - public void verifyIssClaim(List issuers) throws VerificationException { - verifyClaimAgainstTrustedValues(issuers, CLAIM_NAME_ISSUER); - } - - /** - * Verifies that SD-JWT vct claim matches the expected one. - * @param vcts list of supported verifiable credential types - */ - public void verifyVctClaim(List vcts) throws VerificationException { - verifyClaimAgainstTrustedValues(vcts, "vct"); - } - - private void verifyClaimAgainstTrustedValues(List trustedValues, String claimName) - throws VerificationException { - String claimValue = SdJwtUtils.readClaim(payload, claimName); - - if (!trustedValues.contains(claimValue)) { - throw new VerificationException(String.format("Unknown '%s' claim value: %s", claimName, claimValue)); - } - } -} diff --git a/core/src/main/java/org/keycloak/sdjwt/SdJwt.java b/core/src/main/java/org/keycloak/sdjwt/SdJwt.java index 3022c17e180..2c7b4d524ee 100644 --- a/core/src/main/java/org/keycloak/sdjwt/SdJwt.java +++ b/core/src/main/java/org/keycloak/sdjwt/SdJwt.java @@ -19,20 +19,19 @@ package org.keycloak.sdjwt; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -import java.util.stream.IntStream; import org.keycloak.OID4VCConstants; import org.keycloak.common.VerificationException; import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.crypto.SignatureVerifierContext; +import org.keycloak.jose.jws.JWSHeader; import org.keycloak.sdjwt.vp.KeyBindingJWT; +import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.JsonNodeType; import com.fasterxml.jackson.databind.node.ObjectNode; import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM; @@ -44,45 +43,62 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM; */ public class SdJwt { + public static final int DEFAULT_NUMBER_OF_DECOYS = 5; + private final IssuerSignedJWT issuerSignedJWT; private final List claims; - private final List disclosures = new ArrayList<>(); - private final SdJwtVerificationContext sdJwtVerificationContext; + private final List disclosures; - private SdJwt(DisclosureSpec disclosureSpec, JsonNode claimSet, List nesteSdJwts, - Optional keyBindingJWT, - SignatureSignerContext signer, - String hashAlgorithm, - String jwsType) { - claims = new ArrayList<>(); - claimSet.fields() - .forEachRemaining(entry -> claims.add(createClaim(entry.getKey(), entry.getValue(), disclosureSpec))); + private SdJwtVerificationContext sdJwtVerificationContext; + private KeyBindingJWT keyBindingJWT; + private Optional sdJwtString = Optional.empty(); - this.issuerSignedJWT = IssuerSignedJWT.builder() - .withClaims(claims) - .withDecoyClaims(createdDecoyClaims(disclosureSpec)) - .withNestedDisclosures(!nesteSdJwts.isEmpty()) - .withSigner(signer) - .withHashAlg(hashAlgorithm) - .withJwsType(jwsType) - .build(); - - 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 - ); + public SdJwt(IssuerSignedJWT issuerSignedJWT, + KeyBindingJWT keyBindingJWT) { + this(issuerSignedJWT, keyBindingJWT, null); } - private Optional sdJwtString = Optional.empty(); + public SdJwt(IssuerSignedJWT issuerSignedJWT, + KeyBindingJWT keyBindingJWT, + List nesteSdJwts) { + this.issuerSignedJWT = issuerSignedJWT; + this.claims = issuerSignedJWT.getDisclosureClaims(); + this.keyBindingJWT = keyBindingJWT; + + this.disclosures = new ArrayList<>(); + Optional.ofNullable(nesteSdJwts).ifPresent(nestedSdJwtList -> { + nestedSdJwtList.forEach(nestedJwt -> this.disclosures.addAll(nestedJwt.getDisclosures())); + }); + this.disclosures.addAll(getDisclosureStrings(claims)); + + this.sdJwtVerificationContext = new SdJwtVerificationContext(this.issuerSignedJWT, this.disclosures); + } + + public SdJwt(ObjectNode claimSet, + KeyBindingJWT keyBindingJWT) { + this(new IssuerSignedJWT(new JWSHeader(), claimSet), keyBindingJWT, null); + } + + public SdJwt(ObjectNode claimSet, + KeyBindingJWT keyBindingJWT, + List nesteSdJwts) { + this(new IssuerSignedJWT(new JWSHeader(), claimSet), keyBindingJWT, nesteSdJwts); + } + + public SdJwt(IssuerSignedJWT issuerSignedJWT, + KeyBindingJWT keyBindingJWT, + List claims, + List disclosures) { + this.issuerSignedJWT = issuerSignedJWT; + this.keyBindingJWT = keyBindingJWT; + this.claims = claims; + this.disclosures = disclosures; + } private List createdDecoyClaims(DisclosureSpec disclosureSpec) { return disclosureSpec.getDecoyClaims().stream() - .map(disclosureData -> DecoyClaim.builder().withSalt(disclosureData.getSalt()).build()) - .collect(Collectors.toList()); + .map(disclosureData -> DecoyClaim.builder().withSalt(disclosureData.getSalt()).build()) + .collect(Collectors.toList()); } /** @@ -90,11 +106,10 @@ public class SdJwt { *

* dropping the algo claim. * - * @param nestedSdJwt * @return */ public JsonNode asNestedPayload() { - JsonNode nestedPayload = issuerSignedJWT.getPayload(); + JsonNode nestedPayload = JsonSerialization.mapper.convertValue(issuerSignedJWT.getPayload(), JsonNode.class); ((ObjectNode) nestedPayload).remove(CLAIM_NAME_SD_HASH_ALGORITHM); return nestedPayload; } @@ -102,9 +117,9 @@ public class SdJwt { public String toSdJwtString() { List parts = new ArrayList<>(); - parts.add(issuerSignedJWT.toJws()); + parts.add(issuerSignedJWT.getJws()); parts.addAll(disclosures); - parts.add(""); + parts.add(Optional.ofNullable(keyBindingJWT).map(KeyBindingJWT::getJws).orElse("")); return String.join(OID4VCConstants.SDJWT_DELIMITER, parts); } @@ -112,11 +127,39 @@ public class SdJwt { private static List getDisclosureStrings(List claims) { List disclosureStrings = new ArrayList<>(); claims.stream() - .map(SdJwtClaim::getDisclosureStrings) - .forEach(disclosureStrings::addAll); + .map(SdJwtClaim::getDisclosureStrings) + .forEach(disclosureStrings::addAll); return Collections.unmodifiableList(disclosureStrings); } + public KeyBindingJWT getKeybindingJwt() { + return keyBindingJWT; + } + + public void setKeybindingJwt(KeyBindingJWT keybindingJwt) { + this.keyBindingJWT = keybindingJwt; + } + + public List getClaims() { + return claims; + } + + public SdJwtVerificationContext getSdJwtVerificationContext() { + return sdJwtVerificationContext; + } + + public void setSdJwtVerificationContext(SdJwtVerificationContext sdJwtVerificationContext) { + this.sdJwtVerificationContext = sdJwtVerificationContext; + } + + public Optional getSdJwtString() { + return sdJwtString; + } + + public void setSdJwtString(Optional sdJwtString) { + this.sdJwtString = sdJwtString; + } + @Override public String toString() { return sdJwtString.orElseGet(() -> { @@ -126,77 +169,6 @@ public class SdJwt { }); } - private SdJwtClaim createClaim(String claimName, JsonNode claimValue, DisclosureSpec disclosureSpec) { - DisclosureSpec.DisclosureData disclosureData = disclosureSpec.getUndisclosedClaim(SdJwtClaimName.of(claimName)); - - if (disclosureData != null) { - return createUndisclosedClaim(claimName, claimValue, disclosureData.getSalt()); - } else { - return createArrayOrVisibleClaim(claimName, claimValue, disclosureSpec); - } - } - - private SdJwtClaim createUndisclosedClaim(String claimName, JsonNode claimValue, SdJwtSalt salt) { - return UndisclosedClaim.builder() - .withClaimName(claimName) - .withClaimValue(claimValue) - .withSalt(salt) - .build(); - } - - private SdJwtClaim createArrayOrVisibleClaim(String claimName, JsonNode claimValue, DisclosureSpec disclosureSpec) { - SdJwtClaimName sdJwtClaimName = SdJwtClaimName.of(claimName); - Map undisclosedArrayElts = disclosureSpec - .getUndisclosedArrayElts(sdJwtClaimName); - Map decoyArrayElts = disclosureSpec.getDecoyArrayElts(sdJwtClaimName); - - if (undisclosedArrayElts != null || decoyArrayElts != null) { - return createArrayDisclosure(claimName, claimValue, undisclosedArrayElts, decoyArrayElts); - } else { - return VisibleSdJwtClaim.builder() - .withClaimName(claimName) - .withClaimValue(claimValue) - .build(); - } - } - - private SdJwtClaim createArrayDisclosure(String claimName, JsonNode claimValue, - Map undisclosedArrayElts, - Map decoyArrayElts) { - ArrayNode arrayNode = validateArrayNode(claimName, claimValue); - ArrayDisclosure.Builder arrayDisclosureBuilder = ArrayDisclosure.builder().withClaimName(claimName); - - if (undisclosedArrayElts != null) { - IntStream.range(0, arrayNode.size()) - .forEach(i -> processArrayElement(arrayDisclosureBuilder, arrayNode.get(i), - undisclosedArrayElts.get(i))); - } - - if (decoyArrayElts != null) { - decoyArrayElts.entrySet().stream() - .forEach(e -> arrayDisclosureBuilder.withDecoyElt(e.getKey(), e.getValue().getSalt())); - } - - return arrayDisclosureBuilder.build(); - } - - private ArrayNode validateArrayNode(String claimName, JsonNode claimValue) { - return Optional.of(claimValue) - .filter(v -> v.getNodeType() == JsonNodeType.ARRAY) - .map(v -> (ArrayNode) v) - .orElseThrow( - () -> new IllegalArgumentException("Expected array for claim with name: " + claimName)); - } - - private void processArrayElement(ArrayDisclosure.Builder builder, JsonNode elementValue, - DisclosureSpec.DisclosureData disclosureData) { - if (disclosureData != null) { - builder.withUndisclosedElement(disclosureData.getSalt(), elementValue); - } else { - builder.withVisibleElement(elementValue); - } - } - public IssuerSignedJWT getIssuerSignedJWT() { return issuerSignedJWT; } @@ -208,50 +180,41 @@ public class SdJwt { /** * Verifies SD-JWT as to whether the Issuer-signed JWT's signature and disclosures are valid. * - * @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 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( - List issuerVerifyingKeys, - IssuerSignedJwtVerificationOpts verificationOpts - ) throws VerificationException { + public void verify(List issuerVerifyingKeys, + IssuerSignedJwtVerificationOpts verificationOpts) + throws VerificationException { sdJwtVerificationContext.verifyIssuance( - issuerVerifyingKeys, - verificationOpts, - null + issuerVerifyingKeys, + verificationOpts, + null ); } + public static Builder builder() { + return new Builder(); + } + // builder for SdJwt public static class Builder { - private DisclosureSpec disclosureSpec; - private JsonNode claimSet; - private Optional keyBindingJWT = Optional.empty(); - private SignatureSignerContext signer; + private final List nestedSdJwts = new ArrayList<>(); - private String hashAlgorithm; - private String jwsType; - public Builder withDisclosureSpec(DisclosureSpec disclosureSpec) { - this.disclosureSpec = disclosureSpec; + private IssuerSignedJWT issuerSignedJwt; + + private KeyBindingJWT keyBindingJWT; + + public Builder withIssuerSignedJwt(IssuerSignedJWT issuerSignedJwt) { + this.issuerSignedJwt = issuerSignedJwt; return this; } - public Builder withClaimSet(JsonNode claimSet) { - this.claimSet = claimSet; - return this; - } - - public Builder withKeyBindingJWT(KeyBindingJWT keyBindingJWT) { - this.keyBindingJWT = Optional.of(keyBindingJWT); - return this; - } - - public Builder withSigner(SignatureSignerContext signer) { - this.signer = signer; + public Builder withKeybindingJwt(KeyBindingJWT keybindingJwt) { + this.keyBindingJWT = keybindingJwt; return this; } @@ -260,22 +223,86 @@ public class SdJwt { return this; } - public Builder withHashAlgorithm(String hashAlgorithm) { - this.hashAlgorithm = hashAlgorithm; - return this; - } - - public Builder withJwsType(String jwsType) { - this.jwsType = jwsType; - return this; - } - public SdJwt build() { - return new SdJwt(disclosureSpec, claimSet, nestedSdJwts, keyBindingJWT, signer, hashAlgorithm, jwsType); + return build(true); } - } - public static Builder builder() { - return new Builder(); + public SdJwt build(boolean useDefaultDecoys) { + return build(null, null, null, useDefaultDecoys); + } + + public SdJwt build(SignatureSignerContext issuerSigningContext) { + return build(issuerSigningContext, null, null, true); + } + + public SdJwt build(SignatureSignerContext issuerSigningContext, boolean useDefaultDecoys) { + return build(issuerSigningContext, null, null, useDefaultDecoys); + } + + public SdJwt build(SignatureSignerContext issuerSigningContext, + SignatureSignerContext keybindingSigningContext) { + return build(issuerSigningContext, keybindingSigningContext, null, true); + } + + public SdJwt build(SignatureSignerContext issuerSigningContext, + SignatureSignerContext keybindingSigningContext, + boolean useDefaultDecoys) { + return build(issuerSigningContext, keybindingSigningContext, null, useDefaultDecoys); + } + + public SdJwt build(SignatureSignerContext issuerSigningContext, + SignatureSignerContext keybindingSigningContext, + String sdHashAlgorithm, + boolean useDefaultDecoys) { + int numberOfDecoys = Optional.ofNullable(issuerSignedJwt.getDecoyClaims()).map(List::size).orElse(0); + if (useDefaultDecoys && numberOfDecoys == 0) { + List decoyClaims = new ArrayList<>(); + for (int i = 0; i < DEFAULT_NUMBER_OF_DECOYS; i++) { + decoyClaims.add(DecoyClaim.builder().build()); + } + issuerSignedJwt.setDisclosureClaims(issuerSignedJwt.getDisclosureSpec(), + issuerSignedJwt.getDisclosureClaims(), + decoyClaims); + } + + SdJwt sdJwt = new SdJwt(issuerSignedJwt, keyBindingJWT, nestedSdJwts); + AtomicInteger signCounter = new AtomicInteger(0); + // add sd-hash to keybindingJwt + Optional.ofNullable(keyBindingJWT).ifPresent(keyBindJwt -> { + // get the hash-algorithm to use for keyBinding and set it if not present + String hashAlgorithm = getEffectiveHashAlgorithm(sdHashAlgorithm); + issuerSignedJwt.getPayload().put(OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM, + hashAlgorithm); + if (issuerSigningContext != null) { + issuerSignedJwt.sign(issuerSigningContext); + } + signCounter.incrementAndGet(); + // keybinding jwt is not set yet, so the toSdJwtString method returns exactly what we want + String sdHashString; + { + List parts = new ArrayList<>(); + parts.add(sdJwt.getIssuerSignedJWT().getJws()); + parts.addAll(sdJwt.getDisclosures()); + parts.add(""); + sdHashString = String.join(OID4VCConstants.SDJWT_DELIMITER, parts); + } + String sdHash = SdJwtUtils.hashAndBase64EncodeNoPad(sdHashString.getBytes(), hashAlgorithm); + keyBindJwt.getPayload().put(OID4VCConstants.SD_HASH, sdHash); + Optional.ofNullable(keybindingSigningContext).ifPresent(keyBindJwt::sign); + }); + // if issuerSignedJwt was not signed yet + if (issuerSigningContext != null && signCounter.get() == 0) { + issuerSignedJwt.sign(issuerSigningContext); + } + sdJwt.setKeybindingJwt(keyBindingJWT); + return sdJwt; + } + + private String getEffectiveHashAlgorithm(String sdHashAlgorithm) { + return Optional.ofNullable(sdHashAlgorithm).orElseGet(() -> { + // if not given as parameter, try to find the algorithm in the issuerSignedJwt payload + return issuerSignedJwt.getSdHashAlgorithm().orElse(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM); + }); + } } } diff --git a/core/src/main/java/org/keycloak/sdjwt/SdJwtClaimFactory.java b/core/src/main/java/org/keycloak/sdjwt/SdJwtClaimFactory.java new file mode 100644 index 00000000000..6664e9b3af8 --- /dev/null +++ b/core/src/main/java/org/keycloak/sdjwt/SdJwtClaimFactory.java @@ -0,0 +1,119 @@ +/* + * Copyright 2025 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 java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.IntStream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeType; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * @author Pascal Knueppel + * @since 13.11.2025 + */ +public class SdJwtClaimFactory { + + public static List parsePayload(ObjectNode objectNode, DisclosureSpec disclosureSpec) { + List claims = new ArrayList<>(); + objectNode.properties().forEach(entry -> { + claims.add(createClaim(entry.getKey(), entry.getValue(), disclosureSpec)); + }); + return claims; + } + + private static SdJwtClaim createClaim(String claimName, JsonNode claimValue, DisclosureSpec disclosureSpec) { + DisclosureSpec.DisclosureData disclosureData = disclosureSpec.getUndisclosedClaim(SdJwtClaimName.of(claimName)); + + if (disclosureData != null) { + return createUndisclosedClaim(claimName, claimValue, disclosureData.getSalt()); + } + else { + return createArrayOrVisibleClaim(claimName, claimValue, disclosureSpec); + } + } + + private static SdJwtClaim createUndisclosedClaim(String claimName, JsonNode claimValue, SdJwtSalt salt) { + return UndisclosedClaim.builder() + .withClaimName(claimName) + .withClaimValue(claimValue) + .withSalt(salt) + .build(); + } + + private static SdJwtClaim createArrayOrVisibleClaim(String claimName, JsonNode claimValue, DisclosureSpec disclosureSpec) { + SdJwtClaimName sdJwtClaimName = SdJwtClaimName.of(claimName); + Map undisclosedArrayElts = // + disclosureSpec.getUndisclosedArrayElts(sdJwtClaimName); + Map decoyArrayElts = disclosureSpec.getDecoyArrayElts(sdJwtClaimName); + + if (undisclosedArrayElts != null || decoyArrayElts != null) { + return createArrayDisclosure(claimName, claimValue, undisclosedArrayElts, decoyArrayElts); + } + else { + return VisibleSdJwtClaim.builder() + .withClaimName(claimName) + .withClaimValue(claimValue) + .build(); + } + } + + private static SdJwtClaim createArrayDisclosure(String claimName, JsonNode claimValue, + Map undisclosedArrayElts, + Map decoyArrayElts) { + ArrayNode arrayNode = validateArrayNode(claimName, claimValue); + ArrayDisclosure.Builder arrayDisclosureBuilder = ArrayDisclosure.builder().withClaimName(claimName); + + if (undisclosedArrayElts != null) { + IntStream.range(0, arrayNode.size()) + .forEach(i -> processArrayElement(arrayDisclosureBuilder, arrayNode.get(i), + undisclosedArrayElts.get(i))); + } + + if (decoyArrayElts != null) { + decoyArrayElts.forEach((key, value) -> + arrayDisclosureBuilder.withDecoyElt(key, value.getSalt())); + } + + return arrayDisclosureBuilder.build(); + } + + private static ArrayNode validateArrayNode(String claimName, JsonNode claimValue) { + return Optional.of(claimValue) + .filter(v -> v.getNodeType() == JsonNodeType.ARRAY) + .map(v -> (ArrayNode) v) + .orElseThrow( + () -> new IllegalArgumentException("Expected array for claim with name: " + claimName)); + } + + private static void processArrayElement(ArrayDisclosure.Builder builder, JsonNode elementValue, + DisclosureSpec.DisclosureData disclosureData) { + if (disclosureData != null) { + builder.withUndisclosedElement(disclosureData.getSalt(), elementValue); + } + else { + builder.withVisibleElement(elementValue); + } + } +} diff --git a/core/src/main/java/org/keycloak/sdjwt/SdJwtClaimName.java b/core/src/main/java/org/keycloak/sdjwt/SdJwtClaimName.java index 94ec3654f5e..6e7acd953e3 100644 --- a/core/src/main/java/org/keycloak/sdjwt/SdJwtClaimName.java +++ b/core/src/main/java/org/keycloak/sdjwt/SdJwtClaimName.java @@ -18,9 +18,9 @@ package org.keycloak.sdjwt; /** * Strong typing claim name to avoid parameter mismatch. - * + * * Used as map key. Beware of the hashcode and equals implementation. - * + * * @author Francis Pouatcha */ public class SdJwtClaimName { @@ -34,6 +34,10 @@ public class SdJwtClaimName { return new SdJwtClaimName(claimName); } + public String getName() { + return claimName; + } + @Override public String toString() { return claimName; diff --git a/core/src/main/java/org/keycloak/sdjwt/SdJwtFacade.java b/core/src/main/java/org/keycloak/sdjwt/SdJwtFacade.java index d74b9213f44..8a025988605 100644 --- a/core/src/main/java/org/keycloak/sdjwt/SdJwtFacade.java +++ b/core/src/main/java/org/keycloak/sdjwt/SdJwtFacade.java @@ -22,7 +22,8 @@ import org.keycloak.common.VerificationException; import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.crypto.SignatureVerifierContext; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + /** * Simplified service for creating and managing SD-JWTs with easy-to-use methods. @@ -48,14 +49,12 @@ public class SdJwtFacade { * @param disclosureSpec The disclosure specification. * @return A new SD-JWT. */ - public SdJwt createSdJwt(JsonNode claimSet, DisclosureSpec disclosureSpec) { + public SdJwt createSdJwt(ObjectNode claimSet, DisclosureSpec disclosureSpec) { + IssuerSignedJWT issuerSignedJWT = new IssuerSignedJWT(disclosureSpec, claimSet, null, + hashAlgorithm, true); return SdJwt.builder() - .withClaimSet(claimSet) - .withDisclosureSpec(disclosureSpec) - .withSigner(signer) - .withHashAlgorithm(hashAlgorithm) - .withJwsType(jwsType) - .build(); + .withIssuerSignedJwt(issuerSignedJWT) + .build(signer); } /** diff --git a/core/src/main/java/org/keycloak/sdjwt/SdJwtSalt.java b/core/src/main/java/org/keycloak/sdjwt/SdJwtSalt.java index ea67a6d2ded..5365fd60aeb 100644 --- a/core/src/main/java/org/keycloak/sdjwt/SdJwtSalt.java +++ b/core/src/main/java/org/keycloak/sdjwt/SdJwtSalt.java @@ -16,11 +16,13 @@ */ package org.keycloak.sdjwt; +import java.util.Objects; + /** * Strong typing salt to avoid parameter mismatch. - * + * * Comparable to allow sorting in SD-JWT VC. - * + * * @author Francis Pouatcha */ public class SdJwtSalt implements Comparable { @@ -44,4 +46,19 @@ public class SdJwtSalt implements Comparable { public int compareTo(SdJwtSalt o) { return salt.compareTo(o.salt); } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof SdJwtSalt)) { + return false; + } + + SdJwtSalt sdJwtSalt = (SdJwtSalt) o; + return Objects.equals(salt, sdJwtSalt.salt); + } + + @Override + public int hashCode() { + return Objects.hashCode(salt); + } } diff --git a/core/src/main/java/org/keycloak/sdjwt/SdJwtUtils.java b/core/src/main/java/org/keycloak/sdjwt/SdJwtUtils.java index 48f13980403..e7c30e97fb2 100644 --- a/core/src/main/java/org/keycloak/sdjwt/SdJwtUtils.java +++ b/core/src/main/java/org/keycloak/sdjwt/SdJwtUtils.java @@ -113,24 +113,6 @@ public class SdJwtUtils { return (ArrayNode) jsonNode; } - public static long readTimeClaim(JsonNode payload, String claimName) throws VerificationException { - JsonNode claim = payload.get(claimName); - if (claim == null || !claim.isNumber()) { - throw new VerificationException("Missing or invalid '" + claimName + "' claim"); - } - - return claim.asLong(); - } - - public static String readClaim(JsonNode payload, String claimName) throws VerificationException { - JsonNode claim = payload.get(claimName); - if (claim == null) { - throw new VerificationException("Missing '" + claimName + "' claim"); - } - - return claim.textValue(); - } - public static JsonNode deepClone(JsonNode node) { try { byte[] serializedNode = mapper.writeValueAsBytes(node); diff --git a/core/src/main/java/org/keycloak/sdjwt/SdJwtVerificationContext.java b/core/src/main/java/org/keycloak/sdjwt/SdJwtVerificationContext.java index ff5ab9721c0..b94130d4e7d 100644 --- a/core/src/main/java/org/keycloak/sdjwt/SdJwtVerificationContext.java +++ b/core/src/main/java/org/keycloak/sdjwt/SdJwtVerificationContext.java @@ -28,6 +28,7 @@ import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import org.keycloak.OID4VCConstants; import org.keycloak.common.VerificationException; import org.keycloak.crypto.SignatureVerifierContext; import org.keycloak.sdjwt.consumer.PresentationRequirements; @@ -59,14 +60,15 @@ public class SdJwtVerificationContext { private String sdJwtVpString; private final IssuerSignedJWT issuerSignedJwt; + private final Map disclosures; + private KeyBindingJWT keyBindingJwt; - public SdJwtVerificationContext( - String sdJwtVpString, - IssuerSignedJWT issuerSignedJwt, - Map disclosures, - KeyBindingJWT keyBindingJwt) { + public SdJwtVerificationContext(String sdJwtVpString, + IssuerSignedJWT issuerSignedJwt, + Map disclosures, + KeyBindingJWT keyBindingJwt) { this(issuerSignedJwt, disclosures); this.keyBindingJwt = keyBindingJwt; this.sdJwtVpString = sdJwtVpString; @@ -93,7 +95,7 @@ public class SdJwtVerificationContext { } /** - * Verifies SD-JWT as to whether the Issuer-signed JWT's signature and disclosures are valid. + * Verifies SD-JWT whether the Issuer-signed JWT's signature and disclosures are valid. * *

Upon receiving an SD-JWT, a Holder or a Verifier needs to ensure that:

* - the Issuer-signed JWT is valid, i.e., it is signed by the Issuer and the signature is valid, and @@ -108,22 +110,22 @@ public class SdJwtVerificationContext { * disclosing the Issuer-signed JWT during the verification. * @throws VerificationException if verification failed */ - public void verifyIssuance( - List issuerVerifyingKeys, - IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts, - PresentationRequirements presentationRequirements - ) throws VerificationException { + public void verifyIssuance(List issuerVerifyingKeys, + IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts, + PresentationRequirements presentationRequirements) + throws VerificationException + { // Validate the Issuer-signed JWT. validateIssuerSignedJwt(issuerVerifyingKeys); // Validate disclosures. JsonNode disclosedPayload = validateDisclosuresDigests(); - // Validate time claims. + // Validate time claims and algorithm header claim. // Issuers will typically include claims controlling the validity of the SD-JWT in plaintext in the // 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); + issuerSignedJwtVerificationOpts.verify(issuerSignedJwt); // Enforce presentation requirements. if (presentationRequirements != null) { @@ -150,12 +152,12 @@ public class SdJwtVerificationContext { * disclosing the Issuer-signed JWT during the verification. * @throws VerificationException if verification failed */ - public void verifyPresentation( - List issuerVerifyingKeys, - IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts, - KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts, - PresentationRequirements presentationRequirements - ) throws VerificationException { + public void verifyPresentation(List issuerVerifyingKeys, + IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts, + 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. if (keyBindingJwtVerificationOpts.isKeyBindingRequired() && keyBindingJwt == null) { @@ -212,9 +214,9 @@ public class SdJwtVerificationContext { * * @throws VerificationException if verification failed */ - private void validateKeyBindingJwt( - KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts - ) throws VerificationException { + private void validateKeyBindingJwt(KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts) + throws VerificationException + { // Check that the typ of the Key Binding JWT is kb+jwt validateKeyBindingJwtTyp(); @@ -234,12 +236,8 @@ public class SdJwtVerificationContext { throw new VerificationException("Key binding JWT invalid", e); } - // Check that the creation time of the Key Binding JWT is within an acceptable window. - validateKeyBindingJwtTimeClaims(keyBindingJwtVerificationOpts); - - // Determine that the Key Binding JWT is bound to the current transaction and was created - // for this Verifier (replay protection) by validating nonce and aud claims. - preventKeyBindingJwtReplay(keyBindingJwtVerificationOpts); + // Check timestamps of the keybinding-jwt and the header algorithm + keyBindingJwtVerificationOpts.verify(keyBindingJwt); // The same hash algorithm as for the Disclosures MUST be used (defined by the _sd_alg element // in the Issuer-signed JWT or the default value, as defined in Section 5.1.1). @@ -256,9 +254,9 @@ public class SdJwtVerificationContext { * @throws VerificationException if verification failed */ private void validateKeyBindingJwtTyp() throws VerificationException { - String typ = keyBindingJwt.getHeader().getType(); - if (!typ.equals(KeyBindingJWT.TYP)) { - throw new VerificationException("Key Binding JWT is not of declared typ " + KeyBindingJWT.TYP); + String typ = keyBindingJwt.getJwsHeader().getType(); + if (!OID4VCConstants.KEYBINDING_JWT_TYP.equals(typ)) { + throw new VerificationException("Key Binding JWT is not of declared typ " + OID4VCConstants.KEYBINDING_JWT_TYP); } } @@ -284,82 +282,6 @@ public class SdJwtVerificationContext { } } - /** - * Validate Issuer-Signed JWT time claims. - * - *

- * Check that the SD-JWT is valid using claims such as nbf, iat, and exp in the processed payload. - * If a required validity-controlling claim is missing, the SD-JWT MUST be rejected. - *

- * - * @throws VerificationException if verification failed - */ - private void validateIssuerSignedJwtTimeClaims( - JsonNode payload, - IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts - ) throws VerificationException { - TimeClaimVerifier timeClaimVerifier = new TimeClaimVerifier(issuerSignedJwtVerificationOpts); - - try { - timeClaimVerifier.verifyIssuedAtClaim(payload); - } catch (VerificationException e) { - throw new VerificationException("Issuer-Signed JWT: Invalid `iat` claim", e); - } - - try { - timeClaimVerifier.verifyExpirationClaim(payload); - } catch (VerificationException e) { - throw new VerificationException("Issuer-Signed JWT: Invalid `exp` claim", e); - } - - try { - timeClaimVerifier.verifyNotBeforeClaim(payload); - } catch (VerificationException e) { - throw new VerificationException("Issuer-Signed JWT: Invalid `nbf` claim", e); - } - } - - /** - * Validate key binding JWT time claims. - * - * @throws VerificationException if verification failed - */ - private void validateKeyBindingJwtTimeClaims( - KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts - ) throws VerificationException { - JsonNode kbJwtPayload = keyBindingJwt.getPayload(); - TimeClaimVerifier timeClaimVerifier = new TimeClaimVerifier(keyBindingJwtVerificationOpts); - - // Check that the creation time of the Key Binding JWT, as determined by the iat claim, - // is within an acceptable window - - try { - timeClaimVerifier.verifyIssuedAtClaim(kbJwtPayload); - } catch (VerificationException e) { - throw new VerificationException("Key binding JWT: Invalid `iat` claim", e); - } - - try { - timeClaimVerifier.verifyAge(kbJwtPayload, keyBindingJwtVerificationOpts.getAllowedMaxAge()); - } catch (VerificationException e) { - throw new VerificationException("Key binding JWT is too old"); - } - - // Check other time claims - - try { - timeClaimVerifier.verifyExpirationClaim(kbJwtPayload); - } catch (VerificationException e) { - throw new VerificationException("Key binding JWT: Invalid `exp` claim", e); - } - - try { - timeClaimVerifier.verifyNotBeforeClaim(kbJwtPayload); - } catch (VerificationException e) { - throw new VerificationException("Key binding JWT: Invalid `nbf` claim", e); - } - } - /** * Validate disclosures' digests * @@ -634,32 +556,6 @@ public class SdJwtVerificationContext { } } - /** - * Run checks for replay protection. - * - *

- * Determine that the Key Binding JWT is bound to the current transaction and was created for this - * Verifier (replay protection) by validating nonce and aud claims. - *

- * - * @throws VerificationException if verification failed - */ - private void preventKeyBindingJwtReplay( - KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts - ) throws VerificationException { - JsonNode nonce = keyBindingJwt.getPayload().get("nonce"); - if (nonce == null || !nonce.isTextual() - || !nonce.asText().equals(keyBindingJwtVerificationOpts.getNonce())) { - throw new VerificationException("Key binding JWT: Unexpected `nonce` value"); - } - - JsonNode aud = keyBindingJwt.getPayload().get("aud"); - if (aud == null || !aud.isTextual() - || !aud.asText().equals(keyBindingJwtVerificationOpts.getAud())) { - throw new VerificationException("Key binding JWT: Unexpected `aud` value"); - } - } - /** * Validate integrity of Key Binding JWT's sd_hash. * diff --git a/core/src/main/java/org/keycloak/sdjwt/TimeClaimVerificationOpts.java b/core/src/main/java/org/keycloak/sdjwt/TimeClaimVerificationOpts.java deleted file mode 100644 index 3571c66311a..00000000000 --- a/core/src/main/java/org/keycloak/sdjwt/TimeClaimVerificationOpts.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2025 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 static org.keycloak.OID4VCConstants.SD_JWT_DEFAULT_CLOCK_SKEW_SECONDS; - -/** - * Options for validating common time claims during SD-JWT verification. - * - * @author Ingrid Kamga - */ -public class TimeClaimVerificationOpts { - - // These options configure whether the respective time claims must be present - // during validation. They will always be validated if present. - - private final boolean requireIssuedAtClaim; - private final boolean requireExpirationClaim; - private final boolean requireNotBeforeClaim; - - /** - * Tolerance window to account for clock skew when checking time claims - */ - private final int allowedClockSkewSeconds; - - protected TimeClaimVerificationOpts( - boolean requireIssuedAtClaim, - boolean requireExpirationClaim, - boolean validateNotBeforeClaim, - int allowedClockSkewSeconds - ) { - this.requireIssuedAtClaim = requireIssuedAtClaim; - this.requireExpirationClaim = requireExpirationClaim; - this.requireNotBeforeClaim = validateNotBeforeClaim; - this.allowedClockSkewSeconds = allowedClockSkewSeconds; - } - - public boolean mustRequireIssuedAtClaim() { - return requireIssuedAtClaim; - } - - public boolean mustRequireExpirationClaim() { - return requireExpirationClaim; - } - - public boolean mustRequireNotBeforeClaim() { - return requireNotBeforeClaim; - } - - public int getAllowedClockSkewSeconds() { - return allowedClockSkewSeconds; - } - - public static > Builder builder() { - return new Builder<>(); - } - - public static class Builder> { - - protected boolean requireIssuedAtClaim = true; - protected boolean requireExpirationClaim = true; - protected boolean requireNotBeforeClaim = true; - protected int allowedClockSkewSeconds = SD_JWT_DEFAULT_CLOCK_SKEW_SECONDS; - - @SuppressWarnings("unchecked") - public T withRequireIssuedAtClaim(boolean requireIssuedAtClaim) { - this.requireIssuedAtClaim = requireIssuedAtClaim; - return (T) this; - } - - @SuppressWarnings("unchecked") - public T withRequireExpirationClaim(boolean requireExpirationClaim) { - this.requireExpirationClaim = requireExpirationClaim; - return (T) this; - } - - @SuppressWarnings("unchecked") - public T withRequireNotBeforeClaim(boolean requireNotBeforeClaim) { - this.requireNotBeforeClaim = requireNotBeforeClaim; - return (T) this; - } - - @SuppressWarnings("unchecked") - public T withAllowedClockSkew(int allowedClockSkewSeconds) { - this.allowedClockSkewSeconds = allowedClockSkewSeconds; - return (T) this; - } - - public TimeClaimVerificationOpts build() { - return new TimeClaimVerificationOpts( - requireIssuedAtClaim, - requireExpirationClaim, - requireNotBeforeClaim, - allowedClockSkewSeconds - ); - } - } -} diff --git a/core/src/main/java/org/keycloak/sdjwt/TimeClaimVerifier.java b/core/src/main/java/org/keycloak/sdjwt/TimeClaimVerifier.java deleted file mode 100644 index 4bde106285f..00000000000 --- a/core/src/main/java/org/keycloak/sdjwt/TimeClaimVerifier.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2025 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 java.time.Instant; - -import org.keycloak.common.VerificationException; - -import com.fasterxml.jackson.databind.JsonNode; - -import static org.keycloak.OID4VCConstants.CLAIM_NAME_EXP; -import static org.keycloak.OID4VCConstants.CLAIM_NAME_IAT; -import static org.keycloak.OID4VCConstants.CLAIM_NAME_NBF; - -/** - * Module for checking the validity of JWT time claims. - * All checks account for a small window to accommodate allowed clock skew. - * - * @author Ingrid Kamga - */ -public class TimeClaimVerifier { - - private final TimeClaimVerificationOpts opts; - - public TimeClaimVerifier(TimeClaimVerificationOpts opts) { - if (opts.getAllowedClockSkewSeconds() < 0) { - throw new IllegalArgumentException("Allowed clock skew seconds cannot be negative"); - } - - this.opts = opts; - } - - /** - * Validates that JWT was not issued in the future - * - * @param jwtPayload the JWT's payload - */ - public void verifyIssuedAtClaim(JsonNode jwtPayload) throws VerificationException { - if (!jwtPayload.hasNonNull(CLAIM_NAME_IAT)) { - if (opts.mustRequireIssuedAtClaim()) { - throw new VerificationException("Missing 'iat' claim or null"); - } - - return; // Not required, skipping check - } - - long iat = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_IAT); - - if ((currentTimestamp() + opts.getAllowedClockSkewSeconds()) < iat) { - throw new VerificationException("JWT was issued in the future"); - } - } - - /** - * Validates that JWT has not expired - * - * @param jwtPayload the JWT's payload - */ - public void verifyExpirationClaim(JsonNode jwtPayload) throws VerificationException { - if (!jwtPayload.hasNonNull(CLAIM_NAME_EXP)) { - if (opts.mustRequireExpirationClaim()) { - throw new VerificationException("Missing 'exp' claim or null"); - } - - return; // Not required, skipping check - } - - long exp = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_EXP); - - if ((currentTimestamp() - opts.getAllowedClockSkewSeconds()) >= exp) { - throw new VerificationException("JWT has expired"); - } - } - - /** - * Validates that JWT can yet be processed - * - * @param jwtPayload the JWT's payload - */ - public void verifyNotBeforeClaim(JsonNode jwtPayload) throws VerificationException { - if (!jwtPayload.hasNonNull(CLAIM_NAME_NBF)) { - if (opts.mustRequireNotBeforeClaim()) { - throw new VerificationException("Missing 'nbf' claim or null"); - } - - return; // Not required, skipping check - } - - long nbf = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_NBF); - - if ((currentTimestamp() + opts.getAllowedClockSkewSeconds()) < nbf) { - throw new VerificationException("JWT is not yet valid"); - } - } - - /** - * Validates that JWT is not too old - * - * @param jwtPayload the JWT's payload - * @param maxAge maximum allowed age in seconds - */ - public void verifyAge(JsonNode jwtPayload, int maxAge) throws VerificationException { - long iat = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_IAT); - - if ((currentTimestamp() - iat - opts.getAllowedClockSkewSeconds()) > maxAge) { - throw new VerificationException("JWT is too old"); - } - } - - /** - * Returns current timestamp in seconds. - */ - public long currentTimestamp() { - return Instant.now().getEpochSecond(); - } -} diff --git a/core/src/main/java/org/keycloak/sdjwt/UndisclosedArrayElement.java b/core/src/main/java/org/keycloak/sdjwt/UndisclosedArrayElement.java index acf81289eb9..8685a9090d6 100644 --- a/core/src/main/java/org/keycloak/sdjwt/UndisclosedArrayElement.java +++ b/core/src/main/java/org/keycloak/sdjwt/UndisclosedArrayElement.java @@ -23,7 +23,7 @@ import com.fasterxml.jackson.databind.JsonNode; import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_UNDISCLOSED_ARRAY; /** - * + * * @author Francis Pouatcha */ public class UndisclosedArrayElement extends Disclosable implements SdJwtArrayElement { @@ -44,6 +44,26 @@ public class UndisclosedArrayElement extends Disclosable implements SdJwtArrayEl return new Object[] { getSaltAsString(), arrayElement }; } + @Override + public boolean equals(Object o) { + if (!(o instanceof UndisclosedArrayElement)) { + return false; + } + if (!super.equals(o)) { + return false; + } + + UndisclosedArrayElement that = (UndisclosedArrayElement) o; + return Objects.equals(arrayElement, that.arrayElement); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(arrayElement); + return result; + } + public static class Builder { private SdJwtSalt salt; private JsonNode arrayElement; diff --git a/core/src/main/java/org/keycloak/sdjwt/UndisclosedClaim.java b/core/src/main/java/org/keycloak/sdjwt/UndisclosedClaim.java index cc8744b38a8..fdb0b016d5e 100644 --- a/core/src/main/java/org/keycloak/sdjwt/UndisclosedClaim.java +++ b/core/src/main/java/org/keycloak/sdjwt/UndisclosedClaim.java @@ -23,7 +23,7 @@ import java.util.Objects; import com.fasterxml.jackson.databind.JsonNode; /** - * + * * @author Francis Pouatcha */ public class UndisclosedClaim extends Disclosable implements SdJwtClaim { @@ -59,6 +59,28 @@ public class UndisclosedClaim extends Disclosable implements SdJwtClaim { throw new UnsupportedOperationException("Unimplemented method 'getVisibleClaimValue'"); } + @Override + public boolean equals(Object o) { + if (!(o instanceof UndisclosedClaim)) { + return false; + } + if (!super.equals(o)) { + return false; + } + + UndisclosedClaim that = (UndisclosedClaim) o; + return Objects.equals(claimName, that.claimName) &&// + Objects.equals(claimValue, that.claimValue); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(claimName); + result = 31 * result + Objects.hashCode(claimValue); + return result; + } + public static class Builder { private SdJwtClaimName claimName; private SdJwtSalt salt; diff --git a/core/src/main/java/org/keycloak/sdjwt/VisibleArrayElement.java b/core/src/main/java/org/keycloak/sdjwt/VisibleArrayElement.java index 5e7a03ad021..2a4b6235e86 100644 --- a/core/src/main/java/org/keycloak/sdjwt/VisibleArrayElement.java +++ b/core/src/main/java/org/keycloak/sdjwt/VisibleArrayElement.java @@ -16,10 +16,12 @@ */ package org.keycloak.sdjwt; +import java.util.Objects; + import com.fasterxml.jackson.databind.JsonNode; /** - * + * * @author Francis Pouatcha */ public class VisibleArrayElement implements SdJwtArrayElement { @@ -38,4 +40,19 @@ public class VisibleArrayElement implements SdJwtArrayElement { public String getDisclosureString() { return null; } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof VisibleArrayElement)) { + return false; + } + + VisibleArrayElement that = (VisibleArrayElement) o; + return Objects.equals(arrayElement, that.arrayElement); + } + + @Override + public int hashCode() { + return Objects.hashCode(arrayElement); + } } diff --git a/core/src/main/java/org/keycloak/sdjwt/VisibleSdJwtClaim.java b/core/src/main/java/org/keycloak/sdjwt/VisibleSdJwtClaim.java index f36ca80e656..913c64a6d91 100644 --- a/core/src/main/java/org/keycloak/sdjwt/VisibleSdJwtClaim.java +++ b/core/src/main/java/org/keycloak/sdjwt/VisibleSdJwtClaim.java @@ -23,7 +23,7 @@ import java.util.Objects; import com.fasterxml.jackson.databind.JsonNode; /** - * + * * @author Francis Pouatcha */ public class VisibleSdJwtClaim extends AbstractSdJwtClaim { @@ -39,6 +39,26 @@ public class VisibleSdJwtClaim extends AbstractSdJwtClaim { return claimValue; } + @Override + public boolean equals(Object o) { + if (!(o instanceof VisibleSdJwtClaim)) { + return false; + } + if (!super.equals(o)) { + return false; + } + + VisibleSdJwtClaim that = (VisibleSdJwtClaim) o; + return Objects.equals(claimValue, that.claimValue); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(claimValue); + return result; + } + // Static method to create a builder instance public static Builder builder() { return new Builder(); diff --git a/core/src/main/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuer.java b/core/src/main/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuer.java index c6f3ff26ae2..e97d238b200 100644 --- a/core/src/main/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuer.java +++ b/core/src/main/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuer.java @@ -90,7 +90,7 @@ public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer { String iss = Optional.ofNullable(issuerSignedJWT.getPayload().get(CLAIM_NAME_ISSUER)) .map(JsonNode::asText) .orElse(""); - String kid = issuerSignedJWT.getHeader().getKeyId(); + String kid = issuerSignedJWT.getJwsHeader().getKeyId(); // Match the read iss claim against the trusted pattern Matcher matcher = issuerUriPattern.matcher(iss); diff --git a/core/src/main/java/org/keycloak/sdjwt/vp/KeyBindingJWT.java b/core/src/main/java/org/keycloak/sdjwt/vp/KeyBindingJWT.java index 9c70d17bf0c..136908185de 100644 --- a/core/src/main/java/org/keycloak/sdjwt/vp/KeyBindingJWT.java +++ b/core/src/main/java/org/keycloak/sdjwt/vp/KeyBindingJWT.java @@ -16,38 +16,147 @@ */ package org.keycloak.sdjwt.vp; -import org.keycloak.crypto.SignatureSignerContext; -import org.keycloak.jose.jws.JWSInput; -import org.keycloak.sdjwt.SdJws; +import java.security.cert.Certificate; +import java.util.List; +import java.util.Optional; -import com.fasterxml.jackson.databind.JsonNode; +import org.keycloak.OID4VCConstants; +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.representations.IDToken; +import org.keycloak.sdjwt.JwsToken; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; /** * * @author Francis Pouatcha * */ -public class KeyBindingJWT extends SdJws { +public class KeyBindingJWT extends JwsToken { - public static final String TYP = "kb+jwt"; - - public KeyBindingJWT(JsonNode payload, SignatureSignerContext signer, String jwsType) { - super(payload, signer, jwsType); - } - - public static KeyBindingJWT of(String jwsString) { - return new KeyBindingJWT(jwsString); - } - - public static KeyBindingJWT from(JsonNode payload, SignatureSignerContext signer, String jwsType) { - return new KeyBindingJWT(payload, signer, jwsType); - } - - private KeyBindingJWT(JsonNode payload, JWSInput jwsInput) { - super(payload, jwsInput); - } - - private KeyBindingJWT(String jwsString) { + public KeyBindingJWT(String jwsString) { super(jwsString); } + + public KeyBindingJWT(ObjectNode payload, SignatureSignerContext signer) { + this(new JWSHeader(), payload, signer); + } + + public KeyBindingJWT(ObjectNode payload) { + this(new JWSHeader(), payload, null); + } + + public KeyBindingJWT(JWSHeader jwsHeader, ObjectNode payload) { + this(jwsHeader, payload, null); + } + + public KeyBindingJWT(JWSHeader jwsHeader, ObjectNode payload, SignatureSignerContext signer) { + super(jwsHeader, payload); + getJwsHeader().setType(OID4VCConstants.KEYBINDING_JWT_TYP); + Optional.ofNullable(signer).ifPresent(this::sign); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + protected JWSHeader jwsHeader; + + protected ObjectNode payload; + + public Builder() { + this.jwsHeader = new JWSHeader(); + this.payload = JsonNodeFactory.instance.objectNode(); + } + + private JWSHeader getJwsHeader() { + if (jwsHeader == null) { + this.jwsHeader = new JWSHeader(); + } + return jwsHeader; + } + + private ObjectNode getPayload() { + if (payload == null) { + this.payload = JsonNodeFactory.instance.objectNode(); + } + return payload; + } + + public Builder withJwsHeader(JWSHeader jwsHeader) { + this.jwsHeader = jwsHeader; + return this; + } + + public Builder withPayload(ObjectNode payload) { + this.payload = payload; + return this; + } + + public Builder withIat(long iat) + { + getPayload().put(OID4VCConstants.CLAIM_NAME_IAT, iat); + return this; + } + + public Builder withNbf(long nbf) + { + getPayload().put(OID4VCConstants.CLAIM_NAME_NBF, nbf); + return this; + } + + public Builder withExp(long exp) + { + getPayload().put(OID4VCConstants.CLAIM_NAME_EXP, exp); + return this; + } + + public Builder withNonce(String nonce) + { + getPayload().put(IDToken.NONCE, nonce); + return this; + } + + public Builder withAudience(String aud) + { + getPayload().put(IDToken.AUD, aud); + return this; + } + + public Builder withKid(String kid) + { + getJwsHeader().setKeyId(kid); + return this; + } + + public Builder withX5c(List x5c) + { + getJwsHeader().setX5c(x5c); + return this; + } + + public Builder withX5c(String x5c) + { + getJwsHeader().addX5c(x5c); + return this; + } + + public Builder withX5c(Certificate x5c) + { + getJwsHeader().addX5c(x5c); + return this; + } + + public KeyBindingJWT build() { + return new KeyBindingJWT(jwsHeader, payload); + } + + public KeyBindingJWT build(SignatureSignerContext signer) { + return new KeyBindingJWT(jwsHeader, payload, signer); + } + } } diff --git a/core/src/main/java/org/keycloak/sdjwt/vp/KeyBindingJwtVerificationOpts.java b/core/src/main/java/org/keycloak/sdjwt/vp/KeyBindingJwtVerificationOpts.java index 81d4737a4eb..91a8c018529 100644 --- a/core/src/main/java/org/keycloak/sdjwt/vp/KeyBindingJwtVerificationOpts.java +++ b/core/src/main/java/org/keycloak/sdjwt/vp/KeyBindingJwtVerificationOpts.java @@ -17,16 +17,22 @@ package org.keycloak.sdjwt.vp; -import org.keycloak.sdjwt.TimeClaimVerificationOpts; +import java.util.List; +import java.util.Optional; -import static org.keycloak.OID4VCConstants.SD_JWT_KEY_BINDING_DEFAULT_ALLOWED_MAX_AGE; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.sdjwt.ClaimVerifier; +import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts; + +import com.fasterxml.jackson.databind.node.ObjectNode; /** * Options for Key Binding JWT verification. * * @author Ingrid Kamga */ -public class KeyBindingJwtVerificationOpts extends TimeClaimVerificationOpts { +public class KeyBindingJwtVerificationOpts extends IssuerSignedJwtVerificationOpts { /** * Specifies the Verifier's policy whether to check Key Binding @@ -38,23 +44,13 @@ public class KeyBindingJwtVerificationOpts extends TimeClaimVerificationOpts { */ private final int allowedMaxAge; - private final String nonce; - private final String aud; - - private KeyBindingJwtVerificationOpts( - boolean keyBindingRequired, - int allowedMaxAge, - String nonce, - String aud, - boolean validateExpirationClaim, - boolean validateNotBeforeClaim, - int allowClockSkewSeconds - ) { - super(true, validateExpirationClaim, validateNotBeforeClaim, allowClockSkewSeconds); + public KeyBindingJwtVerificationOpts(boolean keyBindingRequired, + int allowedMaxAge, + List> headerVerifiers, + List> contentVerifiers) { + super(headerVerifiers, contentVerifiers); this.keyBindingRequired = keyBindingRequired; this.allowedMaxAge = allowedMaxAge; - this.nonce = nonce; - this.aud = aud; } public boolean isKeyBindingRequired() { @@ -65,69 +61,118 @@ public class KeyBindingJwtVerificationOpts extends TimeClaimVerificationOpts { return allowedMaxAge; } - public String getNonce() { - return nonce; + public static KeyBindingJwtVerificationOpts.Builder builder() { + return new KeyBindingJwtVerificationOpts.Builder(); } - public String getAud() { - return aud; + public static KeyBindingJwtVerificationOpts.Builder builder(Integer clockSkew) { + return new KeyBindingJwtVerificationOpts.Builder(clockSkew); } - public static Builder builder() { - return new Builder(); - } - - public static class Builder extends TimeClaimVerificationOpts.Builder { - + public static class Builder extends IssuerSignedJwtVerificationOpts.Builder { private boolean keyBindingRequired = true; - protected boolean validateIssuedAtClaim = true; - private int allowedMaxAge = SD_JWT_KEY_BINDING_DEFAULT_ALLOWED_MAX_AGE; - private String nonce; - private String aud; + + public Builder() { + super(); + } + + public Builder(Integer clockSkew) { + super(clockSkew); + } public Builder withKeyBindingRequired(boolean keyBindingRequired) { this.keyBindingRequired = keyBindingRequired; return this; } - public Builder withAllowedMaxAge(int allowedMaxAge) { - this.allowedMaxAge = allowedMaxAge; - return this; + public KeyBindingJwtVerificationOpts.Builder withNonceCheck(String expectedNonce) { + return withClaimCheck(IDToken.NONCE, expectedNonce, true); } - public Builder withNonce(String nonce) { - this.nonce = nonce; - return this; + @Override + public KeyBindingJwtVerificationOpts.Builder withAudCheck(String expectedAud) { + return (Builder) super.withAudCheck(expectedAud); } - public Builder withAud(String aud) { - this.aud = aud; - return this; + @Override + public KeyBindingJwtVerificationOpts.Builder withIatCheck(Integer allowedMaxAge) { + return (Builder) super.withIatCheck(allowedMaxAge); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withIatCheck(boolean isCheckOptional) { + return (Builder) super.withIatCheck(isCheckOptional); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withIatCheck(Integer allowedMaxAge, boolean isCheckOptional) { + return (Builder) super.withIatCheck(allowedMaxAge, isCheckOptional); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withNbfCheck() { + return (Builder) super.withNbfCheck(); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withNbfCheck(boolean isCheckOptional) { + return (Builder) super.withNbfCheck(isCheckOptional); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withExpCheck() { + return (Builder) super.withExpCheck(); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withExpCheck(boolean isCheckOptional) { + return (Builder) super.withExpCheck(isCheckOptional); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withClockSkew(int clockSkew) { + return (Builder) super.withClockSkew(clockSkew); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withClaimCheck(String claimName, String expectedValue) { + return (Builder) super.withClaimCheck(claimName, expectedValue); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withClaimCheck(String claimName, String expectedValue, boolean isOptionalCheck) { + return (Builder) super.withClaimCheck(claimName, expectedValue, isOptionalCheck); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder withContentVerifiers(List> contentVerifiers) { + return (Builder) super.withContentVerifiers(contentVerifiers); + } + + @Override + public KeyBindingJwtVerificationOpts.Builder addContentVerifiers(List> contentVerifiers) { + return (Builder) super.addContentVerifiers(contentVerifiers); } @Override public KeyBindingJwtVerificationOpts build() { - if (!validateIssuedAtClaim) { - throw new IllegalArgumentException( - "Validating `iat` claim cannot be disabled for KB-JWT verification because mandated" - ); + boolean isAudCheckPresent = contentVerifiers.stream().anyMatch(verifier -> { + return verifier instanceof AudienceCheck || + (verifier instanceof ClaimCheck && ((ClaimCheck) verifier).getClaimName().equals(JsonWebToken.AUD)); + }); + boolean isNonceCheckPresent = contentVerifiers.stream().anyMatch(verifier -> { + return verifier instanceof ClaimCheck && ((ClaimCheck) verifier).getClaimName().equals(IDToken.NONCE) + && Optional.ofNullable(((ClaimCheck) verifier).getExpectedClaimValue()).map(s -> !s.isEmpty()) + .orElse(false); + }); + if (keyBindingRequired && (!isAudCheckPresent || !isNonceCheckPresent)) { + throw new IllegalArgumentException("Missing `nonce` and `aud` claims for replay protection"); } - if (keyBindingRequired && (aud == null || nonce == null || nonce.isEmpty())) { - throw new IllegalArgumentException( - "Missing `nonce` and `aud` claims for replay protection" - ); - } - - return new KeyBindingJwtVerificationOpts( - keyBindingRequired, - allowedMaxAge, - nonce, - aud, - requireExpirationClaim, - requireNotBeforeClaim, - allowedClockSkewSeconds - ); + return new KeyBindingJwtVerificationOpts(keyBindingRequired, + allowedMaxAge, + headerVerifiers, + contentVerifiers); } } } diff --git a/core/src/main/java/org/keycloak/sdjwt/vp/SdJwtVP.java b/core/src/main/java/org/keycloak/sdjwt/vp/SdJwtVP.java index 384ff717115..f8182e83672 100644 --- a/core/src/main/java/org/keycloak/sdjwt/vp/SdJwtVP.java +++ b/core/src/main/java/org/keycloak/sdjwt/vp/SdJwtVP.java @@ -131,12 +131,12 @@ public class SdJwtVP { disclosuresString = sdJwtString.substring(disclosureStart + 1, disclosureEnd); } - IssuerSignedJWT issuerSignedJWT = IssuerSignedJWT.fromJws(issuerSignedJWTString); + IssuerSignedJWT issuerSignedJWT = new IssuerSignedJWT(issuerSignedJWTString); - ObjectNode issuerPayload = (ObjectNode) issuerSignedJWT.getPayload(); + ObjectNode issuerPayload = issuerSignedJWT.getPayload(); String hashAlgorithm = Optional.ofNullable(issuerPayload.get(CLAIM_NAME_SD_HASH_ALGORITHM)) - .map(JsonNode::asText) - .orElse(JavaAlgorithm.SHA256.toLowerCase()); + .map(JsonNode::asText) + .orElse(JavaAlgorithm.SHA256.toLowerCase()); Map claims = new HashMap<>(); Map disclosures = new HashMap<>(); @@ -163,16 +163,15 @@ public class SdJwtVP { Map recursiveDigests = new HashMap<>(); List ghostDigests = new ArrayList<>(); - allDigests.stream() - .forEach(disclosureDigest -> { - JsonNode node = findNode(issuerPayload, disclosureDigest); - node = processDisclosureDigest(node, disclosureDigest, claims, recursiveDigests, ghostDigests); - }); + allDigests.forEach(disclosureDigest -> { + JsonNode node = findNode(issuerPayload, disclosureDigest); + processDisclosureDigest(node, disclosureDigest, claims, recursiveDigests, ghostDigests); + }); Optional keyBindingJWT = Optional.empty(); if (sdJwtString.length() > disclosureEnd + 1) { String keyBindingJWTString = sdJwtString.substring(disclosureEnd + 1); - keyBindingJWT = Optional.of(KeyBindingJWT.of(keyBindingJWTString)); + keyBindingJWT = Optional.of(new KeyBindingJWT(keyBindingJWTString)); } // Drop the key binding String if any. As it is held by the keyBindingJwtObject @@ -210,14 +209,15 @@ public class SdJwtVP { return issuerSignedJWT.getCnfClaim().orElse(null); } - public String present(List disclosureDigests, JsonNode keyBindingClaims, - SignatureSignerContext holdSignatureSignerContext, String jwsType) { + public String present(List disclosureDigests, + ObjectNode keyBindingClaims, + SignatureSignerContext holdSignatureSignerContext) { StringBuilder sb = new StringBuilder(); if (disclosureDigests == null || disclosureDigests.isEmpty()) { // disclose everything sb.append(sdJwtVpString); } else { - sb.append(issuerSignedJWT.toJws()); + sb.append(issuerSignedJWT.getJws()); sb.append(SDJWT_DELIMITER); for (String disclosureDigest : disclosureDigests) { sb.append(disclosures.get(disclosureDigest)); @@ -229,9 +229,9 @@ public class SdJwtVP { return unboundPresentation; } String sd_hash = SdJwtUtils.hashAndBase64EncodeNoPad(unboundPresentation.getBytes(), getHashAlgorithm()); - keyBindingClaims = ((ObjectNode) keyBindingClaims).put(SD_HASH, sd_hash); - KeyBindingJWT keyBindingJWT = KeyBindingJWT.from(keyBindingClaims, holdSignatureSignerContext, jwsType); - sb.append(keyBindingJWT.toJws()); + keyBindingClaims.put(SD_HASH, sd_hash); + KeyBindingJWT keyBindingJWT = new KeyBindingJWT(keyBindingClaims, holdSignatureSignerContext); + sb.append(keyBindingJWT.getJws()); return sb.toString(); } @@ -247,11 +247,11 @@ public class SdJwtVP { * to check Key Binding. * @throws VerificationException if verification failed */ - public void verify( - List issuerVerifyingKeys, - IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts, - KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts - ) throws VerificationException { + public void verify(List issuerVerifyingKeys, + IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts, + KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts) + throws VerificationException + { sdJwtVerificationContext.verifyPresentation( issuerVerifyingKeys, issuerSignedJwtVerificationOpts, diff --git a/core/src/test/java/org/keycloak/sdjwt/ArrayElementDisclosureTest.java b/core/src/test/java/org/keycloak/sdjwt/ArrayElementDisclosureTest.java index a10738d23aa..8c257d563cb 100644 --- a/core/src/test/java/org/keycloak/sdjwt/ArrayElementDisclosureTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/ArrayElementDisclosureTest.java @@ -17,6 +17,7 @@ package org.keycloak.sdjwt; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -28,7 +29,7 @@ public class ArrayElementDisclosureTest { @Test public void testSdJwtWithUndiclosedArrayElements6_1() { - JsonNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); + ObjectNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); DisclosureSpec disclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("email", "JnwGqRFZjMprsoZobherdQ") @@ -38,10 +39,10 @@ public class ArrayElementDisclosureTest { .withUndisclosedArrayElt("nationalities", 1, "nPuoQnkRFq3BIeAm7AnXFA") .build(); + IssuerSignedJWT issuerSignedJWT = new IssuerSignedJWT(disclosureSpec, claimSet); SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(claimSet) - .build(); + .withIssuerSignedJwt(issuerSignedJWT) + .build(false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); @@ -52,7 +53,7 @@ public class ArrayElementDisclosureTest { @Test public void testSdJwtWithUndiclosedAndDecoyArrayElements6_1() { - JsonNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); + ObjectNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); DisclosureSpec disclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("email", "JnwGqRFZjMprsoZobherdQ") @@ -64,10 +65,10 @@ public class ArrayElementDisclosureTest { .withDecoyArrayElt("nationalities", 1, "5bPs1IquZNa0hkaFzzzZNw") .build(); + IssuerSignedJWT issuerSignedJWT = new IssuerSignedJWT(disclosureSpec, claimSet); SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(claimSet) - .build(); + .withIssuerSignedJwt(issuerSignedJWT) + .build(false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); JsonNode expected = TestUtils.readClaimSet(getClass(), diff --git a/core/src/test/java/org/keycloak/sdjwt/ClaimVerifierTest.java b/core/src/test/java/org/keycloak/sdjwt/ClaimVerifierTest.java new file mode 100644 index 00000000000..948fe710442 --- /dev/null +++ b/core/src/test/java/org/keycloak/sdjwt/ClaimVerifierTest.java @@ -0,0 +1,83 @@ +/* + * Copyright 2025 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 org.keycloak.common.VerificationException; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Assert; +import org.junit.Test; + +/** + * @author Pascal Knueppel + * @since 17.11.2025 + */ +public class ClaimVerifierTest { + + @Test + public void testValidateEmptyHeader() { + ClaimVerifier claimVerifier = ClaimVerifier.builder().build(); + try { + claimVerifier.verifyHeaderClaims(JsonNodeFactory.instance.objectNode()); + Assert.fail("Should have failed with message"); + } catch (VerificationException e) { + Assert.assertEquals("Missing claim 'alg' in token", e.getMessage()); + } + } + + @Test + public void testValidateNoneHeader1() { + ClaimVerifier claimVerifier = ClaimVerifier.builder().build(); + ObjectNode header = JsonNodeFactory.instance.objectNode(); + header.put("alg", "none"); + try { + claimVerifier.verifyHeaderClaims(header); + Assert.fail("Should have failed with message"); + } catch (VerificationException e) { + Assert.assertEquals("Value 'none' is not allowed for claim 'alg'!", e.getMessage()); + } + } + + @Test + public void testValidateNoneHeader2() { + ClaimVerifier claimVerifier = ClaimVerifier.builder().build(); + ObjectNode header = JsonNodeFactory.instance.objectNode(); + header.put("alg", "NONE"); + try { + claimVerifier.verifyHeaderClaims(header); + Assert.fail("Should have failed with message"); + } catch (VerificationException e) { + Assert.assertEquals("Value 'NONE' is not allowed for claim 'alg'!", e.getMessage()); + } + } + + @Test + public void testValidateNoneHeader3() { + ClaimVerifier claimVerifier = ClaimVerifier.builder().build(); + ObjectNode header = JsonNodeFactory.instance.objectNode(); + header.put("alg", "NonE"); + try { + claimVerifier.verifyHeaderClaims(header); + Assert.fail("Should have failed with message"); + } catch (VerificationException e) { + Assert.assertEquals("Value 'NonE' is not allowed for claim 'alg'!", e.getMessage()); + } + } +} diff --git a/core/src/test/java/org/keycloak/sdjwt/IssuerSignedJWTTest.java b/core/src/test/java/org/keycloak/sdjwt/IssuerSignedJWTTest.java index 13dcb29e7c1..8e8c9a3f69a 100644 --- a/core/src/test/java/org/keycloak/sdjwt/IssuerSignedJWTTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/IssuerSignedJWTTest.java @@ -26,7 +26,6 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; /** * @author Francis Pouatcha @@ -35,17 +34,17 @@ public class IssuerSignedJWTTest { /** * If issuer decides to disclose everything, paylod of issuer signed JWT should * be same as the claim set. - * + * * This is essential for backward compatibility with non sd based jwt issuance. - * + * * @throws IOException */ @Test public void testIssuerSignedJWTPayloadWithValidClaims() { - JsonNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); + ObjectNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); List claims = new ArrayList<>(); - claimSet.fields().forEachRemaining(entry -> { + claimSet.properties().forEach(entry -> { claims.add( VisibleSdJwtClaim.builder().withClaimName(entry.getKey()).withClaimValue(entry.getValue()).build()); }); @@ -57,24 +56,24 @@ public class IssuerSignedJWTTest { @Test public void testIssuerSignedJWTPayloadThrowsExceptionForDuplicateClaims() throws IOException { - JsonNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); + ObjectNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); List claims = new ArrayList<>(); // First fill claims - claimSet.fields().forEachRemaining(entry -> { + claimSet.properties().forEach(entry -> { claims.add( VisibleSdJwtClaim.builder().withClaimName(entry.getKey()).withClaimValue(entry.getValue()).build()); }); // First fill claims - claimSet.fields().forEachRemaining(entry -> { + claimSet.properties().forEach(entry -> { claims.add( VisibleSdJwtClaim.builder().withClaimName(entry.getKey()).withClaimValue(entry.getValue()).build()); }); // All claims are duplicate. - assertTrue(claims.size() == claimSet.size() * 2); + assertEquals(claims.size(), claimSet.size() * 2); // Expecting exception assertThrows(IllegalArgumentException.class, () -> IssuerSignedJWT.builder().withClaims(claims).build()); @@ -82,7 +81,7 @@ public class IssuerSignedJWTTest { @Test public void testIssuerSignedJWTWithUndiclosedClaims6_1() { - JsonNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); + ObjectNode claimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s6.1-holder-claims.json"); DisclosureSpec disclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("email", "JnwGqRFZjMprsoZobherdQ") @@ -90,7 +89,9 @@ public class IssuerSignedJWTTest { .withUndisclosedClaim("address", "INhOGJnu82BAtsOwiCJc_A") .withUndisclosedClaim("birthdate", "d0l3jsh5sBzj2oEhZxrJGw").build(); - SdJwt sdJwt = SdJwt.builder().withDisclosureSpec(disclosureSpec).withClaimSet(claimSet).build(); + SdJwt sdJwt = SdJwt.builder() + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(claimSet, disclosureSpec).build()) + .build(false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); @@ -113,17 +114,16 @@ public class IssuerSignedJWTTest { .build(); // Read claims provided by the holder - JsonNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s3.3-holder-claims.json"); + ObjectNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s3.3-holder-claims.json"); // Read claims added by the issuer - JsonNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s3.3-issuer-claims.json"); + ObjectNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s3.3-issuer-claims.json"); // Merge both - ((ObjectNode) holderClaimSet).setAll((ObjectNode) issuerClaimSet); + holderClaimSet.setAll(issuerClaimSet); SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(holderClaimSet) - .build(); + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(holderClaimSet, disclosureSpec).build()) + .build(false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); JsonNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/s3.3-issuer-payload.json"); diff --git a/core/src/test/java/org/keycloak/sdjwt/SdJWTSamplesTest.java b/core/src/test/java/org/keycloak/sdjwt/SdJWTSamplesTest.java index 707a1c2760b..0159c77e9df 100644 --- a/core/src/test/java/org/keycloak/sdjwt/SdJWTSamplesTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/SdJWTSamplesTest.java @@ -16,6 +16,8 @@ */ package org.keycloak.sdjwt; +import org.keycloak.OID4VCConstants; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Test; @@ -29,22 +31,21 @@ public class SdJWTSamplesTest { @Test public void testS7_1_FlatSdJwt() { // Read claims provided by the holder - JsonNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-holder-claims.json"); + ObjectNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-holder-claims.json"); // Read claims added by the issuer - JsonNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-issuer-claims.json"); - ((ObjectNode) holderClaimSet).setAll((ObjectNode) issuerClaimSet); + ObjectNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-issuer-claims.json"); + holderClaimSet.setAll(issuerClaimSet); // produce the main sdJwt, adding nested sdJwts DisclosureSpec disclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("address", "2GLC42sKQveCfGfryNRN9w") .build(); SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(holderClaimSet) - .build(); + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(holderClaimSet, disclosureSpec).build()) + .build(false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); - JsonNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/s7.1-issuer-payload.json"); + ObjectNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/s7.1-issuer-payload.json"); assertEquals(expected, jwt.getPayload()); } @@ -52,10 +53,10 @@ public class SdJWTSamplesTest { @Test public void testS7_2_StructuredSdJwt() { // Read claims provided by the holder - JsonNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-holder-claims.json"); + ObjectNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-holder-claims.json"); // Read claims added by the issuer - JsonNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-issuer-claims.json"); - ((ObjectNode) holderClaimSet).setAll((ObjectNode) issuerClaimSet); + ObjectNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-issuer-claims.json"); + holderClaimSet.setAll(issuerClaimSet); DisclosureSpec addrDisclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("street_address", "2GLC42sKQveCfGfryNRN9w") @@ -65,27 +66,28 @@ public class SdJWTSamplesTest { .build(); // Read claims provided by the holder - JsonNode addressClaimSet = holderClaimSet.get("address"); + ObjectNode addressClaimSet = (ObjectNode) holderClaimSet.get("address"); // produce the nested sdJwt SdJwt addrSdJWT = SdJwt.builder() - .withDisclosureSpec(addrDisclosureSpec) - .withClaimSet(addressClaimSet) - .build(); + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(addressClaimSet, addrDisclosureSpec).build()) + .build(false); // cleanup e.g nested _sd_alg JsonNode addPayload = addrSdJWT.asNestedPayload(); // Set payload back into main claim set - ((ObjectNode) holderClaimSet).set("address", addPayload); + holderClaimSet.set("address", addPayload); DisclosureSpec disclosureSpec = DisclosureSpec.builder().build(); // produce the main sdJwt, adding nested sdJwts SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(holderClaimSet) + .withIssuerSignedJwt(IssuerSignedJWT.builder() + .withClaims(holderClaimSet, disclosureSpec) + .withHashAlg(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM) + .build()) .withNestedSdJwt(addrSdJWT) - .build(); + .build(false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); - JsonNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/s7.2-issuer-payload.json"); + ObjectNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/s7.2-issuer-payload.json"); assertEquals(expected, jwt.getPayload()); } @@ -93,10 +95,10 @@ public class SdJWTSamplesTest { @Test public void testS7_2b_PartialDisclosureOfStructuredSdJwt() { // Read claims provided by the holder - JsonNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-holder-claims.json"); + ObjectNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-holder-claims.json"); // Read claims added by the issuer - JsonNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-issuer-claims.json"); - ((ObjectNode) holderClaimSet).setAll((ObjectNode) issuerClaimSet); + ObjectNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-issuer-claims.json"); + holderClaimSet.setAll(issuerClaimSet); DisclosureSpec addrDisclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("street_address", "2GLC42sKQveCfGfryNRN9w") @@ -105,27 +107,25 @@ public class SdJWTSamplesTest { .build(); // Read claims provided by the holder - JsonNode addressClaimSet = holderClaimSet.get("address"); + ObjectNode addressClaimSet = (ObjectNode) holderClaimSet.get("address"); // produce the nested sdJwt SdJwt addrSdJWT = SdJwt.builder() - .withDisclosureSpec(addrDisclosureSpec) - .withClaimSet(addressClaimSet) - .build(); + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(addressClaimSet, addrDisclosureSpec).build()) + .build(false); // cleanup e.g nested _sd_alg JsonNode addPayload = addrSdJWT.asNestedPayload(); // Set payload back into main claim set - ((ObjectNode) holderClaimSet).set("address", addPayload); + holderClaimSet.set("address", addPayload); DisclosureSpec disclosureSpec = DisclosureSpec.builder().build(); // produce the main sdJwt, adding nested sdJwts SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(holderClaimSet) + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(holderClaimSet, disclosureSpec).build()) .withNestedSdJwt(addrSdJWT) - .build(); + .build(false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); - JsonNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/s7.2b-issuer-payload.json"); + ObjectNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/s7.2b-issuer-payload.json"); assertEquals(expected, jwt.getPayload()); } @@ -133,10 +133,10 @@ public class SdJWTSamplesTest { @Test public void testS7_3_RecursiveDisclosureOfStructuredSdJwt() { // Read claims provided by the holder - JsonNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-holder-claims.json"); + ObjectNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-holder-claims.json"); // Read claims added by the issuer - JsonNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-issuer-claims.json"); - ((ObjectNode) holderClaimSet).setAll((ObjectNode) issuerClaimSet); + ObjectNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s7-issuer-claims.json"); + holderClaimSet.setAll(issuerClaimSet); DisclosureSpec addrDisclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("street_address", "2GLC42sKQveCfGfryNRN9w") @@ -146,26 +146,23 @@ public class SdJWTSamplesTest { .build(); // Read claims provided by the holder - JsonNode addressClaimSet = holderClaimSet.get("address"); + ObjectNode addressClaimSet = (ObjectNode) holderClaimSet.get("address"); // produce the nested sdJwt SdJwt addrSdJWT = SdJwt.builder() - .withDisclosureSpec(addrDisclosureSpec) - .withClaimSet(addressClaimSet) - .build(); + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(addressClaimSet, addrDisclosureSpec).build()) + .build(false); // cleanup e.g nested _sd_alg JsonNode addPayload = addrSdJWT.asNestedPayload(); // Set payload back into main claim set - ((ObjectNode) holderClaimSet).set("address", addPayload); + holderClaimSet.set("address", addPayload); DisclosureSpec disclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("address", "Qg_O64zqAxe412a108iroA") .build(); // produce the main sdJwt, adding nested sdJwts SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(holderClaimSet) - .withNestedSdJwt(addrSdJWT) - .build(); + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(holderClaimSet, disclosureSpec).build()) + .build(false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); JsonNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/s7.3-issuer-payload.json"); diff --git a/core/src/test/java/org/keycloak/sdjwt/SdJwsTest.java b/core/src/test/java/org/keycloak/sdjwt/SdJwsTest.java index 9fc7b53f9e4..19f272de1f0 100644 --- a/core/src/test/java/org/keycloak/sdjwt/SdJwsTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/SdJwsTest.java @@ -19,15 +19,14 @@ package org.keycloak.sdjwt; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; import org.keycloak.common.VerificationException; +import org.keycloak.jose.jws.JWSHeader; import org.keycloak.rule.CryptoInitRule; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.LongNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.ClassRule; import org.junit.Test; @@ -43,7 +42,7 @@ public abstract class SdJwsTest { static TestSettings testSettings = TestSettings.getInstance(); - private JsonNode createPayload() { + private ObjectNode createPayload() { ObjectMapper mapper = new ObjectMapper(); ObjectNode node = mapper.createObjectNode(); node.put("sub", "test"); @@ -54,74 +53,162 @@ public abstract class SdJwsTest { @Test public void testVerifySignature_Positive() throws Exception { - SdJws sdJws = new SdJws(createPayload(), testSettings.holderSigContext, "jwt") { + JWSHeader jwsHeader = new JWSHeader(); + jwsHeader.setType("jwt"); + JwsToken sdJws = new JwsToken(jwsHeader, createPayload(), testSettings.holderSigContext) { }; sdJws.verifySignature(testSettings.holderVerifierContext); } @Test public void testVerifySignature_WrongPublicKey() { - SdJws sdJws = new SdJws(createPayload(), testSettings.holderSigContext, "jwt") { + JWSHeader jwsHeader = new JWSHeader(); + jwsHeader.setType("jwt"); + JwsToken sdJws = new JwsToken(jwsHeader, createPayload(), testSettings.holderSigContext) { }; assertThrows(VerificationException.class, () -> sdJws.verifySignature(testSettings.issuerVerifierContext)); } @Test - public void testPayloadJwsConstruction() { - SdJws sdJws = new SdJws(createPayload()) { - }; - assertNotNull(sdJws.getPayload()); + public void testVerifyExpClaim_ExpiredJWT() { + ObjectNode payload = createPayload(); + payload.put("exp", Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond()); + assertThrows(VerificationException.class, () -> { + new ClaimVerifier.ExpCheck(0, false).test(payload); + }); } - @Test(expected = IllegalStateException.class) - public void testUnsignedJwsConstruction() { - SdJws sdJws = new SdJws(createPayload()) { + @Test + public void testVerifyExpClaim_Positive() throws Exception { + ObjectNode payload = createPayload(); + payload.put("exp", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond()); + + new ClaimVerifier.ExpCheck(0, false).test(payload); + } + + @Test + public void testVerifyNotBeforeClaim_Negative() { + ObjectNode payload = createPayload(); + payload.put("nbf", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond()); + assertThrows(VerificationException.class, () -> { + new ClaimVerifier.NbfCheck(0, false).test(payload); + }); + } + + @Test + public void testVerifyNotBeforeClaim_Positive() throws Exception { + ObjectNode payload = createPayload(); + payload.put("nbf", Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond()); + + new ClaimVerifier.NbfCheck(0, false).test(payload); + } + + @Test + public void testPayloadJwsConstruction() { + JWSHeader jwsHeader = new JWSHeader(); + jwsHeader.setType("jwt"); + JwsToken sdJws = new JwsToken(jwsHeader, createPayload()) { }; - sdJws.toJws(); + assertNotNull(sdJws.getJwsHeader()); + assertNotNull(sdJws.getPayload()); } @Test public void testSignedJwsConstruction() { - SdJws sdJws = new SdJws(createPayload(), testSettings.holderSigContext, "jwt") { + JWSHeader jwsHeader = new JWSHeader(); + jwsHeader.setType("jwt"); + JwsToken sdJws = new JwsToken(jwsHeader, createPayload(), testSettings.holderSigContext) { }; - assertNotNull(sdJws.toJws()); + + assertNotNull(sdJws.getJws()); } - - @Test public void testVerifyIssClaim_Negative() { - List allowedIssuers = Arrays.asList(new String[]{"issuer1@sdjwt.com", "issuer2@sdjwt.com"}); - JsonNode payload = createPayload(); - ((ObjectNode) payload).put("iss", "unknown-issuer@sdjwt.com"); - SdJws sdJws = new SdJws(payload) {}; - VerificationException exception = assertThrows(VerificationException.class, () -> sdJws.verifyIssClaim(allowedIssuers)); - assertEquals("Unknown 'iss' claim value: unknown-issuer@sdjwt.com", exception.getMessage()); + String allowedIssuer = "issuer1@sdjwt.com"; + ObjectNode payload = createPayload(); + String invalidIssuer = "unknown-issuer@sdjwt.com"; + payload.put("iss", invalidIssuer); + JWSHeader jwsHeader = new JWSHeader(); + jwsHeader.setType("jwt"); + VerificationException exception = assertThrows(VerificationException.class, () -> { + new ClaimVerifier.ClaimCheck("iss", allowedIssuer).test(payload); + }); + assertEquals(String.format("Expected value '%s' in token for claim 'iss' does not match actual value '%s'", + allowedIssuer, + invalidIssuer), exception.getMessage()); } @Test public void testVerifyIssClaim_Positive() throws VerificationException { - List allowedIssuers = Arrays.asList(new String[]{"issuer1@sdjwt.com", "issuer2@sdjwt.com"}); - JsonNode payload = createPayload(); - ((ObjectNode) payload).put("iss", "issuer1@sdjwt.com"); - SdJws sdJws = new SdJws(payload) {}; - sdJws.verifyIssClaim(allowedIssuers); + String allowedIssuer = "issuer1@sdjwt.com"; + ObjectNode payload = createPayload(); + payload.put("iss", "issuer1@sdjwt.com"); + new ClaimVerifier.ClaimCheck("iss", allowedIssuer).test(payload); } @Test public void testVerifyVctClaim_Negative() { - JsonNode payload = createPayload(); - ((ObjectNode) payload).put("vct", "IdentityCredential"); - SdJws sdJws = new SdJws(payload) {}; - VerificationException exception = assertThrows(VerificationException.class, () -> sdJws.verifyVctClaim(Collections.singletonList("PassportCredential"))); - assertEquals("Unknown 'vct' claim value: IdentityCredential", exception.getMessage()); + ObjectNode payload = createPayload(); + + final String claimName = "vct"; + final String actualValue = "IdentityCredential"; + payload.put(claimName, actualValue); + + final String expectedClaimValue = "PassportCredential"; + VerificationException exception = assertThrows(VerificationException.class, () -> { + new ClaimVerifier.ClaimCheck(claimName, expectedClaimValue).test(payload); + }); + + assertEquals(String.format("Expected value '%s' in token for claim '%s' does not match actual value '%s'", + expectedClaimValue, + claimName, + actualValue), exception.getMessage()); } @Test public void testVerifyVctClaim_Positive() throws VerificationException { - JsonNode payload = createPayload(); - ((ObjectNode) payload).put("vct", "IdentityCredential"); - SdJws sdJws = new SdJws(payload) {}; - sdJws.verifyVctClaim(Collections.singletonList("IdentityCredential")); + ObjectNode payload = createPayload(); + + final String claimName = "vct"; + final String expectedClaimValue = "IdentityCredential"; + payload.put(claimName, expectedClaimValue); + + new ClaimVerifier.ClaimCheck(claimName, expectedClaimValue).test(payload); + } + + @Test + public void shouldValidateAgeSinceIssued() throws VerificationException { + long now = Instant.now().getEpochSecond(); + JwsToken sdJws = exampleSdJws(now); + + new ClaimVerifier.IatLifetimeCheck(0, 180).test(sdJws.getPayload()); + } + + @Test + public void shouldValidateAgeSinceIssued_IfJwtIsTooOld() { + long now = Instant.now().getEpochSecond(); + long iat = now - 1000; + long maxLifetime = 180; + JwsToken sdJws = exampleSdJws(iat); // that will be too old + VerificationException exception = assertThrows(VerificationException.class, () -> { + new ClaimVerifier.IatLifetimeCheck(0, maxLifetime).test(sdJws.getPayload()); + }); + assertEquals(String.format("Token has expired by iat: now: '%s', expired at: '%s', " + + "iat: '%s', maxLifetime: '%s'", + now, + iat + maxLifetime, + iat, + maxLifetime), exception.getMessage()); + } + + private JwsToken exampleSdJws(long iat) { + ObjectNode payload = new ObjectNode(JsonNodeFactory.instance); + payload.set("iat", new LongNode(iat)); + + JWSHeader jwsHeader = new JWSHeader(); + jwsHeader.setType("jwt"); + return new JwsToken(jwsHeader, payload) { + }; } } diff --git a/core/src/test/java/org/keycloak/sdjwt/SdJwtCreationAndSigningTest.java b/core/src/test/java/org/keycloak/sdjwt/SdJwtCreationAndSigningTest.java new file mode 100644 index 00000000000..78e33f0b68c --- /dev/null +++ b/core/src/test/java/org/keycloak/sdjwt/SdJwtCreationAndSigningTest.java @@ -0,0 +1,406 @@ +/* + * Copyright 2025 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 java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.spec.ECGenParameterSpec; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.keycloak.OID4VCConstants; +import org.keycloak.common.VerificationException; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.ECDSASignatureSignerContext; +import org.keycloak.crypto.ECDSASignatureVerifierContext; +import org.keycloak.crypto.JavaAlgorithm; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.crypto.SignatureVerifierContext; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.representations.IDToken; +import org.keycloak.rule.CryptoInitRule; +import org.keycloak.sdjwt.vp.KeyBindingJWT; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Test; + +/** + * @author Pascal Knueppel + * @since 13.11.2025 + */ +public abstract class SdJwtCreationAndSigningTest { + + @ClassRule + public static CryptoInitRule cryptoInitRule = new CryptoInitRule(); + + @Test + public void testCreateSdJwtWithoutKeybindingAndNoSignature() throws Exception { + + final long iat = Instant.now().minus(10, ChronoUnit.SECONDS).getEpochSecond(); + final long nbf = Instant.now().minus(5, ChronoUnit.SECONDS).getEpochSecond(); + final long exp = Instant.now().plus(60, ChronoUnit.SECONDS).getEpochSecond(); + + String disclosurePayload = "{\n" + + " \"given_name\": \"Carlos\",\n" + + " \"family_name\": \"Norris\"\n" + + "}"; + ObjectNode disclosures = JsonSerialization.readValue(disclosurePayload, ObjectNode.class); + DisclosureSpec disclosureSpec = DisclosureSpec.builder() + .withUndisclosedClaim("given_name", "123456789") + .withUndisclosedClaim("family_name", "987654321") + .build(); + IssuerSignedJWT issuerSignedJWT = IssuerSignedJWT.builder() + /* body */ + .withClaims(disclosures, disclosureSpec) + .withIat(iat) + .withNbf(nbf) + .withExp(exp) + .build(); + + SdJwt sdJwt = SdJwt.builder() + .withIssuerSignedJwt(issuerSignedJWT) + .build(); + + // validate object content + { + Assert.assertEquals(OID4VCConstants.SD_JWT_VC_FORMAT, sdJwt.getIssuerSignedJWT().getJwsHeader().getType()); + Assert.assertEquals(1, + JsonSerialization.mapper.convertValue(sdJwt.getIssuerSignedJWT().getJwsHeader(), + ObjectNode.class).size()); + + Assert.assertEquals(iat, + sdJwt.getIssuerSignedJWT().getPayload().get(OID4VCConstants.CLAIM_NAME_IAT).longValue()); + Assert.assertEquals(nbf, + sdJwt.getIssuerSignedJWT().getPayload().get(OID4VCConstants.CLAIM_NAME_NBF).longValue()); + Assert.assertEquals(exp, + sdJwt.getIssuerSignedJWT().getPayload().get(OID4VCConstants.CLAIM_NAME_EXP).longValue()); + Assert.assertEquals(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM, + sdJwt.getIssuerSignedJWT() + .getPayload() + .get(OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM) + .textValue()); + + List disclosureHashes = sdJwt.getClaims() + .stream() + .flatMap(sdJwtClaim -> { + return sdJwtClaim.getDisclosureStrings().stream(); + }) + .map(b64String -> { + return SdJwtUtils.hashAndBase64EncodeNoPad(b64String, + JavaAlgorithm.SHA256); + }) + .collect(Collectors.toList()); + List decoyHashes = sdJwt.getIssuerSignedJWT() + .getDecoyClaims() + .stream() + .map(decoy -> decoy.getDisclosureDigest(JavaAlgorithm.SHA256)) + .collect(Collectors.toList()); + List expectedSdHashes = new ArrayList<>(disclosureHashes); + expectedSdHashes.addAll(decoyHashes); + expectedSdHashes = expectedSdHashes.stream().sorted().collect(Collectors.toList()); + JsonNode actualSdHashNode = sdJwt.getIssuerSignedJWT() + .getPayload() + .get(OID4VCConstants.CLAIM_NAME_SD); + List actualSdHashes = new ArrayList<>(); + for (JsonNode jsonNode : actualSdHashNode) { + actualSdHashes.add(jsonNode.textValue()); + } + actualSdHashes = actualSdHashes.stream().sorted().collect(Collectors.toList()); + Assert.assertEquals(expectedSdHashes, actualSdHashes); + Assert.assertEquals(5, sdJwt.getIssuerSignedJWT().getPayload().size()); + } + + // make sure default ClaimVerifiers succeed + { + ClaimVerifier claimVerifier = ClaimVerifier.builder().build(); + Assert.assertEquals(3, claimVerifier.getContentVerifiers().size()); + try { + claimVerifier.verifyClaims(sdJwt.getIssuerSignedJWT().getJwsHeaderAsNode(), + sdJwt.getIssuerSignedJWT().getPayload()); + Assert.fail("Verification must fail due to missing 'alg' header"); + } catch (VerificationException e) { + Assert.assertEquals("Missing claim 'alg' in token", e.getMessage()); + } + } + + final String sdJwtString = sdJwt.toSdJwtString(); + int disclosureStart = sdJwtString.indexOf(OID4VCConstants.SDJWT_DELIMITER); + int disclosureEnd = sdJwtString.lastIndexOf(OID4VCConstants.SDJWT_DELIMITER); + + // validate applied disclosures + { + String disclosureString = sdJwtString.substring(disclosureStart + 1, disclosureEnd); + String[] disclosureParts = disclosureString.split(OID4VCConstants.SDJWT_DELIMITER); + Assert.assertEquals(2, disclosureParts.length); + List sortedExpectedDisclosures = sdJwt.getIssuerSignedJWT() + .getDisclosureClaims() + .stream() + .filter(disclosure -> { + return disclosure instanceof UndisclosedClaim; + }) + .map(UndisclosedClaim.class::cast) + .flatMap(disclosure -> { + return disclosure.getDisclosureStrings().stream(); + }) + .sorted(String::compareTo) + .collect(Collectors.toList()); + List sortedActualDisclosures = Arrays.stream(disclosureParts).sorted(String::compareTo).collect( + Collectors.toList()); + Assert.assertEquals(sortedExpectedDisclosures, sortedActualDisclosures); + } + } + + @Test + public void testCreateSdJwtWithKeybindingJwt() throws Exception { + final String authorizationServerUrl = "https://example.com"; + + KeyWrapper issuerKeyPair = toKeyWrapper(createEcKey()); + JWK issuerJwk = JWKBuilder.create().ec(issuerKeyPair.getPublicKey()); + + KeyWrapper holderKeyPair = toKeyWrapper(createEcKey()); + JWK holderKeybindingKey = JWKBuilder.create().ec(holderKeyPair.getPublicKey()); + + SignatureSignerContext issuerSignerContext = new ECDSASignatureSignerContext(issuerKeyPair); + SignatureSignerContext holderSignerContext = new ECDSASignatureSignerContext(holderKeyPair); + + final long iat = Instant.now().minus(10, ChronoUnit.SECONDS).getEpochSecond(); + final long nbf = Instant.now().minus(5, ChronoUnit.SECONDS).getEpochSecond(); + final long exp = Instant.now().plus(60, ChronoUnit.SECONDS).getEpochSecond(); + final String nonce = "123456789"; + final String audience = String.format("x509_san_dns:%s", authorizationServerUrl); + + KeyBindingJWT keyBindingJWT = KeyBindingJWT.builder() + /* header */ + .withKid(holderKeybindingKey.getKeyId()) + /* body */ + .withIat(iat) + .withNbf(nbf) + .withExp(exp) + .withNonce(nonce) + .withAudience(audience) + .build(); + + String disclosurePayload = "{\n" + + " \"given_name\": \"Carlos\",\n" + + " \"family_name\": \"Norris\"\n" + + "}"; + ObjectNode disclosures = JsonSerialization.readValue(disclosurePayload, ObjectNode.class); + DisclosureSpec disclosureSpec = DisclosureSpec.builder() + .withUndisclosedClaim("given_name", "123456789") + .withUndisclosedClaim("family_name", "987654321") + .build(); + IssuerSignedJWT issuerSignedJWT = IssuerSignedJWT.builder() + /* header */ + .withKid(issuerJwk.getKeyId()) + /* body */ + .withClaims(disclosures, disclosureSpec) + .withKeyBinding(holderKeybindingKey) + .withIat(iat) + .withNbf(nbf) + .withExp(exp) + .build(); + + SdJwt sdJwt = SdJwt.builder() + .withIssuerSignedJwt(issuerSignedJWT) + .withKeybindingJwt(keyBindingJWT) + .build(issuerSignerContext, holderSignerContext); + + // validate object content + { + Assert.assertEquals(Algorithm.ES256, sdJwt.getIssuerSignedJWT().getJwsHeader().getAlgorithm().name()); + Assert.assertEquals(OID4VCConstants.SD_JWT_VC_FORMAT, sdJwt.getIssuerSignedJWT().getJwsHeader().getType()); + Assert.assertEquals(issuerKeyPair.getKid(), sdJwt.getIssuerSignedJWT().getJwsHeader().getKeyId()); + Assert.assertEquals(3, + JsonSerialization.mapper.convertValue(sdJwt.getIssuerSignedJWT().getJwsHeader(), + ObjectNode.class).size()); + + ObjectNode expectedCnf = JsonNodeFactory.instance.objectNode(); + expectedCnf.set("jwk", JsonSerialization.mapper.convertValue(holderKeybindingKey, ObjectNode.class)); + Assert.assertEquals(expectedCnf, + sdJwt.getIssuerSignedJWT().getPayload().get(OID4VCConstants.CLAIM_NAME_CNF)); + Assert.assertEquals(iat, + sdJwt.getIssuerSignedJWT().getPayload().get(OID4VCConstants.CLAIM_NAME_IAT).longValue()); + Assert.assertEquals(nbf, + sdJwt.getIssuerSignedJWT().getPayload().get(OID4VCConstants.CLAIM_NAME_NBF).longValue()); + Assert.assertEquals(exp, + sdJwt.getIssuerSignedJWT().getPayload().get(OID4VCConstants.CLAIM_NAME_EXP).longValue()); + Assert.assertEquals(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM, + sdJwt.getIssuerSignedJWT() + .getPayload() + .get(OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM) + .textValue()); + + List disclosureHashes = sdJwt.getClaims() + .stream() + .flatMap(sdJwtClaim -> { + return sdJwtClaim.getDisclosureStrings().stream(); + }) + .map(b64String -> { + return SdJwtUtils.hashAndBase64EncodeNoPad(b64String, + JavaAlgorithm.SHA256); + }) + .collect(Collectors.toList()); + List decoyHashes = sdJwt.getIssuerSignedJWT() + .getDecoyClaims() + .stream() + .map(decoy -> decoy.getDisclosureDigest(JavaAlgorithm.SHA256)) + .collect(Collectors.toList()); + List expectedSdHashes = new ArrayList<>(disclosureHashes); + expectedSdHashes.addAll(decoyHashes); + expectedSdHashes = expectedSdHashes.stream().sorted().collect(Collectors.toList()); + JsonNode actualSdHashNode = sdJwt.getIssuerSignedJWT() + .getPayload() + .get(OID4VCConstants.CLAIM_NAME_SD); + List actualSdHashes = new ArrayList<>(); + for (JsonNode jsonNode : actualSdHashNode) { + actualSdHashes.add(jsonNode.textValue()); + } + actualSdHashes = actualSdHashes.stream().sorted().collect(Collectors.toList()); + Assert.assertEquals(expectedSdHashes, actualSdHashes); + Assert.assertEquals(6, sdJwt.getIssuerSignedJWT().getPayload().size()); + + Assert.assertEquals(holderKeyPair.getKid(), sdJwt.getKeybindingJwt().getJwsHeader().getKeyId()); + Assert.assertEquals(OID4VCConstants.KEYBINDING_JWT_TYP, sdJwt.getKeybindingJwt().getJwsHeader().getType()); + Assert.assertEquals(Algorithm.ES256, sdJwt.getKeybindingJwt().getJwsHeader().getAlgorithm().name()); + Assert.assertEquals(3, + JsonSerialization.mapper.convertValue(sdJwt.getKeybindingJwt().getJwsHeader(), + ObjectNode.class).size()); + + Assert.assertEquals(iat, + sdJwt.getKeybindingJwt().getPayload().get(OID4VCConstants.CLAIM_NAME_IAT).longValue()); + Assert.assertEquals(nbf, + sdJwt.getKeybindingJwt().getPayload().get(OID4VCConstants.CLAIM_NAME_NBF).longValue()); + Assert.assertEquals(exp, + sdJwt.getKeybindingJwt().getPayload().get(OID4VCConstants.CLAIM_NAME_EXP).longValue()); + Assert.assertEquals(nonce, sdJwt.getKeybindingJwt().getPayload().get(IDToken.NONCE).textValue()); + Assert.assertEquals(audience, sdJwt.getKeybindingJwt().getPayload().get(IDToken.AUD).textValue()); + // check sd_hash + { + List parts = new ArrayList<>(); + parts.add(sdJwt.getIssuerSignedJWT().getJws()); + parts.addAll(sdJwt.getDisclosures()); + parts.add(""); + String sdHashString = String.join(OID4VCConstants.SDJWT_DELIMITER, parts); + String expectedSdHash = SdJwtUtils.hashAndBase64EncodeNoPad(sdHashString, JavaAlgorithm.SHA256); + Assert.assertEquals(expectedSdHash, sdJwt.getKeybindingJwt().getPayload().get(OID4VCConstants.SD_HASH) + .textValue()); + } + Assert.assertEquals(6, sdJwt.getKeybindingJwt().getPayload().size()); + } + + // make sure default ClaimVerifiers succeed + { + ClaimVerifier claimVerifier = ClaimVerifier.builder().build(); + Assert.assertEquals(3, claimVerifier.getContentVerifiers().size()); + try { + claimVerifier.verifyClaims(sdJwt.getIssuerSignedJWT().getJwsHeaderAsNode(), + sdJwt.getIssuerSignedJWT().getPayload()); + } catch (VerificationException e) { + throw new RuntimeException("Verification should have succeeded", e); + } + try { + claimVerifier.verifyClaims(sdJwt.getKeybindingJwt().getJwsHeaderAsNode(), + sdJwt.getKeybindingJwt().getPayload()); + } catch (VerificationException e) { + throw new RuntimeException("Verification should have succeeded", e); + } + } + + final String sdJwtString = sdJwt.toSdJwtString(); + int disclosureStart = sdJwtString.indexOf(OID4VCConstants.SDJWT_DELIMITER); + int disclosureEnd = sdJwtString.lastIndexOf(OID4VCConstants.SDJWT_DELIMITER); + + // validate applied disclosures + { + String disclosureString = sdJwtString.substring(disclosureStart + 1, disclosureEnd); + String[] disclosureParts = disclosureString.split(OID4VCConstants.SDJWT_DELIMITER); + Assert.assertEquals(2, disclosureParts.length); + List sortedExpectedDisclosures = sdJwt.getIssuerSignedJWT() + .getDisclosureClaims() + .stream() + .filter(disclosure -> { + return disclosure instanceof UndisclosedClaim; + }) + .map(UndisclosedClaim.class::cast) + .flatMap(disclosure -> { + return disclosure.getDisclosureStrings().stream(); + }) + .sorted(String::compareTo) + .collect(Collectors.toList()); + List sortedActualDisclosures = Arrays.stream(disclosureParts).sorted(String::compareTo).collect( + Collectors.toList()); + Assert.assertEquals(sortedExpectedDisclosures, sortedActualDisclosures); + } + + // validate applied signatures + { + + { + SignatureVerifierContext issuerVerifier = new ECDSASignatureVerifierContext(issuerKeyPair); + String issuerSignedJwtString = sdJwtString.substring(0, disclosureStart); + JWSInput issuerToken = new JWSInput(issuerSignedJwtString); + issuerVerifier.verify(issuerToken.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), + issuerToken.getSignature()); + } + + { + SignatureVerifierContext holderVerifier = new ECDSASignatureVerifierContext(holderKeyPair); + String keybindingJwtString = sdJwtString.substring(disclosureEnd + 1); + JWSInput keybindingToken = new JWSInput(keybindingJwtString); + holderVerifier.verify(keybindingToken.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), + keybindingToken.getSignature()); + } + } + } + + public KeyPair createEcKey() { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec("secp521r1"), new SecureRandom()); + return kpg.generateKeyPair(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + public KeyWrapper toKeyWrapper(KeyPair keyPair) { + KeyWrapper keyWrapper = new KeyWrapper(); + keyWrapper.setKid(KeyUtils.createKeyId(keyPair.getPublic())); + keyWrapper.setAlgorithm(Algorithm.ES256); + keyWrapper.setPrivateKey(keyPair.getPrivate()); + keyWrapper.setPublicKey(keyPair.getPublic()); + keyWrapper.setType(keyPair.getPublic().getAlgorithm()); + return keyWrapper; + } +} diff --git a/core/src/test/java/org/keycloak/sdjwt/SdJwtFacadeTest.java b/core/src/test/java/org/keycloak/sdjwt/SdJwtFacadeTest.java index edfabbc51fe..4e6cedfa609 100644 --- a/core/src/test/java/org/keycloak/sdjwt/SdJwtFacadeTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/SdJwtFacadeTest.java @@ -16,15 +16,17 @@ */ package org.keycloak.sdjwt; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import org.keycloak.OID4VCConstants; import org.keycloak.common.VerificationException; import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.crypto.SignatureVerifierContext; import org.keycloak.rule.CryptoInitRule; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; @@ -45,12 +47,12 @@ public abstract class SdJwtFacadeTest { @ClassRule public static CryptoInitRule cryptoInitRule = new CryptoInitRule(); - private static final String HASH_ALGORITHM = "sha-256"; + private static final String HASH_ALGORITHM = OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM; private static final String JWS_TYPE = "JWS_TYPE"; private SdJwtFacade sdJwtFacade; - private JsonNode claimSet; + private ObjectNode claimSet; private DisclosureSpec disclosureSpec; @Before @@ -140,10 +142,8 @@ public abstract class SdJwtFacadeTest { } private IssuerSignedJwtVerificationOpts createVerificationOptions() { - return IssuerSignedJwtVerificationOpts.builder() - .withRequireIssuedAtClaim(false) - .withRequireExpirationClaim(false) - .withRequireNotBeforeClaim(false) - .build(); + List> headerVerifierList = new ArrayList<>(); + List> bodyVerifierList = new ArrayList<>(); + return new IssuerSignedJwtVerificationOpts(headerVerifierList, bodyVerifierList); } } diff --git a/core/src/test/java/org/keycloak/sdjwt/SdJwtTest.java b/core/src/test/java/org/keycloak/sdjwt/SdJwtTest.java index fac829dc461..a839cd38c27 100644 --- a/core/src/test/java/org/keycloak/sdjwt/SdJwtTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/SdJwtTest.java @@ -30,76 +30,79 @@ import static org.junit.Assert.assertNotNull; */ public class SdJwtTest { - @Test - public void settingsTest() { - SignatureSignerContext issuerSignerContext = TestSettings.getInstance().getIssuerSignerContext(); - assertNotNull(issuerSignerContext); - } + @Test + public void settingsTest() { + SignatureSignerContext issuerSignerContext = TestSettings.getInstance().getIssuerSignerContext(); + assertNotNull(issuerSignerContext); + } - @Test - public void testA1_Example2_with_nested_disclosure_and_decoy_claims() { - DisclosureSpec addrDisclosureSpec = DisclosureSpec.builder() - .withUndisclosedClaim("street_address", "AJx-095VPrpTtN4QMOqROA") - .withUndisclosedClaim("locality", "Pc33JM2LchcU_lHggv_ufQ") - .withUndisclosedClaim("region", "G02NSrQfjFXQ7Io09syajA") - .withUndisclosedClaim("country", "lklxF5jMYlGTPUovMNIvCA") - .withDecoyClaim("2GLC42sKQveCfGfryNRN9w") - .withDecoyClaim("eluV5Og3gSNII8EYnsxA_A") - .withDecoyClaim("6Ij7tM-a5iVPGboS5tmvVA") - .withDecoyClaim("eI8ZWm9QnKPpNPeNenHdhQ") - .build(); + @Test + public void testA1_Example2_with_nested_disclosure_and_decoy_claims() { + DisclosureSpec addrDisclosureSpec = // + DisclosureSpec.builder() + .withUndisclosedClaim("street_address", "AJx-095VPrpTtN4QMOqROA") + .withUndisclosedClaim("locality", "Pc33JM2LchcU_lHggv_ufQ") + .withUndisclosedClaim("region", "G02NSrQfjFXQ7Io09syajA") + .withUndisclosedClaim("country", "lklxF5jMYlGTPUovMNIvCA") + .withDecoyClaim("2GLC42sKQveCfGfryNRN9w") + .withDecoyClaim("eluV5Og3gSNII8EYnsxA_A") + .withDecoyClaim("6Ij7tM-a5iVPGboS5tmvVA") + .withDecoyClaim("eI8ZWm9QnKPpNPeNenHdhQ") + .build(); - DisclosureSpec disclosureSpec = DisclosureSpec.builder() - .withUndisclosedClaim("sub", "2GLC42sKQveCfGfryNRN9w") - .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A") - .withUndisclosedClaim("family_name", "6Ij7tM-a5iVPGboS5tmvVA") - .withUndisclosedClaim("email", "eI8ZWm9QnKPpNPeNenHdhQ") - .withUndisclosedClaim("phone_number", "Qg_O64zqAxe412a108iroA") - .withUndisclosedClaim("birthdate", "yytVbdAPGcgl2rI4C9GSog") - .withDecoyClaim("AJx-095VPrpTtN4QMOqROA") - .withDecoyClaim("G02NSrQfjFXQ7Io09syajA") - .build(); + DisclosureSpec disclosureSpec = // + DisclosureSpec.builder() + .withUndisclosedClaim("sub", "2GLC42sKQveCfGfryNRN9w") + .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A") + .withUndisclosedClaim("family_name", "6Ij7tM-a5iVPGboS5tmvVA") + .withUndisclosedClaim("email", "eI8ZWm9QnKPpNPeNenHdhQ") + .withUndisclosedClaim("phone_number", "Qg_O64zqAxe412a108iroA") + .withUndisclosedClaim("birthdate", "yytVbdAPGcgl2rI4C9GSog") + .withDecoyClaim("AJx-095VPrpTtN4QMOqROA") + .withDecoyClaim("G02NSrQfjFXQ7Io09syajA") + .build(); - // Read claims provided by the holder - JsonNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/a1.example2-holder-claims.json"); + // Read claims provided by the holder + ObjectNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/a1.example2-holder-claims.json"); - // Read claims provided by the holder - JsonNode addressClaimSet = holderClaimSet.get("address"); + // Read claims provided by the holder + ObjectNode addressClaimSet = (ObjectNode) holderClaimSet.get("address"); - // produce the nested sdJwt - SdJwt addrSdJWT = SdJwt.builder() - .withDisclosureSpec(addrDisclosureSpec) - .withClaimSet(addressClaimSet) - .build(); - JsonNode addPayload = addrSdJWT.asNestedPayload(); - JsonNode expectedAddrPayload = TestUtils.readClaimSet(getClass(), - "sdjwt/a1.example2-address-payload.json"); - assertEquals(expectedAddrPayload, addPayload); + // produce the nested sdJwt + SdJwt addrSdJWT = SdJwt.builder() + .withIssuerSignedJwt(IssuerSignedJWT.builder() + .withClaims(addressClaimSet, addrDisclosureSpec) + .build()) + .build(); + JsonNode addPayload = addrSdJWT.asNestedPayload(); + JsonNode expectedAddrPayload = TestUtils.readClaimSet(getClass(), + "sdjwt/a1.example2-address-payload.json"); + assertEquals(expectedAddrPayload, addPayload); - // Verify nested claim has 4 disclosures - assertEquals(4, addrSdJWT.getDisclosures().size()); + // Verify nested claim has 4 disclosures + assertEquals(4, addrSdJWT.getDisclosures().size()); - // Set payload back into main claim set - ((ObjectNode) holderClaimSet).set("address", addPayload); + // Set payload back into main claim set + holderClaimSet.set("address", addPayload); - // Read claims added by the issuer & merge both - JsonNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/a1.example2-issuer-claims.json"); - ((ObjectNode) holderClaimSet).setAll((ObjectNode) issuerClaimSet); + // Read claims added by the issuer & merge both + JsonNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/a1.example2-issuer-claims.json"); + holderClaimSet.setAll((ObjectNode) issuerClaimSet); - // produce the main sdJwt, adding nested sdJwts - SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(holderClaimSet) - .withNestedSdJwt(addrSdJWT) - .build(); + // produce the main sdJwt, adding nested sdJwts + SdJwt sdJwt = SdJwt.builder() + .withIssuerSignedJwt(IssuerSignedJWT.builder() + .withClaims(holderClaimSet, disclosureSpec) + .build()) + .withNestedSdJwt(addrSdJWT) + .build(); - IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); - JsonNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/a1.example2-issuer-payload.json"); - assertEquals(expected, jwt.getPayload()); - - // Verify all claims are present. - // 10 disclosures from 16 digests (6 decoy claims & decoy array elements) - assertEquals(10, sdJwt.getDisclosures().size()); - } + IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); + JsonNode expected = TestUtils.readClaimSet(getClass(), "sdjwt/a1.example2-issuer-payload.json"); + assertEquals(expected, jwt.getPayload()); + // Verify all claims are present. + // 10 disclosures from 16 digests (6 decoy claims & decoy array elements) + assertEquals(10, sdJwt.getDisclosures().size()); + } } diff --git a/core/src/test/java/org/keycloak/sdjwt/SdJwtVerificationTest.java b/core/src/test/java/org/keycloak/sdjwt/SdJwtVerificationTest.java index 5f21bafd2ca..acf5af9a387 100644 --- a/core/src/test/java/org/keycloak/sdjwt/SdJwtVerificationTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/SdJwtVerificationTest.java @@ -21,7 +21,9 @@ import java.time.Instant; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.Function; +import org.keycloak.OID4VCConstants; import org.keycloak.common.VerificationException; import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.crypto.SignatureVerifierContext; @@ -40,6 +42,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -62,74 +65,78 @@ public abstract class SdJwtVerificationTest { @Test public void testSdJwtVerification_FlatSdJwt() throws VerificationException { - for (String hashAlg : Arrays.asList("sha-256", "sha-384", "sha-512")) { - SdJwt sdJwt = exampleFlatSdJwtV1() - .withHashAlgorithm(hashAlg) - .build(); + for (String hashAlg : Arrays.asList(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM, "sha-384", "sha-512")) { + IssuerSignedJWT issuerSignedJWT = exampleFlatSdJwtV1().withHashAlg(hashAlg).build(); + SdJwt sdJwt = SdJwt.builder() + .withIssuerSignedJwt(issuerSignedJWT) + .build(testSettings.issuerSigContext); sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ); } } @Test public void testSdJwtVerification_EnforceIdempotence() throws VerificationException { - SdJwt sdJwt = exampleFlatSdJwtV1().build(); + IssuerSignedJWT issuerSignedJWT = exampleFlatSdJwtV1().build(); + SdJwt sdJwt = SdJwt.builder().withIssuerSignedJwt(issuerSignedJWT).build(testSettings.issuerSigContext); sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ); sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ); } @Test public void testSdJwtVerification_SdJwtWithUndisclosedNestedFields() throws VerificationException { - SdJwt sdJwt = exampleSdJwtWithUndisclosedNestedFieldsV1().build(); + SdJwt sdJwt = SdJwt.builder().withIssuerSignedJwt(exampleSdJwtWithUndisclosedNestedFieldsV1().build()) + .build(testSettings.issuerSigContext); sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ); } @Test public void testSdJwtVerification_SdJwtWithUndisclosedArrayElements() throws Exception { - SdJwt sdJwt = exampleSdJwtWithUndisclosedArrayElementsV1().build(); + SdJwt sdJwt = exampleSdJwtWithUndisclosedArrayElementsV1(); sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ); } @Test public void testSdJwtVerification_RecursiveSdJwt() throws Exception { - SdJwt sdJwt = exampleRecursiveSdJwtV1().build(); + SdJwt sdJwt = exampleRecursiveSdJwtV1().build(testSettings.issuerSigContext); sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ); } @Test public void sdJwtVerificationShouldFail_OnInsecureHashAlg() { - SdJwt sdJwt = exampleFlatSdJwtV1() - .withHashAlgorithm("sha-224") // not deemed secure - .build(); + IssuerSignedJWT issuerSignedJWT = exampleFlatSdJwtV1().withHashAlg("sha-224").build(); + SdJwt sdJwt = SdJwt.builder() + .withIssuerSignedJwt(issuerSignedJWT) // not deemed secure + .build(testSettings.issuerSigContext); VerificationException exception = assertThrows( VerificationException.class, () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ) ); @@ -138,12 +145,13 @@ public abstract class SdJwtVerificationTest { @Test public void sdJwtVerificationShouldFail_WithWrongVerifier() { - SdJwt sdJwt = exampleFlatSdJwtV1().build(); + IssuerSignedJWT issuerSignedJWT = exampleFlatSdJwtV1().build(); + SdJwt sdJwt = SdJwt.builder().withIssuerSignedJwt(issuerSignedJWT).build(testSettings.issuerSigContext); VerificationException exception = assertThrows( VerificationException.class, () -> sdJwt.verify( - Collections.singletonList(testSettings.holderVerifierContext), // wrong verifier - defaultIssuerSignedJwtVerificationOpts().build() + Collections.singletonList(testSettings.holderVerifierContext), // wrong verifier + optionalTimeClaimVerificationOpts().build() ) ); @@ -159,24 +167,40 @@ public abstract class SdJwtVerificationTest { claimSet.put("exp", now - 1000); // expired 1000 seconds ago // Exp claim is plain - SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build(); + SdJwt sdJwtV1 = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build()) + .build(testSettings.issuerSigContext); // Exp claim is undisclosed - SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder() - .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet())) - .withUndisclosedClaim("exp", "eluV5Og3gSNII8EYnsxA_A") - .build()).build(); + SdJwt sdJwtV2 = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, + DisclosureSpec.builder() + .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet())) + .withUndisclosedClaim("exp", "eluV5Og3gSNII8EYnsxA_A") + .build()).build()) + .build(testSettings.issuerSigContext); - for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) { - VerificationException exception = assertThrows( - VerificationException.class, - () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() - ) + Function verify = sdJwt -> { + return assertThrows(VerificationException.class, + () -> sdJwt.verify(defaultIssuerVerifyingKeys(), + IssuerSignedJwtVerificationOpts.builder() + .withClockSkew(0) + .withExpCheck(false) + .withIatCheck(true) + .withNbfCheck(true) + .build()) ); + }; - assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage()); - assertEquals("JWT has expired", exception.getCause().getMessage()); + { + VerificationException exception = verify.apply(sdJwtV1); + assertTrue(String.format("Unexpected error message:\n\tMessage was: %s", exception.getMessage()), + exception.getMessage().matches("Token has expired by exp: now: '\\d+', exp: '\\d+'")); + } + + { + VerificationException exception = verify.apply(sdJwtV2); + assertEquals("Missing required claim 'exp'", exception.getMessage()); + assertNull(exception.getCause()); } } @@ -185,32 +209,47 @@ public abstract class SdJwtVerificationTest { // exp: null ObjectNode claimSet1 = mapper.createObjectNode(); claimSet1.put("given_name", "John"); + claimSet1.put("exp", Instant.now().getEpochSecond() - (31536000)); // exp: invalid ObjectNode claimSet2 = mapper.createObjectNode(); - claimSet1.put("given_name", "John"); - claimSet1.set("exp", null); + claimSet2.put("given_name", "John"); + claimSet2.set("exp", null); DisclosureSpec disclosureSpec = DisclosureSpec.builder() .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A") .build(); - SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet1, disclosureSpec).build(); - SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet2, disclosureSpec).build(); - for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) { - VerificationException exception = assertThrows( - VerificationException.class, - () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts() - .withRequireExpirationClaim(true) - .build() - ) + + Function verify = sdJwt -> { + return assertThrows(VerificationException.class, + () -> sdJwt.verify(defaultIssuerVerifyingKeys(), + IssuerSignedJwtVerificationOpts.builder() + .withClockSkew(0) + .withExpCheck(false) + .withIatCheck(true) + .withNbfCheck(true) + .build() + ) ); + }; - assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage()); - assertEquals("Missing 'exp' claim or null", exception.getCause().getMessage()); + { + SdJwt sdJwtV1 = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet1, disclosureSpec).build()) + .build(testSettings.issuerSigContext); + VerificationException exception = verify.apply(sdJwtV1); + assertTrue(String.format("Unexpected error message:\n\tMessage was: %s", exception.getMessage()), + exception.getMessage().matches("Token has expired by exp: now: '\\d+', exp: '\\d+'")); + } + { + SdJwt sdJwtV2 = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet2, disclosureSpec).build()) + .build(testSettings.issuerSigContext); + VerificationException exception = verify.apply(sdJwtV2); + assertEquals(String.format("Unexpected error message:\n\tMessage was: %s", exception.getMessage()), + "Missing required claim 'exp'", exception.getMessage()); } } @@ -223,24 +262,41 @@ public abstract class SdJwtVerificationTest { claimSet.put("iat", now + 1000); // issued in the future // Exp claim is plain - SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build(); + SdJwt sdJwtV1 = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, + DisclosureSpec.builder().build()).build()) + .build(testSettings.issuerSigContext); // Exp claim is undisclosed - SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder() - .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet())) - .withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A") - .build()).build(); + SdJwt sdJwtV2 = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, + DisclosureSpec.builder() + .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet())) + .withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A") + .build()).build()) + .build(testSettings.issuerSigContext); - for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) { - VerificationException exception = assertThrows( - VerificationException.class, - () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() - ) + Function verify = sdJwt -> { + return assertThrows(VerificationException.class, + () -> sdJwt.verify(defaultIssuerVerifyingKeys(), + IssuerSignedJwtVerificationOpts.builder() + .withClockSkew(0) + .withIatCheck(false) + .withExpCheck(true) + .withNbfCheck(true) + .build()) ); + }; - assertEquals("Issuer-Signed JWT: Invalid `iat` claim", exception.getMessage()); - assertEquals("JWT was issued in the future", exception.getCause().getMessage()); + { + VerificationException exception = verify.apply(sdJwtV1); + assertTrue(String.format("Unexpected error message:\n\tMessage was: %s", exception.getMessage()), + exception.getMessage().matches("Token was issued in the future: now: '\\d+', iat: '\\d+'")); + assertNull(exception.getCause()); + } + + { + VerificationException exception = verify.apply(sdJwtV2); + assertEquals("Missing required claim 'iat'", exception.getMessage()); } } @@ -253,24 +309,29 @@ public abstract class SdJwtVerificationTest { claimSet.put("nbf", now + 1000); // now will be too soon to accept the jwt // Exp claim is plain - SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build(); + SdJwt sdJwtV1 = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build()) + .build(testSettings.issuerSigContext); // Exp claim is undisclosed - SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder() - .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet())) - .withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A") - .build()).build(); + SdJwt sdJwtV2 = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, + DisclosureSpec.builder() + .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet())) + .withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A") + .build()).build()) + .build(testSettings.issuerSigContext); for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) { VerificationException exception = assertThrows( VerificationException.class, () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ) ); - assertEquals("Issuer-Signed JWT: Invalid `nbf` claim", exception.getMessage()); - assertEquals("JWT is not yet valid", exception.getCause().getMessage()); + assertTrue(String.format("Unexpected error message:\n\tMessage was: %s", exception.getMessage()), + exception.getMessage().matches("Token is not yet valid: now: '\\d+', nbf: '\\d+'")); } } @@ -280,13 +341,15 @@ public abstract class SdJwtVerificationTest { claimSet.put("given_name", "John"); claimSet.set(CLAIM_NAME_SD, mapper.readTree("[123]")); - SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build(); + SdJwt sdJwt = SdJwt.builder().withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()) + .build()) + .build(testSettings.issuerSigContext); VerificationException exception = assertThrows( VerificationException.class, () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ) ); @@ -299,15 +362,18 @@ public abstract class SdJwtVerificationTest { ObjectNode claimSet = mapper.createObjectNode(); claimSet.put(forbiddenClaimName, "Value"); - SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder() - .withUndisclosedClaim(forbiddenClaimName, "eluV5Og3gSNII8EYnsxA_A") - .build()).build(); + SdJwt sdJwt = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, + DisclosureSpec.builder() + .withUndisclosedClaim(forbiddenClaimName, "eluV5Og3gSNII8EYnsxA_A") + .build()).build()) + .build(testSettings.issuerSigContext); VerificationException exception = assertThrows( VerificationException.class, () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ) ); @@ -320,17 +386,20 @@ public abstract class SdJwtVerificationTest { ObjectNode claimSet = mapper.createObjectNode(); claimSet.put("given_name", "John"); // this same field will also be nested - SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder() - .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A") - .withDecoyClaim("G02NSrQfjFXQ7Io09syajA") - .withDecoyClaim("G02NSrQfjFXQ7Io09syajA") - .build()).build(); + SdJwt sdJwt = SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, + DisclosureSpec.builder() + .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A") + .withDecoyClaim("G02NSrQfjFXQ7Io09syajA") + .withDecoyClaim("G02NSrQfjFXQ7Io09syajA") + .build()).build()) + .build(testSettings.issuerSigContext); VerificationException exception = assertThrows( VerificationException.class, () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() + defaultIssuerVerifyingKeys(), + optionalTimeClaimVerificationOpts().build() ) ); @@ -344,35 +413,45 @@ public abstract class SdJwtVerificationTest { claimSet.put("family_name", "Doe"); String salt = "eluV5Og3gSNII8EYnsxA_A"; - SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder() - .withUndisclosedClaim("given_name", salt) - // We are reusing the same salt value, and that is the problem - .withUndisclosedClaim("family_name", salt) - .build()).build(); - - VerificationException exception = assertThrows( - VerificationException.class, - () -> sdJwt.verify( - defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build() - ) + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> { + DisclosureSpec disclosureSpec = DisclosureSpec.builder() + .withUndisclosedClaim("given_name", salt) + // We are reusing the same salt value, and that is the problem + .withUndisclosedClaim("family_name", salt) + .build(); + SdJwt.builder() + .withIssuerSignedJwt(exampleFlatSdJwtV2(claimSet, disclosureSpec).build()) + .build(testSettings.issuerSigContext); + } ); - assertEquals("A salt value was reused: " + salt, exception.getMessage()); + assertEquals(String.format("Salt value '%s' was reused for claims 'family_name' and 'given_name'", salt), + exception.getMessage()); } private List defaultIssuerVerifyingKeys() { return Collections.singletonList(testSettings.issuerVerifierContext); } - private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() { - return IssuerSignedJwtVerificationOpts.builder() - .withRequireIssuedAtClaim(false) - .withRequireExpirationClaim(false) - .withRequireNotBeforeClaim(false); + private IssuerSignedJwtVerificationOpts.Builder mandatoryTimeClaimVerificationOpts() { + return getTimeClaimVerificationOpts(false); } - private SdJwt.Builder exampleFlatSdJwtV1() { + private IssuerSignedJwtVerificationOpts.Builder optionalTimeClaimVerificationOpts() { + return getTimeClaimVerificationOpts(true); + } + + private IssuerSignedJwtVerificationOpts.Builder getTimeClaimVerificationOpts(boolean isOptional) { + return IssuerSignedJwtVerificationOpts.builder() + .withClockSkew(0) + .withIatCheck(isOptional) + .withExpCheck(isOptional) + .withNbfCheck(isOptional); + } + + private IssuerSignedJWT.Builder exampleFlatSdJwtV1() { ObjectNode claimSet = mapper.createObjectNode(); claimSet.put("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c"); claimSet.put("given_name", "John"); @@ -386,17 +465,11 @@ public abstract class SdJwtVerificationTest { .withDecoyClaim("G02NSrQfjFXQ7Io09syajA") .build(); - return SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(claimSet) - .withSigner(testSettings.issuerSigContext); + return IssuerSignedJWT.builder().withClaims(claimSet, disclosureSpec); } - private SdJwt.Builder exampleFlatSdJwtV2(ObjectNode claimSet, DisclosureSpec disclosureSpec) { - return SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(claimSet) - .withSigner(testSettings.issuerSigContext); + private IssuerSignedJWT.Builder exampleFlatSdJwtV2(ObjectNode claimSet, DisclosureSpec disclosureSpec) { + return IssuerSignedJWT.builder().withClaims(claimSet, disclosureSpec); } private SdJwt exampleAddrSdJwt() { @@ -412,12 +485,13 @@ public abstract class SdJwtVerificationTest { .build(); return SdJwt.builder() - .withDisclosureSpec(addrDisclosureSpec) - .withClaimSet(addressClaimSet) - .build(); + .withIssuerSignedJwt(IssuerSignedJWT.builder() + .withClaims(addressClaimSet, addrDisclosureSpec) + .build()) + .build(); } - private SdJwt.Builder exampleSdJwtWithUndisclosedNestedFieldsV1() { + private IssuerSignedJWT.Builder exampleSdJwtWithUndisclosedNestedFieldsV1() { SdJwt addrSdJWT = exampleAddrSdJwt(); ObjectNode claimSet = mapper.createObjectNode(); @@ -433,14 +507,10 @@ public abstract class SdJwtVerificationTest { .withUndisclosedClaim("email", "eI8ZWm9QnKPpNPeNenHdhQ") .build(); - return SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(claimSet) - .withNestedSdJwt(addrSdJWT) - .withSigner(testSettings.issuerSigContext); + return IssuerSignedJWT.builder().withClaims(claimSet, disclosureSpec); } - private SdJwt.Builder exampleSdJwtWithUndisclosedArrayElementsV1() throws JsonProcessingException { + private SdJwt exampleSdJwtWithUndisclosedArrayElementsV1() throws JsonProcessingException { ObjectNode claimSet = mapper.createObjectNode(); claimSet.put("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c"); claimSet.put("given_name", "John"); @@ -457,9 +527,10 @@ public abstract class SdJwtVerificationTest { .build(); return SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(claimSet) - .withSigner(testSettings.issuerSigContext); + .withIssuerSignedJwt(IssuerSignedJWT.builder() + .withClaims(claimSet, disclosureSpec) + .build()) + .build(testSettings.issuerSigContext); } private SdJwt.Builder exampleRecursiveSdJwtV1() { @@ -481,9 +552,7 @@ public abstract class SdJwtVerificationTest { .build(); return SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(claimSet) - .withNestedSdJwt(addrSdJWT) - .withSigner(testSettings.issuerSigContext); + .withIssuerSignedJwt(IssuerSignedJWT.builder().withClaims(claimSet, disclosureSpec).build()) + .withNestedSdJwt(addrSdJWT); } } diff --git a/core/src/test/java/org/keycloak/sdjwt/TestSettings.java b/core/src/test/java/org/keycloak/sdjwt/TestSettings.java index fb116db13ca..4034b73c84f 100644 --- a/core/src/test/java/org/keycloak/sdjwt/TestSettings.java +++ b/core/src/test/java/org/keycloak/sdjwt/TestSettings.java @@ -107,7 +107,7 @@ public class TestSettings { return getSignatureVerifierContext(keyPair.getPublic(), algorithm, kid); } - private static KeyPair readKeyPair(JsonNode keySetting) { + public static KeyPair readKeyPair(JsonNode keySetting) { String curveName = keySetting.get("crv").asText(); String base64UrlEncodedD = keySetting.get("d").asText(); String base64UrlEncodedX = keySetting.get("x").asText(); diff --git a/core/src/test/java/org/keycloak/sdjwt/TestUtils.java b/core/src/test/java/org/keycloak/sdjwt/TestUtils.java index d0da34dabc9..36bc686db94 100644 --- a/core/src/test/java/org/keycloak/sdjwt/TestUtils.java +++ b/core/src/test/java/org/keycloak/sdjwt/TestUtils.java @@ -20,17 +20,18 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.util.Objects; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; /** * @author Francis Pouatcha */ public class TestUtils { - public static JsonNode readClaimSet(Class klass, String path) { + public static ObjectNode readClaimSet(Class klass, String path) { // try-with-resources closes inputstream! try (InputStream is = klass.getClassLoader().getResourceAsStream(path)) { - return SdJwtUtils.mapper.readTree(is); + return SdJwtUtils.mapper.readValue(is, ObjectNode.class); } catch (IOException e) { throw new RuntimeException("Error reading file at path: " + path, e); } @@ -38,8 +39,9 @@ public class TestUtils { public static String readFileAsString(Class klass, String filePath) { StringBuilder stringBuilder = new StringBuilder(); - try (BufferedReader reader = new BufferedReader( - (new InputStreamReader(klass.getClassLoader().getResourceAsStream(filePath))))) { + try (InputStream inputStream = Objects.requireNonNull(klass.getClassLoader().getResourceAsStream(filePath)); + InputStreamReader inputStreamReader = new InputStreamReader(inputStream); + BufferedReader reader = new BufferedReader(inputStreamReader)) { String line; while ((line = reader.readLine()) != null) { stringBuilder.append(line); // Appends line without a newline character diff --git a/core/src/test/java/org/keycloak/sdjwt/TimeClaimVerifierTest.java b/core/src/test/java/org/keycloak/sdjwt/TimeClaimVerifierTest.java index da650f22bd1..7bafd99ae49 100644 --- a/core/src/test/java/org/keycloak/sdjwt/TimeClaimVerifierTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/TimeClaimVerifierTest.java @@ -17,9 +17,15 @@ package org.keycloak.sdjwt; +import java.util.ArrayList; +import java.util.function.Function; + import org.keycloak.common.VerificationException; +import org.keycloak.common.util.Time; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import static org.keycloak.OID4VCConstants.CLAIM_NAME_EXP; @@ -28,28 +34,39 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_NBF; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; /** * @author Ingrid Kamga */ public class TimeClaimVerifierTest { - private static final long CURRENT_TIMESTAMP = 1609459200L; // Fixed timestamp: 2021-01-01 00:00:00 UTC + private static final int TIMESTAMP2021 = 1609459200; // Fixed timestamp: 2021-01-01 00:00:00 UTC + private int CURRENT_TIMESTAMP; private static final int DEFAULT_CLOCK_SKEW_SECONDS = 20; - private final TimeClaimVerifier timeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_CLOCK_SKEW_SECONDS, false); - private final TimeClaimVerifier strictTimeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_CLOCK_SKEW_SECONDS, true); + private final ClaimVerifier timeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_CLOCK_SKEW_SECONDS, false); - static class FixedTimeClaimVerifier extends TimeClaimVerifier { + static class FixedTimeClaimVerifier extends ClaimVerifier { - public FixedTimeClaimVerifier(int allowedClockSkewSeconds, boolean requireClaims) { - super(createOptsWithAllowedClockSkew(allowedClockSkewSeconds, requireClaims)); + public FixedTimeClaimVerifier(int clockSkew, boolean requireClaims) { + super(new ArrayList<>(), + createOptsWithClockSkew(clockSkew, requireClaims) + .getContentVerifiers()); } + } - @Override - public long currentTimestamp() { - return CURRENT_TIMESTAMP; - } + @Before + public void updateTimeOffset() { + int currentTime = Time.currentTime(); + Time.setOffset(TIMESTAMP2021 - currentTime); // Move time to 2021-01-01 00:00:00 UTC + this.CURRENT_TIMESTAMP = Time.currentTime(); + } + + @After + public void revertTimeOffset() { + Time.setOffset(0); } @Test @@ -58,25 +75,36 @@ public class TimeClaimVerifierTest { payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP + 100); // 100 seconds in the future VerificationException exception = assertThrows(VerificationException.class, - () -> timeClaimVerifier.verifyIssuedAtClaim(payload)); + () -> timeClaimVerifier.verifyBodyClaims(payload)); - assertEquals("JWT was issued in the future", exception.getMessage()); + assertTrue(String.format("Expected message '%s' does not match regex", exception.getMessage()), + exception.getMessage().matches("Token was issued in the future: now: '\\d+', iat: '\\d+'")); } @Test public void testVerifyIatClaimValid() throws VerificationException { ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); - payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 1); // Issued 1 second ago, in the past + payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 5); // Issued 5 seconds ago, in the past - timeClaimVerifier.verifyIssuedAtClaim(payload); + timeClaimVerifier.verifyBodyClaims(payload); } @Test public void testVerifyIatClaimEdge() throws VerificationException { ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); - payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP + 19); // Issued 19 seconds in the future, within the 20 second clock skew + payload.put(CLAIM_NAME_IAT, + CURRENT_TIMESTAMP + 15); // Issued 15 seconds in the future, within the 20 second clock skew - timeClaimVerifier.verifyIssuedAtClaim(payload); + timeClaimVerifier.verifyBodyClaims(payload); + + payload.put(CLAIM_NAME_IAT, + CURRENT_TIMESTAMP + 25); // Issued 25 seconds in the future, which is not within the 20 second clock skew + + VerificationException exception = assertThrows(VerificationException.class, + () -> timeClaimVerifier.verifyBodyClaims(payload)); + + assertTrue(String.format("Expected message '%s' does not match regex", exception.getMessage()), + exception.getMessage().matches("Token was issued in the future: now: '\\d+', iat: '\\d+'")); } @Test @@ -85,9 +113,10 @@ public class TimeClaimVerifierTest { payload.put(CLAIM_NAME_EXP, CURRENT_TIMESTAMP - 100); // Expired 100 seconds ago VerificationException exception = assertThrows(VerificationException.class, - () -> timeClaimVerifier.verifyExpirationClaim(payload)); + () -> timeClaimVerifier.verifyBodyClaims(payload)); - assertEquals("JWT has expired", exception.getMessage()); + assertTrue(String.format("Expected message '%s' does not match regex", exception.getMessage()), + exception.getMessage().matches("Token has expired by exp: now: '\\d+', exp: '\\d+'")); } @Test @@ -95,16 +124,16 @@ public class TimeClaimVerifierTest { ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); payload.put(CLAIM_NAME_EXP, CURRENT_TIMESTAMP + 100); // Expires 100 seconds in the future - timeClaimVerifier.verifyExpirationClaim(payload); + timeClaimVerifier.verifyBodyClaims(payload); } @Test public void testVerifyExpClaimEdge() throws VerificationException { ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); - payload.put(CLAIM_NAME_EXP, CURRENT_TIMESTAMP - 19); // 19 seconds ago, within the 20 second clock skew + payload.put(CLAIM_NAME_EXP, CURRENT_TIMESTAMP - 15); // 15 seconds ago, within the 20 second clock skew // No exception expected for JWT expiring within clock skew - timeClaimVerifier.verifyExpirationClaim(payload); + timeClaimVerifier.verifyBodyClaims(payload); } @Test @@ -113,9 +142,10 @@ public class TimeClaimVerifierTest { payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP + 100); // Not valid for another 100 seconds VerificationException exception = assertThrows(VerificationException.class, - () -> timeClaimVerifier.verifyNotBeforeClaim(payload)); + () -> timeClaimVerifier.verifyBodyClaims(payload)); - assertEquals("JWT is not yet valid", exception.getMessage()); + assertTrue(String.format("Expected message '%s' does not match regex", exception.getMessage()), + exception.getMessage().matches("Token is not yet valid: now: '\\d+', nbf: '\\d+'")); } @Test @@ -123,58 +153,74 @@ public class TimeClaimVerifierTest { ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP - 100); // Valid since 100 seconds ago - timeClaimVerifier.verifyNotBeforeClaim(payload); + timeClaimVerifier.verifyBodyClaims(payload); } // Test for verifyNotBeforeClaim (edge case: valid exactly at current time with clock skew) @Test public void testVerifyNotBeforeClaimEdge() throws VerificationException { ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); - payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP + 19); // 19 seconds in the future, within the 20 second clock skew + payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP + 15); // 15 seconds in the future, within the 20 second clock skew // No exception expected for JWT becoming valid within clock skew - timeClaimVerifier.verifyNotBeforeClaim(payload); + timeClaimVerifier.verifyBodyClaims(payload); + + payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP + 25); // 25 seconds in the future, not anymore within the 20 second clock skew + + VerificationException exception = assertThrows(VerificationException.class, + () -> timeClaimVerifier.verifyBodyClaims(payload)); + + assertTrue(String.format("Expected message '%s' does not match regex", exception.getMessage()), + exception.getMessage().matches("Token is not yet valid: now: '\\d+', nbf: '\\d+'")); } @Test public void testVerifyAgeJwtTooOld() { - int maxAgeAllowed = 300; // 5 minutes - ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); - payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 361); // 361 seconds old + payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 365); // 365 seconds old VerificationException exception = assertThrows(VerificationException.class, - () -> timeClaimVerifier.verifyAge(payload, maxAgeAllowed)); + () -> timeClaimVerifier.verifyBodyClaims(payload)); - assertEquals("JWT is too old", exception.getMessage()); + assertTrue(String.format("Expected message '%s' does not match regex", exception.getMessage()), + exception.getMessage().matches("Token has expired by iat: now: '\\d+', expired at:" + + " '\\d+', iat: '\\d+', maxLifetime: '300'")); } @Test public void testVerifyAgeValid() throws VerificationException { - int maxAgeAllowed = 300; // 5 minutes - ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 100); // Only 100 seconds old - timeClaimVerifier.verifyAge(payload, maxAgeAllowed); + timeClaimVerifier.verifyBodyClaims(payload); } @Test public void testVerifyAgeValidEdge() throws VerificationException { - int maxAgeAllowed = 300; // 5 minutes - ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); - payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 320); // 320 seconds old, within the 20 second clock skew + payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 315); // 315 seconds old, within the 20 second clock skew - timeClaimVerifier.verifyAge(payload, maxAgeAllowed); + timeClaimVerifier.verifyBodyClaims(payload); + + payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 325); // 325 seconds old. not anymore within valid clock skew + + VerificationException exception = assertThrows(VerificationException.class, + () -> timeClaimVerifier.verifyBodyClaims(payload)); + + assertTrue(String.format("Expected message '%s' does not match regex", exception.getMessage()), + exception.getMessage().matches("Token has expired by iat: now: '\\d+', expired at:" + + " '\\d+', iat: '\\d+', maxLifetime: '300'")); } @Test - public void instantiationShouldFailIfClockSkewNegative() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> new TimeClaimVerifier(createOptsWithAllowedClockSkew(-1, false))); - - assertEquals("Allowed clock skew seconds cannot be negative", exception.getMessage()); + public void testUseClockSkewZeroIfSetToNegative() { + ClaimVerifier claimVerifier = createOptsWithClockSkew(-1, false); + claimVerifier.getContentVerifiers() + .stream() + .filter(verifier -> verifier instanceof ClaimVerifier.TimeCheck) + .forEach(verifier -> { + assertEquals(0, ((ClaimVerifier.TimeCheck) verifier).getClockSkewSeconds()); + }); } @Test @@ -183,9 +229,7 @@ public class TimeClaimVerifierTest { ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); // No exception expected as claims are not required - timeClaimVerifier.verifyIssuedAtClaim(payload); - timeClaimVerifier.verifyExpirationClaim(payload); - timeClaimVerifier.verifyNotBeforeClaim(payload); + timeClaimVerifier.verifyBodyClaims(payload); } @Test @@ -193,25 +237,47 @@ public class TimeClaimVerifierTest { // No time claims added ObjectNode payload = SdJwtUtils.mapper.createObjectNode(); - VerificationException exceptionIat = assertThrows(VerificationException.class, - () -> strictTimeClaimVerifier.verifyIssuedAtClaim(payload)); - assertEquals("Missing 'iat' claim or null", exceptionIat.getMessage()); + Function strictVerifier = verifier -> { + try{ + verifier.verifyBodyClaims(payload); + fail("Verification should have failed"); + return null; + } catch(VerificationException e){ + return e; + } + }; - VerificationException exceptionExp = assertThrows(VerificationException.class, - () -> strictTimeClaimVerifier.verifyExpirationClaim(payload)); - assertEquals("Missing 'exp' claim or null", exceptionExp.getMessage()); + VerificationException exceptionIat = strictVerifier.apply(ClaimVerifier.builder() + .withIatCheck(false) + .withNbfCheck(true) + .withExpCheck(true) + .build()); + assertEquals("Missing required claim 'iat'", exceptionIat.getMessage()); - VerificationException exceptionNbf = assertThrows(VerificationException.class, - () -> strictTimeClaimVerifier.verifyNotBeforeClaim(payload)); - assertEquals("Missing 'nbf' claim or null", exceptionNbf.getMessage()); + VerificationException exceptionExp = strictVerifier.apply(ClaimVerifier.builder() + .withExpCheck(false) + .withIatCheck(true) + .withNbfCheck(true) + .build()); + assertEquals("Missing required claim 'exp'", exceptionExp.getMessage()); + + VerificationException exceptionNbf = strictVerifier.apply(ClaimVerifier.builder() + .withNbfCheck(false) + .withIatCheck(true) + .withExpCheck(true) + .build()); + assertEquals("Missing required claim 'nbf'", exceptionNbf.getMessage()); } - private static TimeClaimVerificationOpts createOptsWithAllowedClockSkew(int allowedClockSkewSeconds, boolean requireClaims) { - return TimeClaimVerificationOpts.builder() - .withAllowedClockSkew(allowedClockSkewSeconds) - .withRequireIssuedAtClaim(requireClaims) - .withRequireExpirationClaim(requireClaims) - .withRequireNotBeforeClaim(requireClaims) - .build(); + private static ClaimVerifier createOptsWithClockSkew(int clockSkew, boolean requireClaims) { + final int defaultMaxLifeTime = 300; + final boolean isOptionalCheck = !requireClaims; + + return ClaimVerifier.builder() + .withClockSkew(clockSkew) + .withIatCheck(defaultMaxLifeTime, isOptionalCheck) + .withExpCheck(isOptionalCheck) + .withNbfCheck(isOptionalCheck) + .build(); } } diff --git a/core/src/test/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuerTest.java b/core/src/test/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuerTest.java index 28db6ce4c5c..221e6ddea9d 100644 --- a/core/src/test/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuerTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuerTest.java @@ -271,7 +271,7 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest { // 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(); + String kid = issuerSignedJWT.getJwsHeader().getKeyId(); // Act and assert genericTestShouldFail( @@ -290,7 +290,7 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest { // Set the same kid to all JWKs to publish, which is problematic - String kid = issuerSignedJWT.getHeader().getKeyId(); + String kid = issuerSignedJWT.getJwsHeader().getKeyId(); JsonNode jwks = exampleJwks(); for (JsonNode jwk : jwks.get("keys")) { ((ObjectNode) jwk).put("kid", kid); @@ -373,7 +373,7 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest { 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); + issuerSignedJWT.getPayload().put("iss", issuerUri); return issuerSignedJWT; } diff --git a/core/src/test/java/org/keycloak/sdjwt/consumer/SdJwtPresentationConsumerTest.java b/core/src/test/java/org/keycloak/sdjwt/consumer/SdJwtPresentationConsumerTest.java index 063496ab5a8..5ec6107840b 100644 --- a/core/src/test/java/org/keycloak/sdjwt/consumer/SdJwtPresentationConsumerTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/consumer/SdJwtPresentationConsumerTest.java @@ -102,19 +102,19 @@ public abstract class SdJwtPresentationConsumerTest { private IssuerSignedJwtVerificationOpts defaultIssuerSignedJwtVerificationOpts() { return IssuerSignedJwtVerificationOpts.builder() - .withRequireIssuedAtClaim(false) - .withRequireNotBeforeClaim(false) + .withIatCheck(Integer.MAX_VALUE, true) + .withNbfCheck(true) .build(); } private KeyBindingJwtVerificationOpts defaultKeyBindingJwtVerificationOpts() { return KeyBindingJwtVerificationOpts.builder() .withKeyBindingRequired(true) - .withAllowedMaxAge(Integer.MAX_VALUE) - .withNonce("1234567890") - .withAud("https://verifier.example.org") - .withRequireExpirationClaim(false) - .withRequireNotBeforeClaim(false) + .withIatCheck(Integer.MAX_VALUE) + .withNonceCheck("1234567890") + .withAudCheck("https://verifier.example.org") + .withNbfCheck(true) + .withExpCheck(true) .build(); } } diff --git a/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/KeyBindingJwtVerificationOptsTest.java b/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/KeyBindingJwtVerificationOptsTest.java index 25ac6af0f7a..48d6028b292 100644 --- a/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/KeyBindingJwtVerificationOptsTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/KeyBindingJwtVerificationOptsTest.java @@ -34,7 +34,7 @@ public class KeyBindingJwtVerificationOptsTest { public void buildShouldFail_IfKeyBindingRequired_AndNonceEmpty() { KeyBindingJwtVerificationOpts.builder() .withKeyBindingRequired(true) - .withNonce("") + .withNonceCheck("") .build(); } @@ -42,7 +42,7 @@ public class KeyBindingJwtVerificationOptsTest { public void buildShouldFail_IfKeyBindingRequired_AndAudNotSpecified() { KeyBindingJwtVerificationOpts.builder() .withKeyBindingRequired(true) - .withNonce("12345678") + .withNonceCheck("12345678") .build(); } diff --git a/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/SdJwtVPTest.java b/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/SdJwtVPTest.java index 2394edea2fd..3523159f57e 100644 --- a/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/SdJwtVPTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/SdJwtVPTest.java @@ -33,8 +33,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.ClassRule; import org.junit.Test; -import static org.keycloak.OID4VCConstants.SD_JWT_VC_FORMAT; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; @@ -63,18 +61,17 @@ public abstract class SdJwtVPTest { .build(); // Read claims provided by the holder - JsonNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s3.3-holder-claims.json"); + ObjectNode holderClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s3.3-holder-claims.json"); // Read claims added by the issuer JsonNode issuerClaimSet = TestUtils.readClaimSet(getClass(), "sdjwt/s3.3-issuer-claims.json"); // Merge both - ((ObjectNode) holderClaimSet).setAll((ObjectNode) issuerClaimSet); + holderClaimSet.setAll((ObjectNode) issuerClaimSet); + IssuerSignedJWT issuerSignedJWT = IssuerSignedJWT.builder().withClaims(holderClaimSet, disclosureSpec).build(); SdJwt sdJwt = SdJwt.builder() - .withDisclosureSpec(disclosureSpec) - .withClaimSet(holderClaimSet) - .withSigner(TestSettings.getInstance().getIssuerSignerContext()) - .build(); + .withIssuerSignedJwt(issuerSignedJWT) + .build(TestSettings.getInstance().getIssuerSignerContext(), false); IssuerSignedJWT jwt = sdJwt.getIssuerSignedJWT(); @@ -149,9 +146,9 @@ public abstract class SdJwtVPTest { public void testS6_2_PresentationPositive() throws VerificationException { String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s6.2-presented-sdjwtvp.txt"); SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString); - JsonNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json"); + ObjectNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json"); String presentation = sdJwtVP.present(null, keyBindingClaims, - TestSettings.getInstance().getHolderSignerContext(), SD_JWT_VC_FORMAT); + TestSettings.getInstance().getHolderSignerContext()); SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation); assertTrue(presenteSdJwtVP.getKeyBindingJWT().isPresent()); @@ -168,9 +165,9 @@ public abstract class SdJwtVPTest { public void testS6_2_PresentationNegative() throws VerificationException { String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s6.2-presented-sdjwtvp.txt"); SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString); - JsonNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json"); + ObjectNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json"); String presentation = sdJwtVP.present(null, keyBindingClaims, - TestSettings.getInstance().getHolderSignerContext(), SD_JWT_VC_FORMAT); + TestSettings.getInstance().getHolderSignerContext()); SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation); assertTrue(presenteSdJwtVP.getKeyBindingJWT().isPresent()); @@ -186,10 +183,10 @@ public abstract class SdJwtVPTest { public void testS6_2_PresentationPartialDisclosure() throws VerificationException { String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s6.2-presented-sdjwtvp.txt"); SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString); - JsonNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json"); + ObjectNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json"); // disclose only the given_name String presentation = sdJwtVP.present(Arrays.asList("jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4"), - keyBindingClaims, TestSettings.getInstance().getHolderSignerContext(), SD_JWT_VC_FORMAT); + keyBindingClaims, TestSettings.getInstance().getHolderSignerContext()); SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation); assertTrue(presenteSdJwtVP.getKeyBindingJWT().isPresent()); diff --git a/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/SdJwtVPVerificationTest.java b/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/SdJwtVPVerificationTest.java index 5a4f5e51b91..d88576fa083 100644 --- a/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/SdJwtVPVerificationTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/SdJwtVPVerificationTest.java @@ -33,19 +33,19 @@ import org.keycloak.sdjwt.vp.KeyBindingJWT; import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts; import org.keycloak.sdjwt.vp.SdJwtVP; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; import org.junit.ClassRule; import org.junit.Test; -import static org.keycloak.OID4VCConstants.CLAIM_NAME_EXP; import static org.keycloak.OID4VCConstants.CLAIM_NAME_IAT; -import static org.keycloak.OID4VCConstants.CLAIM_NAME_NBF; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; /** @@ -214,9 +214,9 @@ public abstract class SdJwtVPVerificationTest { testShouldFailGeneric( "sdjwt/s20.1-sdjwt+kb.txt", defaultKeyBindingJwtVerificationOpts() - .withNonce("abcd") // kb's nonce is "1234567890" + .withNonceCheck("abcd") // kb's nonce is "1234567890" .build(), - "Key binding JWT: Unexpected `nonce` value", + "Expected value 'abcd' in token for claim 'nonce' does not match actual value '1234567890'", null ); } @@ -226,9 +226,9 @@ public abstract class SdJwtVPVerificationTest { testShouldFailGeneric( "sdjwt/s20.1-sdjwt+kb.txt", defaultKeyBindingJwtVerificationOpts() - .withAud("abcd") // kb's aud is "https://verifier.example.org" + .withAudCheck("abcd") // kb's aud is "https://verifier.example.org" .build(), - "Key binding JWT: Unexpected `aud` value", + "Expected audience 'abcd' not available in the token. Present values are '[https://verifier.example.org]'", null ); } @@ -268,13 +268,13 @@ public abstract class SdJwtVPVerificationTest { long now = Instant.now().getEpochSecond(); ObjectNode kbPayload = exampleKbPayload(); - kbPayload.set(CLAIM_NAME_IAT, mapper.valueToTree(now + 1000)); + kbPayload.set(OID4VCConstants.CLAIM_NAME_IAT, mapper.valueToTree(now + 1000)); - testShouldFailGeneric2( + testShouldFailGenericMatchText( kbPayload, defaultKeyBindingJwtVerificationOpts().build(), - "Key binding JWT: Invalid `iat` claim", - "JWT was issued in the future" + "Token was issued in the future: now: '\\d+', iat: '\\d+'", + null ); } @@ -284,14 +284,15 @@ public abstract class SdJwtVPVerificationTest { ObjectNode kbPayload = exampleKbPayload(); // Issued just 5 seconds in the future. Should pass with a clock skew of 10 seconds. - kbPayload.set(CLAIM_NAME_IAT, mapper.valueToTree(now + 5)); + kbPayload.set(OID4VCConstants.CLAIM_NAME_IAT, mapper.valueToTree(now + 5)); SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayload); sdJwtVP.verify( defaultIssuerVerifyingKeys(), defaultIssuerSignedJwtVerificationOpts().build(), defaultKeyBindingJwtVerificationOpts() - .withAllowedClockSkew(10) + .withClockSkew(10) + .withIatCheck(Integer.MAX_VALUE, false) .build() ); } @@ -302,14 +303,14 @@ public abstract class SdJwtVPVerificationTest { ObjectNode kbPayload = exampleKbPayload(); // This KB-JWT is then issued more than 60s ago - kbPayload.set(CLAIM_NAME_IAT, mapper.valueToTree(issuerSignedJwtIat - 120)); + kbPayload.set(OID4VCConstants.CLAIM_NAME_IAT, mapper.valueToTree(issuerSignedJwtIat - 120)); - testShouldFailGeneric2( + testShouldFailGenericMatchText( kbPayload, defaultKeyBindingJwtVerificationOpts() - .withAllowedMaxAge(60) + .withIatCheck(60) .build(), - "Key binding JWT is too old", + "Token has expired by iat: now: '\\d+', expired at: '\\d+', iat: '\\d+', maxLifetime: '60'", null ); } @@ -319,13 +320,13 @@ public abstract class SdJwtVPVerificationTest { long now = Instant.now().getEpochSecond(); ObjectNode kbPayload = exampleKbPayload(); - kbPayload.set(CLAIM_NAME_EXP, mapper.valueToTree(now - 1000)); + kbPayload.set(OID4VCConstants.CLAIM_NAME_EXP, mapper.valueToTree(now - 1000)); - testShouldFailGeneric2( + testShouldFailGenericMatchText( kbPayload, defaultKeyBindingJwtVerificationOpts().build(), - "Key binding JWT: Invalid `exp` claim", - "JWT has expired" + "Token has expired by exp: now: '\\d+', exp: '\\d+'", + null ); } @@ -335,15 +336,15 @@ public abstract class SdJwtVPVerificationTest { ObjectNode kbPayload = exampleKbPayload(); // Expires just 5 seconds ago. Should pass with a clock skew of 10 seconds. - kbPayload.set(CLAIM_NAME_EXP, mapper.valueToTree(now - 5)); + kbPayload.set(OID4VCConstants.CLAIM_NAME_EXP, mapper.valueToTree(now - 5)); SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayload); sdJwtVP.verify( defaultIssuerVerifyingKeys(), defaultIssuerSignedJwtVerificationOpts().build(), defaultKeyBindingJwtVerificationOpts() - .withRequireExpirationClaim(true) - .withAllowedClockSkew(10) + .withClockSkew(10) + .withExpCheck() .build() ); } @@ -353,13 +354,13 @@ public abstract class SdJwtVPVerificationTest { long now = Instant.now().getEpochSecond(); ObjectNode kbPayload = exampleKbPayload(); - kbPayload.set(CLAIM_NAME_NBF, mapper.valueToTree(now + 1000)); + kbPayload.set(OID4VCConstants.CLAIM_NAME_NBF, mapper.valueToTree(now + 1000)); - testShouldFailGeneric2( + testShouldFailGenericMatchText( kbPayload, defaultKeyBindingJwtVerificationOpts().build(), - "Key binding JWT: Invalid `nbf` claim", - "JWT is not yet valid" + "Token is not yet valid: now: '\\d+', nbf: '\\d+'", + null ); } @@ -431,19 +432,17 @@ public abstract class SdJwtVPVerificationTest { * This test helper allows replacing the key binding JWT of base * sample `sdjwt/s20.1-sdjwt+kb.txt` to cover different scenarios. */ - private void testShouldFailGeneric2( - JsonNode kbPayloadSubstitute, - KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts, - String exceptionMessage, - String exceptionCauseMessage - ) { + private void testShouldFailGeneric2(ObjectNode kbPayloadSubstitute, + KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts, + String exceptionMessage, + String exceptionCauseMessage) { SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayloadSubstitute); VerificationException exception = assertThrows( VerificationException.class, () -> sdJwtVP.verify( defaultIssuerVerifyingKeys(), - defaultIssuerSignedJwtVerificationOpts().build(), + defaultIssuerSignedJwtVerificationOpts().withIatCheck(Integer.MAX_VALUE).build(), keyBindingJwtVerificationOpts ) ); @@ -454,24 +453,52 @@ public abstract class SdJwtVPVerificationTest { } } + /** + * This test helper allows replacing the key binding JWT of base + * sample `sdjwt/s20.1-sdjwt+kb.txt` to cover different scenarios. + */ + private void testShouldFailGenericMatchText(ObjectNode kbPayloadSubstitute, + KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts, + String exceptionMessage, + String exceptionCauseMessage) { + SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayloadSubstitute); + + VerificationException exception = assertThrows( + VerificationException.class, + () -> sdJwtVP.verify( + defaultIssuerVerifyingKeys(), + defaultIssuerSignedJwtVerificationOpts().withIatCheck(Integer.MAX_VALUE).build(), + keyBindingJwtVerificationOpts + ) + ); + + MatcherAssert.assertThat(exception.getMessage(), CoreMatchers.anything().matches(exceptionMessage)); + if (exceptionCauseMessage != null) { + assertNotNull(exception.getCause()); + MatcherAssert.assertThat(exception.getCause().getMessage(), + CoreMatchers.anything().matches(exceptionMessage)); + } + } + private List defaultIssuerVerifyingKeys() { return Collections.singletonList(testSettings.issuerVerifierContext); } private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() { return IssuerSignedJwtVerificationOpts.builder() - .withRequireIssuedAtClaim(false) - .withRequireNotBeforeClaim(false); + .withClockSkew(0) + .withIatCheck(Integer.MAX_VALUE, true) + .withNbfCheck(true); } private KeyBindingJwtVerificationOpts.Builder defaultKeyBindingJwtVerificationOpts() { return KeyBindingJwtVerificationOpts.builder() - .withKeyBindingRequired(true) - .withAllowedMaxAge(Integer.MAX_VALUE) - .withNonce("1234567890") - .withAud("https://verifier.example.org") - .withRequireExpirationClaim(false) - .withRequireNotBeforeClaim(false); + .withKeyBindingRequired(true) + .withNonceCheck("1234567890") + .withAudCheck("https://verifier.example.org") + .withIatCheck(Integer.MAX_VALUE, false) + .withExpCheck(true) + .withNbfCheck(true); } private ObjectNode exampleKbPayload() { @@ -484,16 +511,12 @@ public abstract class SdJwtVPVerificationTest { return payload; } - private SdJwtVP exampleSdJwtWithCustomKbPayload(JsonNode kbPayloadSubstitute) { - KeyBindingJWT keyBindingJWT = KeyBindingJWT.from( - kbPayloadSubstitute, - testSettings.holderSigContext, - KeyBindingJWT.TYP - ); + private SdJwtVP exampleSdJwtWithCustomKbPayload(ObjectNode kbPayloadSubstitute) { + KeyBindingJWT keyBindingJWT = new KeyBindingJWT(kbPayloadSubstitute, testSettings.holderSigContext); String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s20.1-sdjwt+kb.txt"); String sdJwtWithoutKb = sdJwtVPString.substring(0, sdJwtVPString.lastIndexOf(OID4VCConstants.SDJWT_DELIMITER) + 1); - return SdJwtVP.of(sdJwtWithoutKb + keyBindingJWT.toJws()); + return SdJwtVP.of(sdJwtWithoutKb + keyBindingJWT.getJws()); } } diff --git a/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/TestCompareSdJwt.java b/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/TestCompareSdJwt.java index 2c12332d779..697c18b5d40 100644 --- a/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/TestCompareSdJwt.java +++ b/core/src/test/java/org/keycloak/sdjwt/sdjwtvp/TestCompareSdJwt.java @@ -28,7 +28,6 @@ import org.keycloak.sdjwt.SdJwtUtils; import org.keycloak.sdjwt.vp.SdJwtVP; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import static org.junit.Assert.assertEquals; @@ -47,7 +46,7 @@ import static org.junit.Assert.assertTrue; * the spec, i had to produce * the same print. This is by no way reliable enough to be used to test * conformity to the spec. - * + * * @author Francis Pouatcha */ public class TestCompareSdJwt { @@ -64,12 +63,12 @@ public class TestCompareSdJwt { } private static void compareIssuerSignedJWT(IssuerSignedJWT e, IssuerSignedJWT a) - throws JsonMappingException, JsonProcessingException { + throws JsonProcessingException { assertEquals(e.getPayload(), a.getPayload()); - List expectedJwsStrings = Arrays.asList(e.toJws().split("\\.")); - List actualJwsStrings = Arrays.asList(a.toJws().split("\\.")); + List expectedJwsStrings = Arrays.asList(e.getJws().split("\\.")); + List actualJwsStrings = Arrays.asList(a.getJws().split("\\.")); // compare json content of header assertEquals(toJsonNode(expectedJwsStrings.get(0)), toJsonNode(actualJwsStrings.get(0))); diff --git a/core/src/test/resources/sdjwt/s7.2-issuer-payload.json b/core/src/test/resources/sdjwt/s7.2-issuer-payload.json index 18b48088d4b..a39b8a39b4e 100644 --- a/core/src/test/resources/sdjwt/s7.2-issuer-payload.json +++ b/core/src/test/resources/sdjwt/s7.2-issuer-payload.json @@ -10,6 +10,5 @@ "KURDPh4ZC19-3tiz-Df39V8eidy1oV3a3H1Da2N0g88", "WN9r9dCBJ8HTCsS2jKASxTjEyW5m5x65_Z_2ro2jfXM" ] - }, - "_sd_alg": "sha-256" + } } diff --git a/core/src/test/resources/sdjwt/s7.2b-issuer-payload.json b/core/src/test/resources/sdjwt/s7.2b-issuer-payload.json index 57e0380aa07..d709142657d 100644 --- a/core/src/test/resources/sdjwt/s7.2b-issuer-payload.json +++ b/core/src/test/resources/sdjwt/s7.2b-issuer-payload.json @@ -10,6 +10,5 @@ "KURDPh4ZC19-3tiz-Df39V8eidy1oV3a3H1Da2N0g88" ], "country": "DE" - }, - "_sd_alg": "sha-256" + } } diff --git a/crypto/default/src/test/java/org/keycloak/crypto/def/test/sdjwt/DefaultCryptoSdJwtCreationAndSigningTest.java b/crypto/default/src/test/java/org/keycloak/crypto/def/test/sdjwt/DefaultCryptoSdJwtCreationAndSigningTest.java new file mode 100644 index 00000000000..965fe0bf6ba --- /dev/null +++ b/crypto/default/src/test/java/org/keycloak/crypto/def/test/sdjwt/DefaultCryptoSdJwtCreationAndSigningTest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 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.keycloak.common.util.Environment; +import org.keycloak.sdjwt.SdJwtCreationAndSigningTest; + +import org.junit.Assume; +import org.junit.Before; + +/** + * @author Pascal Knueppel + * @since 13.11.2025 + */ +public class DefaultCryptoSdJwtCreationAndSigningTest extends SdJwtCreationAndSigningTest { + + @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()); + } +} diff --git a/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/sdjwt/ElytronCryptoSdJwtCreationAndSigningTest.java b/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/sdjwt/ElytronCryptoSdJwtCreationAndSigningTest.java new file mode 100644 index 00000000000..71ea8a747b4 --- /dev/null +++ b/crypto/elytron/src/test/java/org/keycloak/crypto/elytron/test/sdjwt/ElytronCryptoSdJwtCreationAndSigningTest.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 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.SdJwtCreationAndSigningTest; + +/** + * @author Pascal Knueppel + * @since 13.11.2025 + */ +public class ElytronCryptoSdJwtCreationAndSigningTest extends SdJwtCreationAndSigningTest { + +} diff --git a/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/sdjwt/FIPS1402SdJwtCreationAndSigningTest.java b/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/sdjwt/FIPS1402SdJwtCreationAndSigningTest.java new file mode 100644 index 00000000000..3973c7c2b93 --- /dev/null +++ b/crypto/fips1402/src/test/java/org/keycloak/crypto/fips/test/sdjwt/FIPS1402SdJwtCreationAndSigningTest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 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.fips.test.sdjwt; + +import org.keycloak.common.util.Environment; +import org.keycloak.sdjwt.SdJwtCreationAndSigningTest; + +import org.junit.Assume; +import org.junit.Before; + +/** + * @author Pascal Knueppel + * @since 13.11.2025 + */ +public class FIPS1402SdJwtCreationAndSigningTest extends SdJwtCreationAndSigningTest { + + @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()); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBody.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBody.java index 0563e68d0f6..067387a94cc 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBody.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBody.java @@ -17,14 +17,13 @@ package org.keycloak.protocol.oid4vc.issuance.credentialbuilder; -import java.util.Map; - import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.jose.jwk.JWK; +import org.keycloak.sdjwt.IssuerSignedJWT; import org.keycloak.sdjwt.SdJwt; import org.keycloak.util.JsonSerialization; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import static org.keycloak.OID4VCConstants.CLAIM_NAME_CNF; import static org.keycloak.OID4VCConstants.CLAIM_NAME_JWK; @@ -35,27 +34,27 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_JWK; public class SdJwtCredentialBody implements CredentialBody { private final SdJwt.Builder sdJwtBuilder; - private final Map claimSet; + private final IssuerSignedJWT issuerSignedJWT; - public SdJwtCredentialBody(SdJwt.Builder sdJwtBuilder, Map claimSet) { + public SdJwtCredentialBody(SdJwt.Builder sdJwtBuilder, IssuerSignedJWT issuerSignedJWT) { this.sdJwtBuilder = sdJwtBuilder; - this.claimSet = claimSet; + this.issuerSignedJWT = issuerSignedJWT; } public void addKeyBinding(JWK jwk) throws CredentialBuilderException { - claimSet.put(CLAIM_NAME_CNF, Map.of(CLAIM_NAME_JWK, jwk)); + ObjectNode jwkNode = JsonSerialization.mapper.convertValue(jwk, ObjectNode.class); + ObjectNode keyBindingNode = JsonSerialization.mapper.createObjectNode(); + keyBindingNode.set(CLAIM_NAME_JWK, jwkNode); + issuerSignedJWT.getPayload().set(CLAIM_NAME_CNF, keyBindingNode); } - public Map getClaimSet() { - return claimSet; + public IssuerSignedJWT getIssuerSignedJWT() { + return issuerSignedJWT; } public String sign(SignatureSignerContext signatureSignerContext) { - JsonNode claimSet = JsonSerialization.mapper.valueToTree(this.claimSet); - SdJwt sdJwt = sdJwtBuilder - .withClaimSet(claimSet) - .withSigner(signatureSignerContext) - .build(); + SdJwt sdJwt = sdJwtBuilder.withIssuerSignedJwt(issuerSignedJWT) + .build(signatureSignerContext); return sdJwt.toSdJwtString(); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java index 8a2017e5b28..b8046a2eb37 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java @@ -26,8 +26,12 @@ import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.sdjwt.DisclosureSpec; +import org.keycloak.sdjwt.IssuerSignedJWT; import org.keycloak.sdjwt.SdJwt; import org.keycloak.sdjwt.SdJwtUtils; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.databind.node.ObjectNode; public class SdJwtCredentialBuilder implements CredentialBuilder { @@ -43,10 +47,9 @@ public class SdJwtCredentialBuilder implements CredentialBuilder { } @Override - public SdJwtCredentialBody buildCredentialBody( - VerifiableCredential verifiableCredential, - CredentialBuildConfig credentialBuildConfig - ) throws CredentialBuilderException { + public SdJwtCredentialBody buildCredentialBody(VerifiableCredential verifiableCredential, + CredentialBuildConfig credentialBuildConfig) + throws CredentialBuilderException { // Retrieve claims CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject(); Map claimSet = credentialSubject.getClaims(); @@ -84,11 +87,15 @@ public class SdJwtCredentialBuilder implements CredentialBuilder { .forEach(i -> disclosureSpecBuilder.withDecoyClaim(SdJwtUtils.randomSalt())); } - var sdJwtBuilder = SdJwt.builder() - .withDisclosureSpec(disclosureSpecBuilder.build()) - .withHashAlgorithm(credentialBuildConfig.getHashAlgorithm()) - .withJwsType(credentialBuildConfig.getTokenJwsType()); + ObjectNode claimsNode = JsonSerialization.mapper.convertValue(claimSet, ObjectNode.class); + IssuerSignedJWT issuerSignedJWT = IssuerSignedJWT.builder() + .withClaims(claimsNode, + disclosureSpecBuilder.build()) + .withHashAlg(credentialBuildConfig.getHashAlgorithm()) + .withJwsType(credentialBuildConfig.getTokenJwsType()) + .build(); + SdJwt.Builder sdJwtBuilder = SdJwt.builder(); - return new SdJwtCredentialBody(sdJwtBuilder, claimSet); + return new SdJwtCredentialBody(sdJwtBuilder, issuerSignedJWT); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java index e0785cd51d0..2754f39a00e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import org.keycloak.OID4VCConstants; import org.keycloak.common.VerificationException; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBody; import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder; @@ -28,6 +29,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.sdjwt.IssuerSignedJWT; import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts; +import org.keycloak.sdjwt.SdJwt; import org.keycloak.sdjwt.vp.SdJwtVP; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -95,7 +97,7 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest { .setCredentialIssuer(issuerDid) .setCredentialType("https://credentials.example.com/test-credential") .setTokenJwsType("example+sd-jwt") - .setHashAlgorithm("sha-256") + .setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM) .setNumberOfDecoys(decoys) .setSdJwtVisibleClaims(visibleClaims); @@ -118,7 +120,7 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest { assertEquals("The JWS token type should be included", credentialBuildConfig.getTokenJwsType(), - jwt.getHeader().getType()); + jwt.getJwsHeader().getType()); ArrayNode sdArrayNode = (ArrayNode) jwt.getPayload().get(CLAIM_NAME_SD); if (sdArrayNode != null) { @@ -129,7 +131,8 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest { List disclosed = sdJwt.getDisclosures().values().stream().toList(); assertEquals("All undisclosed claims and decoys should be provided.", - disclosed.size() + decoys, sdArrayNode == null ? 0 : sdArrayNode.size()); + disclosed.size() + (decoys == 0 ? SdJwt.DEFAULT_NUMBER_OF_DECOYS : decoys), + sdArrayNode == null ? 0 : sdArrayNode.size()); visibleClaims.forEach(vc -> assertTrue("The visible claims should be present within the token.", @@ -137,14 +140,13 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest { ); // Will check disclosure conformity - sdJwt.getSdJwtVerificationContext().verifyIssuance( - List.of(exampleVerifier()), - IssuerSignedJwtVerificationOpts.builder() - .withRequireIssuedAtClaim(false) - .withRequireNotBeforeClaim(false) - .withRequireExpirationClaim(false) - .build(), - null - ); + sdJwt.getSdJwtVerificationContext() + .verifyIssuance(List.of(exampleVerifier()), + IssuerSignedJwtVerificationOpts.builder() + .withIatCheck(true) + .withNbfCheck(true) + .withExpCheck(true) + .build(), + null); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java index 750fdcfa0ff..c121aec928f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -550,7 +550,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { protected void handleCredentialResponse(CredentialResponse credentialResponse, ClientScopeRepresentation clientScope) throws VerificationException { // SDJWT have a special format. SdJwtVP sdJwtVP = SdJwtVP.of(credentialResponse.getCredentials().get(0).getCredential().toString()); - JsonWebToken jsonWebToken = TokenVerifier.create(sdJwtVP.getIssuerSignedJWT().toJws(), JsonWebToken.class).getToken(); + JsonWebToken jsonWebToken = TokenVerifier.create(sdJwtVP.getIssuerSignedJWT().getJws(), JsonWebToken.class).getToken(); assertNotNull("A valid credential string should have been responded", jsonWebToken); assertNotNull("The credentials should include the id claim", jsonWebToken.getId()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java index 5387a77e4ec..8369087e564 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java @@ -25,6 +25,7 @@ import java.util.Map; import java.util.StringJoiner; import java.util.UUID; +import org.keycloak.OID4VCConstants; import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; import org.keycloak.common.util.MultivaluedHashMap; @@ -44,6 +45,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.sdjwt.SdJwt; import org.keycloak.sdjwt.SdJwtUtils; import org.keycloak.testsuite.runonserver.RunOnServerException; import org.keycloak.util.JsonSerialization; @@ -207,7 +209,7 @@ public class SdJwtCredentialSignerTest extends OID4VCTest { .setCredentialIssuer(TEST_DID.toString()) .setCredentialType("https://credentials.example.com/test-credential") .setTokenJwsType("example+sd-jwt") - .setHashAlgorithm("sha-256") + .setHashAlgorithm(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM) .setNumberOfDecoys(decoys) .setSdJwtVisibleClaims(visibleClaims) .setSigningKeyId(signingKeyId) @@ -267,11 +269,13 @@ public class SdJwtCredentialSignerTest extends OID4VCTest { assertEquals("The type should be included", "https://credentials.example.com/test-credential", theToken.getOtherClaims().get("vct")); List sds = (List) theToken.getOtherClaims().get(CLAIM_NAME_SD); if (sds != null && !sds.isEmpty()) { - assertEquals("The algorithm should be included", "sha-256", theToken.getOtherClaims().get(CLAIM_NAME_SD_HASH_ALGORITHM)); + assertEquals("The algorithm should be included", OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM, theToken.getOtherClaims().get(CLAIM_NAME_SD_HASH_ALGORITHM)); } List disclosed = Arrays.asList(splittedSdToken).subList(1, splittedSdToken.length); int numSds = sds != null ? sds.size() : 0; - assertEquals("All undisclosed claims and decoys should be provided.", disclosed.size() + decoys, numSds); + assertEquals("All undisclosed claims and decoys should be provided.", + disclosed.size() + (decoys == 0 ? decoys + SdJwt.DEFAULT_NUMBER_OF_DECOYS : decoys), + numSds); verifyDisclosures(sds, disclosed); visibleClaims @@ -322,7 +326,7 @@ public class SdJwtCredentialSignerTest extends OID4VCTest { private String createHash(String salt, String key, Object value) { try { return SdJwtUtils.encodeNoPad( - HashUtils.hash("sha-256", + HashUtils.hash(OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM, SdJwtUtils.encodeNoPad( SdJwtUtils.printJsonArray(List.of(salt, key, value).toArray()).getBytes()).getBytes())); } catch (JsonProcessingException e) {