mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
[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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
552
core/src/main/java/org/keycloak/sdjwt/ClaimVerifier.java
Normal file
552
core/src/main/java/org/keycloak/sdjwt/ClaimVerifier.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
core/src/main/java/org/keycloak/sdjwt/JwsToken.java
Normal file
150
core/src/main/java/org/keycloak/sdjwt/JwsToken.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
core/src/main/java/org/keycloak/sdjwt/SdJwtClaimFactory.java
Normal file
119
core/src/main/java/org/keycloak/sdjwt/SdJwtClaimFactory.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
83
core/src/test/java/org/keycloak/sdjwt/ClaimVerifierTest.java
Normal file
83
core/src/test/java/org/keycloak/sdjwt/ClaimVerifierTest.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -10,6 +10,5 @@
|
||||
"KURDPh4ZC19-3tiz-Df39V8eidy1oV3a3H1Da2N0g88",
|
||||
"WN9r9dCBJ8HTCsS2jKASxTjEyW5m5x65_Z_2ro2jfXM"
|
||||
]
|
||||
},
|
||||
"_sd_alg": "sha-256"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,5 @@
|
||||
"KURDPh4ZC19-3tiz-Df39V8eidy1oV3a3H1Da2N0g88"
|
||||
],
|
||||
"country": "DE"
|
||||
},
|
||||
"_sd_alg": "sha-256"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user