[OID4VCI] Redesign SDJwt API and handle keybinding JWT (#44227)

closes #42091


Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de>
Signed-off-by: Captain-P-Goldfish <captain.p.goldfish@gmx.de>
Signed-off-by: mposolda <mposolda@gmail.com>
Co-authored-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Pascal Knüppel
2025-11-24 11:01:19 +01:00
committed by GitHub
parent 081d8e5a01
commit 64d5e1a3d5
60 changed files with 3423 additions and 1429 deletions

View File

@@ -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;

View File

@@ -248,7 +248,7 @@ public class TokenVerifier<T extends JsonWebToken> {
* <ul>
* <li>Realm URL (JWT issuer field: {@code iss}) has to be defined and match realm set via {@link #realmUrl(java.lang.String)} method</li>
* <li>Subject (JWT subject field: {@code sub}) has to be defined</li>
* <li>Token type (JWT type field: {@code typ}) has to be {@code Bearer}. The type can be set via {@link #tokenType(java.lang.String)} method</li>
* <li>Token type (JWT type field: {@code typ}) has to be {@code Bearer}. The type can be set via {@link #tokenType(List)} method</li>
* <li>Token has to be active, ie. both not expired and not used before its validity (JWT issuer fields: {@code exp} and {@code nbf})</li>
* </ul>
* @return This token verifier.

View File

@@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@@ -53,6 +59,8 @@ public class JWSHeader implements JOSEHeader {
@JsonProperty("x5c")
private List<String> x5c;
private Map<String, Object> 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<String> getX5c() {
return x5c;
}
private static final ObjectMapper mapper = new ObjectMapper();
public void setX5c(List<String> 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<String, Object> 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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -16,9 +16,11 @@
*/
package org.keycloak.sdjwt;
import java.util.Objects;
/**
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*
*/
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);
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*
*/
public class ArrayDisclosure extends AbstractSdJwtClaim {
private final List<SdJwtArrayElement> 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<SdJwtArrayElement> elements = new ArrayList<>();

View File

@@ -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. <br/>
* Time-checks include a small tolerance to account for clock skew.
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class ClaimVerifier {
private final List<Predicate<ObjectNode>> headerVerifiers;
private final List<Predicate<ObjectNode>> contentVerifiers;
public ClaimVerifier(List<ClaimVerifier.Predicate<ObjectNode>> headerVerifiers,
List<Predicate<ObjectNode>> 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<ObjectNode> verifier : headerVerifiers) {
verifier.test(header);
}
}
public void verifyBodyClaims(ObjectNode body) throws VerificationException {
for (Predicate<ObjectNode> verifier : contentVerifiers) {
verifier.test(body);
}
}
public List<ClaimVerifier.Predicate<ObjectNode>> getContentVerifiers() {
return contentVerifiers;
}
public static ClaimVerifier.Builder builder() {
return new ClaimVerifier.Builder();
}
/**
* Functional interface of checks that verify some part of a JWT.
*
* @param <T> Type of the token handled by this predicate.
*/
public interface Predicate<T> {
/**
* 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<ObjectNode> {
private final String claimName;
private final String expectedClaimValue;
private final BiFunction<String, String, Boolean> 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<String, String, Boolean> stringComparator) {
this(claimName, expectedClaimValue, stringComparator, false);
}
public ClaimCheck(String claimName,
String expectedClaimValue,
BiFunction<String, String, Boolean> 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<String, String, Boolean> 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<String, String, Boolean> stringComparator) {
super(claimName, expectedClaimValue, stringComparator);
}
public NegatedClaimCheck(String claimName,
String expectedClaimValue,
BiFunction<String, String, Boolean> 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<ObjectNode> {
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<ObjectNode> {
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<ObjectNode> {
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<ObjectNode> {
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<String> 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<ClaimVerifier.Predicate<ObjectNode>> headerVerifiers = new ArrayList<>();
protected List<ClaimVerifier.Predicate<ObjectNode>> 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<ClaimVerifier.Predicate<ObjectNode>> contentVerifiers) {
this.contentVerifiers = contentVerifiers;
return this;
}
public Builder addContentVerifiers(List<ClaimVerifier.Predicate<ObjectNode>> contentVerifiers) {
this.contentVerifiers = Optional.ofNullable(this.contentVerifiers).orElseGet(ArrayList::new);
this.contentVerifiers.addAll(contentVerifiers);
return this;
}
public ClaimVerifier build() {
return new ClaimVerifier(headerVerifiers, contentVerifiers);
}
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;

View File

@@ -22,19 +22,14 @@ import org.keycloak.jose.jws.crypto.HashUtils;
/**
* Handles hash production for a decoy entry from the given salt.
*
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*
*/
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) {

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*
*/
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();
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*
*/
public class DisclosureSpec {
@@ -45,9 +47,9 @@ public class DisclosureSpec {
private final Map<SdJwtClaimName, Map<Integer, DisclosureData>> decoyArrayElts;
private DisclosureSpec(Map<SdJwtClaimName, DisclosureData> undisclosedClaims,
List<DisclosureData> decoyClaims,
Map<SdJwtClaimName, Map<Integer, DisclosureData>> undisclosedArrayElts,
Map<SdJwtClaimName, Map<Integer, DisclosureData>> decoyArrayElts) {
List<DisclosureData> decoyClaims,
Map<SdJwtClaimName, Map<Integer, DisclosureData>> undisclosedArrayElts,
Map<SdJwtClaimName, Map<Integer, DisclosureData>> decoyArrayElts) {
this.undisclosedClaims = undisclosedClaims;
this.decoyClaims = decoyClaims;
this.undisclosedArrayElts = undisclosedArrayElts;
@@ -80,6 +82,14 @@ public class DisclosureSpec {
return undisclosedArrayElts.containsKey(claimName);
}
public List<DecoyClaim> createDecoyClaims() {
return this.getDecoyClaims().stream()
.map(disclosureData -> {
return DecoyClaim.builder().withSalt(disclosureData.getSalt()).build();
})
.collect(Collectors.toList());
}
public static class Builder {
private final Map<SdJwtClaimName, DisclosureData> undisclosedClaims = new HashMap<>();
private final List<DisclosureData> 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);
}
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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<SdJwtClaim> disclosureClaims;
private List<DecoyClaim> 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<SdJwtClaim> claims, List<DecoyClaim> 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<SdJwtClaim> claims, List<DecoyClaim> 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<DecoyClaim> decoyClaims,
String hashAlg,
boolean nestedDisclosures) {
this(disclosureSpec, new JWSHeader(), disclosureClaims, decoyClaims, hashAlg, nestedDisclosures);
}
public IssuerSignedJWT(List<SdJwtClaim> disclosureClaims,
List<DecoyClaim> decoyClaims,
String hashAlg,
boolean nestedDisclosures) {
this(DisclosureSpec.builder().build(), new JWSHeader(),
disclosureClaims, decoyClaims, hashAlg, nestedDisclosures);
}
public IssuerSignedJWT(DisclosureSpec disclosureSpec,
JWSHeader jwsHeader,
ObjectNode disclosureClaims,
List<DecoyClaim> decoyClaims,
String hashAlg,
boolean nestedDisclosures) {
this(disclosureSpec,
jwsHeader,
SdJwtClaimFactory.parsePayload(disclosureClaims, disclosureSpec),
decoyClaims,
hashAlg,
nestedDisclosures);
}
public IssuerSignedJWT(DisclosureSpec disclosureSpec,
JWSHeader jwsHeader,
ObjectNode disclosureClaims,
List<DecoyClaim> 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<SdJwtClaim> disclosureClaims,
List<DecoyClaim> decoyClaims,
String hashAlg,
boolean nestedDisclosures) {
super(jwsHeader, generatePayloadString(disclosureClaims, decoyClaims, hashAlg, nestedDisclosures));
this.disclosureSpec = disclosureSpec;
this.disclosureClaims = disclosureClaims;
this.decoyClaims = decoyClaims;
}
public IssuerSignedJWT(List<SdJwtClaim> disclosureClaims,
List<DecoyClaim> 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<SdJwtClaim> disclosureClaims,
List<DecoyClaim> 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<SdJwtClaim> claims, List<DecoyClaim> decoyClaims, String hashAlg,
boolean nestedDisclosures) {
private static ObjectNode generatePayloadString(List<SdJwtClaim> claims,
List<DecoyClaim> decoyClaims,
String hashAlg,
boolean nestedDisclosures) {
SdJwtUtils.requireNonEmpty(hashAlg, "hashAlg must not be null or empty");
final List<SdJwtClaim> claimsInternal = claims == null ? Collections.emptyList()
: Collections.unmodifiableList(claims);
: Collections.unmodifiableList(claims);
final List<DecoyClaim> 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<String> 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<SdJwtSalt, UndisclosedClaim> 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<String> 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<String> 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<SdJwtClaim> getDisclosureClaims() {
return disclosureClaims;
}
public List<DecoyClaim> getDecoyClaims() {
return decoyClaims;
}
public void setDisclosureClaims(DisclosureSpec disclosureSpec,
List<SdJwtClaim> disclosureClaims,
List<DecoyClaim> decoyClaims) {
setDisclosureClaims(disclosureSpec, disclosureClaims, decoyClaims, null);
}
public void setDisclosureClaims(DisclosureSpec disclosureSpec,
List<SdJwtClaim> disclosureClaims,
List<DecoyClaim> 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<SdJwtClaim> claims;
private String hashAlg;
private SignatureSignerContext signer;
private List<DecoyClaim> 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<SdJwtClaim> getClaims() {
if (this.claims == null) {
this.claims = new ArrayList<>();
}
return claims;
}
public Builder withClaims(List<SdJwtClaim> 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<DecoyClaim> 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<String> 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);
}
}
}

View File

@@ -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 <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
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<ClaimVerifier.Predicate<ObjectNode>> headerVerifiers,
List<ClaimVerifier.Predicate<ObjectNode>> 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<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<ClaimVerifier.Predicate<ObjectNode>> contentVerifiers) {
return (Builder) super.withContentVerifiers(contentVerifiers);
}
@Override
public IssuerSignedJwtVerificationOpts.Builder addContentVerifiers(List<ClaimVerifier.Predicate<ObjectNode>> 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);
}
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*/
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<String> 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);
}
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*/
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<String> 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<String> vcts) throws VerificationException {
verifyClaimAgainstTrustedValues(vcts, "vct");
}
private void verifyClaimAgainstTrustedValues(List<String> 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));
}
}
}

View File

@@ -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<SdJwtClaim> claims;
private final List<String> disclosures = new ArrayList<>();
private final SdJwtVerificationContext sdJwtVerificationContext;
private final List<String> disclosures;
private SdJwt(DisclosureSpec disclosureSpec, JsonNode claimSet, List<SdJwt> nesteSdJwts,
Optional<KeyBindingJWT> 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<String> 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<String> sdJwtString = Optional.empty();
public SdJwt(IssuerSignedJWT issuerSignedJWT,
KeyBindingJWT keyBindingJWT,
List<SdJwt> 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<SdJwt> nesteSdJwts) {
this(new IssuerSignedJWT(new JWSHeader(), claimSet), keyBindingJWT, nesteSdJwts);
}
public SdJwt(IssuerSignedJWT issuerSignedJWT,
KeyBindingJWT keyBindingJWT,
List<SdJwtClaim> claims,
List<String> disclosures) {
this.issuerSignedJWT = issuerSignedJWT;
this.keyBindingJWT = keyBindingJWT;
this.claims = claims;
this.disclosures = disclosures;
}
private List<DecoyClaim> 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 {
* <p>
* 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<String> 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<String> getDisclosureStrings(List<SdJwtClaim> claims) {
List<String> 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<SdJwtClaim> getClaims() {
return claims;
}
public SdJwtVerificationContext getSdJwtVerificationContext() {
return sdJwtVerificationContext;
}
public void setSdJwtVerificationContext(SdJwtVerificationContext sdJwtVerificationContext) {
this.sdJwtVerificationContext = sdJwtVerificationContext;
}
public Optional<String> getSdJwtString() {
return sdJwtString;
}
public void setSdJwtString(Optional<String> 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<Integer, DisclosureSpec.DisclosureData> undisclosedArrayElts = disclosureSpec
.getUndisclosedArrayElts(sdJwtClaimName);
Map<Integer, DisclosureSpec.DisclosureData> 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<Integer, DisclosureSpec.DisclosureData> undisclosedArrayElts,
Map<Integer, DisclosureSpec.DisclosureData> 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<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts verificationOpts
) throws VerificationException {
public void verify(List<SignatureVerifierContext> 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> keyBindingJWT = Optional.empty();
private SignatureSignerContext signer;
private final List<SdJwt> 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<DecoyClaim> 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<String> 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);
});
}
}
}

View File

@@ -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<SdJwtClaim> parsePayload(ObjectNode objectNode, DisclosureSpec disclosureSpec) {
List<SdJwtClaim> 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<Integer, DisclosureSpec.DisclosureData> undisclosedArrayElts = //
disclosureSpec.getUndisclosedArrayElts(sdJwtClaimName);
Map<Integer, DisclosureSpec.DisclosureData> 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<Integer, DisclosureSpec.DisclosureData> undisclosedArrayElts,
Map<Integer, DisclosureSpec.DisclosureData> 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);
}
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;

View File

@@ -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);
}
/**

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
public class SdJwtSalt implements Comparable<SdJwtSalt> {
@@ -44,4 +46,19 @@ public class SdJwtSalt implements Comparable<SdJwtSalt> {
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);
}
}

View File

@@ -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);

View File

@@ -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<String, String> disclosures;
private KeyBindingJWT keyBindingJwt;
public SdJwtVerificationContext(
String sdJwtVpString,
IssuerSignedJWT issuerSignedJwt,
Map<String, String> disclosures,
KeyBindingJWT keyBindingJwt) {
public SdJwtVerificationContext(String sdJwtVpString,
IssuerSignedJWT issuerSignedJwt,
Map<String, String> 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.
*
* <p>Upon receiving an SD-JWT, a Holder or a Verifier needs to ensure that:</p>
* - 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<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
PresentationRequirements presentationRequirements
) throws VerificationException {
public void verifyIssuance(List<SignatureVerifierContext> 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<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts,
PresentationRequirements presentationRequirements
) throws VerificationException {
public void verifyPresentation(List<SignatureVerifierContext> 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.
*
* <p>
* 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.
* </p>
*
* @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.
*
* <p>
* 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.
* </p>
*
* @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.
*

View File

@@ -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 <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
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 <T extends Builder<T>> Builder<T> builder() {
return new Builder<>();
}
public static class Builder<T extends Builder<T>> {
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
);
}
}
}

View File

@@ -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 <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
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();
}
}

View File

@@ -23,7 +23,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_UNDISCLOSED_ARRAY;
/**
*
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;

View File

@@ -23,7 +23,7 @@ import java.util.Objects;
import com.fasterxml.jackson.databind.JsonNode;
/**
*
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;

View File

@@ -16,10 +16,12 @@
*/
package org.keycloak.sdjwt;
import java.util.Objects;
import com.fasterxml.jackson.databind.JsonNode;
/**
*
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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);
}
}

View File

@@ -23,7 +23,7 @@ import java.util.Objects;
import com.fasterxml.jackson.databind.JsonNode;
/**
*
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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();

View File

@@ -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);

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*
*/
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<String> 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);
}
}
}

View File

@@ -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 <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
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<ClaimVerifier.Predicate<ObjectNode>> headerVerifiers,
List<ClaimVerifier.Predicate<ObjectNode>> 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<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<ClaimVerifier.Predicate<ObjectNode>> contentVerifiers) {
return (Builder) super.withContentVerifiers(contentVerifiers);
}
@Override
public KeyBindingJwtVerificationOpts.Builder addContentVerifiers(List<ClaimVerifier.Predicate<ObjectNode>> 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);
}
}
}

View File

@@ -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<String, ArrayNode> claims = new HashMap<>();
Map<String, String> disclosures = new HashMap<>();
@@ -163,16 +163,15 @@ public class SdJwtVP {
Map<String, String> recursiveDigests = new HashMap<>();
List<String> 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> 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<String> disclosureDigests, JsonNode keyBindingClaims,
SignatureSignerContext holdSignatureSignerContext, String jwsType) {
public String present(List<String> 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<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
) throws VerificationException {
public void verify(List<SignatureVerifierContext> issuerVerifyingKeys,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts)
throws VerificationException
{
sdJwtVerificationContext.verifyPresentation(
issuerVerifyingKeys,
issuerSignedJwtVerificationOpts,

View File

@@ -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(),

View File

@@ -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());
}
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
@@ -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<SdJwtClaim> 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<SdJwtClaim> 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");

View File

@@ -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");

View File

@@ -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<String> 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<String> 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) {
};
}
}

View File

@@ -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<String> disclosureHashes = sdJwt.getClaims()
.stream()
.flatMap(sdJwtClaim -> {
return sdJwtClaim.getDisclosureStrings().stream();
})
.map(b64String -> {
return SdJwtUtils.hashAndBase64EncodeNoPad(b64String,
JavaAlgorithm.SHA256);
})
.collect(Collectors.toList());
List<String> decoyHashes = sdJwt.getIssuerSignedJWT()
.getDecoyClaims()
.stream()
.map(decoy -> decoy.getDisclosureDigest(JavaAlgorithm.SHA256))
.collect(Collectors.toList());
List<String> 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<String> 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<String> 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<String> 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<String> disclosureHashes = sdJwt.getClaims()
.stream()
.flatMap(sdJwtClaim -> {
return sdJwtClaim.getDisclosureStrings().stream();
})
.map(b64String -> {
return SdJwtUtils.hashAndBase64EncodeNoPad(b64String,
JavaAlgorithm.SHA256);
})
.collect(Collectors.toList());
List<String> decoyHashes = sdJwt.getIssuerSignedJWT()
.getDecoyClaims()
.stream()
.map(decoy -> decoy.getDisclosureDigest(JavaAlgorithm.SHA256))
.collect(Collectors.toList());
List<String> 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<String> 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<String> 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<String> 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<String> 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;
}
}

View File

@@ -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<ClaimVerifier.Predicate<ObjectNode>> headerVerifierList = new ArrayList<>();
List<ClaimVerifier.Predicate<ObjectNode>> bodyVerifierList = new ArrayList<>();
return new IssuerSignedJwtVerificationOpts(headerVerifierList, bodyVerifierList);
}
}

View File

@@ -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());
}
}

View File

@@ -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<SdJwt, VerificationException> 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<SdJwt, VerificationException> 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<SdJwt, VerificationException> 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<SignatureVerifierContext> 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);
}
}

View File

@@ -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();

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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

View File

@@ -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 <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
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<ClaimVerifier, VerificationException> 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();
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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());

View File

@@ -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<SignatureVerifierContext> 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());
}
}

View File

@@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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<String> expectedJwsStrings = Arrays.asList(e.toJws().split("\\."));
List<String> actualJwsStrings = Arrays.asList(a.toJws().split("\\."));
List<String> expectedJwsStrings = Arrays.asList(e.getJws().split("\\."));
List<String> actualJwsStrings = Arrays.asList(a.getJws().split("\\."));
// compare json content of header
assertEquals(toJsonNode(expectedJwsStrings.get(0)), toJsonNode(actualJwsStrings.get(0)));

View File

@@ -10,6 +10,5 @@
"KURDPh4ZC19-3tiz-Df39V8eidy1oV3a3H1Da2N0g88",
"WN9r9dCBJ8HTCsS2jKASxTjEyW5m5x65_Z_2ro2jfXM"
]
},
"_sd_alg": "sha-256"
}
}

View File

@@ -10,6 +10,5 @@
"KURDPh4ZC19-3tiz-Df39V8eidy1oV3a3H1Da2N0g88"
],
"country": "DE"
},
"_sd_alg": "sha-256"
}
}

View File

@@ -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());
}
}

View File

@@ -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 {
}

View File

@@ -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());
}
}

View File

@@ -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<String, Object> claimSet;
private final IssuerSignedJWT issuerSignedJWT;
public SdJwtCredentialBody(SdJwt.Builder sdJwtBuilder, Map<String, Object> 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<String, Object> 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();
}

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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());

View File

@@ -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<String> sds = (List<String>) 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<String> 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) {