Use the unified constants class for sd-jwt/oid4vc standard data and claims (#44153)

closes #44152

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Marek Posolda 2025-11-18 10:41:04 +01:00 committed by GitHub
parent 8ee23aaa15
commit a4c583246d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 255 additions and 170 deletions

View File

@ -0,0 +1,51 @@
package org.keycloak;
/**
* Constants related to the OID4VC and related specifications (like sd-jwt)
*/
public class OID4VCConstants {
// Sd-JWT constants
public static final String SDJWT_DELIMITER = "~";
public static final String SD_HASH = "sd_hash";
/**
* SD-JWT-Credentials {@see https://drafts.oauth.net/oauth-sd-jwt-vc/draft-ietf-oauth-sd-jwt-vc.html}
*/
public static final String SD_JWT_VC_FORMAT = "dc+sd-jwt";
public static final String CLAIM_NAME_SD = "_sd";
public static final String CLAIM_NAME_SD_HASH_ALGORITHM = "_sd_alg";
public static final String CLAIM_NAME_SD_UNDISCLOSED_ARRAY = "...";
public static final String CLAIM_NAME_IAT = "iat";
public static final String CLAIM_NAME_EXP = "exp";
public static final String CLAIM_NAME_NBF = "nbf";
public static final String CLAIM_NAME_ISSUER = "iss";
public static final String CLAIM_NAME_CNF = "cnf";
public static final String CLAIM_NAME_JWK = "jwk";
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;
/**
* JWT VC issuer endpoint {@see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc-13#section-5}
*/
public static final String JWT_VC_ISSUER_END_POINT = "/.well-known/jwt-vc-issuer";
/**
* https://www.w3.org/TR/2022/REC-vc-data-model-20220303/#credential-subject
*/
public static final String CREDENTIAL_SUBJECT = "credentialSubject";
/**
* https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-G.6.3
*/
public static final String SIGNED_METADATA_JWT_TYPE = "openidvci-issuer-metadata+jwt";
// --- Endpoints/Well-Known ---
public static final String WELL_KNOWN_OPENID_CREDENTIAL_ISSUER = "openid-credential-issuer";
public static final String RESPONSE_TYPE_IMG_PNG = "image/png";
public static final String CREDENTIAL_OFFER_URI_CODE_SCOPE = "credential-offer";
private OID4VCConstants() {
}
}

View File

@ -18,6 +18,8 @@ package org.keycloak.sdjwt;
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>
@ -32,7 +34,7 @@ public class DecoyArrayElement extends DecoyEntry {
}
public JsonNode getVisibleValue(String hashAlg) {
return SdJwtUtils.mapper.createObjectNode().put("...", getDisclosureDigest(hashAlg));
return SdJwtUtils.mapper.createObjectNode().put(CLAIM_NAME_SD_UNDISCLOSED_ARRAY, getDisclosureDigest(hashAlg));
}
public Integer getIndex() {

View File

@ -26,6 +26,7 @@ import java.util.Optional;
import java.util.Set;
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;
@ -34,6 +35,10 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_CNF;
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.
@ -117,7 +122,7 @@ public class IssuerSignedJWT extends SdJws {
if (sdArray.size() > 0) {
// drop _sd claim if empty
payload.set(CLAIM_NAME_SELECTIVE_DISCLOSURE, sdArray);
payload.set(CLAIM_NAME_SD, sdArray);
}
if (sdArray.size() > 0 || nestedDisclosures) {
// add sd alg only if ay disclosure.
@ -142,7 +147,7 @@ public class IssuerSignedJWT extends SdJws {
* Returns `cnf` claim (establishing key binding)
*/
public Optional<JsonNode> getCnfClaim() {
JsonNode cnf = getPayload().get("cnf");
JsonNode cnf = getPayload().get(CLAIM_NAME_CNF);
return Optional.ofNullable(cnf);
}
@ -175,10 +180,6 @@ public class IssuerSignedJWT extends SdJws {
}
}
// SD-JWT Claims
public static final String CLAIM_NAME_SELECTIVE_DISCLOSURE = "_sd";
public static final String CLAIM_NAME_SD_HASH_ALGORITHM = "_sd_alg";
// Builder
public static Builder builder() {
return new Builder();
@ -224,8 +225,8 @@ public class IssuerSignedJWT extends SdJws {
public IssuerSignedJWT build() {
// Preinitialize hashAlg to sha-256 if not provided
hashAlg = hashAlg == null ? "sha-256" : hashAlg;
jwsType = jwsType == null ? "dc+sd-jwt" : jwsType;
hashAlg = hashAlg == null ? OID4VCConstants.SD_HASH_DEFAULT_ALGORITHM : hashAlg;
jwsType = jwsType == null ? OID4VCConstants.SD_JWT_VC_FORMAT : jwsType;
// send an empty lise if claims not set.
claims = claims == null ? Collections.emptyList() : claims;
decoyClaims = decoyClaims == null ? Collections.emptyList() : decoyClaims;

View File

@ -28,9 +28,9 @@ public class IssuerSignedJwtVerificationOpts extends TimeClaimVerificationOpts {
boolean validateIssuedAtClaim,
boolean validateExpirationClaim,
boolean validateNotBeforeClaim,
int leewaySeconds
int allowedClockSkewSeconds
) {
super(validateIssuedAtClaim, validateExpirationClaim, validateNotBeforeClaim, leewaySeconds);
super(validateIssuedAtClaim, validateExpirationClaim, validateNotBeforeClaim, allowedClockSkewSeconds);
}
public static Builder builder() {
@ -45,7 +45,7 @@ public class IssuerSignedJwtVerificationOpts extends TimeClaimVerificationOpts {
requireIssuedAtClaim,
requireExpirationClaim,
requireNotBeforeClaim,
leewaySeconds
allowedClockSkewSeconds
);
}
}

View File

@ -31,6 +31,8 @@ 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.
*
@ -39,8 +41,6 @@ import com.fasterxml.jackson.databind.JsonNode;
*/
public abstract class SdJws {
public static final String CLAIM_NAME_ISSUER = "iss";
private final JWSInput jwsInput;
private final JsonNode payload;

View File

@ -24,6 +24,7 @@ import java.util.Optional;
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;
@ -34,13 +35,14 @@ 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;
/**
* Main entry class for selective disclosure jwt (SD-JWT).
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
public class SdJwt {
public static final String DELIMITER = "~";
private final IssuerSignedJWT issuerSignedJWT;
private final List<SdJwtClaim> claims;
@ -93,7 +95,7 @@ public class SdJwt {
*/
public JsonNode asNestedPayload() {
JsonNode nestedPayload = issuerSignedJWT.getPayload();
((ObjectNode) nestedPayload).remove(IssuerSignedJWT.CLAIM_NAME_SD_HASH_ALGORITHM);
((ObjectNode) nestedPayload).remove(CLAIM_NAME_SD_HASH_ALGORITHM);
return nestedPayload;
}
@ -104,7 +106,7 @@ public class SdJwt {
parts.addAll(disclosures);
parts.add("");
return String.join(DELIMITER, parts);
return String.join(OID4VCConstants.SDJWT_DELIMITER, parts);
}
private static List<String> getDisclosureStrings(List<SdJwtClaim> claims) {

View File

@ -39,6 +39,14 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.jboss.logging.Logger;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_JWK;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_UNDISCLOSED_ARRAY;
import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER;
import static org.keycloak.OID4VCConstants.SD_HASH;
/**
* Runs SD-JWT verification in isolation with only essential properties.
*
@ -263,7 +271,7 @@ public class SdJwtVerificationContext {
Objects.requireNonNull(cnf);
// Read JWK
JsonNode cnfJwk = cnf.get("jwk");
JsonNode cnfJwk = cnf.get(CLAIM_NAME_JWK);
if (cnfJwk == null) {
throw new UnsupportedOperationException("Only cnf/jwk claim supported");
}
@ -409,7 +417,7 @@ public class SdJwtVerificationContext {
if (currentNode.isObject()) {
ObjectNode currentObjectNode = ((ObjectNode) currentNode);
JsonNode sdArray = currentObjectNode.get(IssuerSignedJWT.CLAIM_NAME_SELECTIVE_DISCLOSURE);
JsonNode sdArray = currentObjectNode.get(CLAIM_NAME_SD);
if (sdArray != null && sdArray.isArray()) {
for (JsonNode el : sdArray) {
if (!el.isTextual()) {
@ -447,10 +455,10 @@ public class SdJwtVerificationContext {
// Remove all _sd keys and their contents from the Issuer-signed JWT payload.
// If this results in an object with no properties, it should be represented as an empty object {}
currentObjectNode.remove(IssuerSignedJWT.CLAIM_NAME_SELECTIVE_DISCLOSURE);
currentObjectNode.remove(CLAIM_NAME_SD);
// Remove the claim _sd_alg from the SD-JWT payload.
currentObjectNode.remove(IssuerSignedJWT.CLAIM_NAME_SD_HASH_ALGORITHM);
currentObjectNode.remove(CLAIM_NAME_SD_HASH_ALGORITHM);
}
// Find all array elements that are objects with one key, that key being ... and referring to a string
@ -463,7 +471,7 @@ public class SdJwtVerificationContext {
if (itemNode.isObject() && itemNode.size() == 1) {
// Check single "..." field
Map.Entry<String, JsonNode> field = itemNode.fields().next();
if (field.getKey().equals(UndisclosedArrayElement.SD_CLAIM_NAME)
if (field.getKey().equals(CLAIM_NAME_SD_UNDISCLOSED_ARRAY)
&& field.getValue().isTextual()) {
// Compare the value with the digests calculated previously and find the matching Disclosure.
// If no such Disclosure can be found, the digest MUST be ignored.
@ -565,8 +573,8 @@ public class SdJwtVerificationContext {
// If the claim name is _sd or ..., the SD-JWT MUST be rejected.
List<String> denylist = Arrays.asList(
IssuerSignedJWT.CLAIM_NAME_SELECTIVE_DISCLOSURE,
UndisclosedArrayElement.SD_CLAIM_NAME
CLAIM_NAME_SD,
CLAIM_NAME_SD_UNDISCLOSED_ARRAY
);
String claimName = arrayNode.get(1).asText();
@ -665,12 +673,12 @@ public class SdJwtVerificationContext {
private void validateKeyBindingJwtSdHashIntegrity() throws VerificationException {
Objects.requireNonNull(sdJwtVpString);
JsonNode sdHash = keyBindingJwt.getPayload().get("sd_hash");
JsonNode sdHash = keyBindingJwt.getPayload().get(SD_HASH);
if (sdHash == null || !sdHash.isTextual()) {
throw new VerificationException("Key binding JWT: Claim `sd_hash` missing or not a string");
}
int lastDelimiterIndex = sdJwtVpString.lastIndexOf(SdJwt.DELIMITER);
int lastDelimiterIndex = sdJwtVpString.lastIndexOf(SDJWT_DELIMITER);
String toHash = sdJwtVpString.substring(0, lastDelimiterIndex + 1);
String digest = SdJwtUtils.hashAndBase64EncodeNoPad(

View File

@ -17,6 +17,8 @@
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.
*
@ -24,8 +26,6 @@ package org.keycloak.sdjwt;
*/
public class TimeClaimVerificationOpts {
public static final int DEFAULT_LEEWAY_SECONDS = 10;
// These options configure whether the respective time claims must be present
// during validation. They will always be validated if present.
@ -36,18 +36,18 @@ public class TimeClaimVerificationOpts {
/**
* Tolerance window to account for clock skew when checking time claims
*/
private final int leewaySeconds;
private final int allowedClockSkewSeconds;
protected TimeClaimVerificationOpts(
boolean requireIssuedAtClaim,
boolean requireExpirationClaim,
boolean validateNotBeforeClaim,
int leewaySeconds
int allowedClockSkewSeconds
) {
this.requireIssuedAtClaim = requireIssuedAtClaim;
this.requireExpirationClaim = requireExpirationClaim;
this.requireNotBeforeClaim = validateNotBeforeClaim;
this.leewaySeconds = leewaySeconds;
this.allowedClockSkewSeconds = allowedClockSkewSeconds;
}
public boolean mustRequireIssuedAtClaim() {
@ -62,8 +62,8 @@ public class TimeClaimVerificationOpts {
return requireNotBeforeClaim;
}
public int getLeewaySeconds() {
return leewaySeconds;
public int getAllowedClockSkewSeconds() {
return allowedClockSkewSeconds;
}
public static <T extends Builder<T>> Builder<T> builder() {
@ -75,7 +75,7 @@ public class TimeClaimVerificationOpts {
protected boolean requireIssuedAtClaim = true;
protected boolean requireExpirationClaim = true;
protected boolean requireNotBeforeClaim = true;
protected int leewaySeconds = DEFAULT_LEEWAY_SECONDS;
protected int allowedClockSkewSeconds = SD_JWT_DEFAULT_CLOCK_SKEW_SECONDS;
@SuppressWarnings("unchecked")
public T withRequireIssuedAtClaim(boolean requireIssuedAtClaim) {
@ -96,8 +96,8 @@ public class TimeClaimVerificationOpts {
}
@SuppressWarnings("unchecked")
public T withLeewaySeconds(int leewaySeconds) {
this.leewaySeconds = leewaySeconds;
public T withAllowedClockSkew(int allowedClockSkewSeconds) {
this.allowedClockSkewSeconds = allowedClockSkewSeconds;
return (T) this;
}
@ -106,7 +106,7 @@ public class TimeClaimVerificationOpts {
requireIssuedAtClaim,
requireExpirationClaim,
requireNotBeforeClaim,
leewaySeconds
allowedClockSkewSeconds
);
}
}

View File

@ -23,23 +23,23 @@ 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 leeway window to accommodate clock skew.
* 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 {
public static final String CLAIM_NAME_IAT = "iat";
public static final String CLAIM_NAME_EXP = "exp";
public static final String CLAIM_NAME_NBF = "nbf";
private final TimeClaimVerificationOpts opts;
public TimeClaimVerifier(TimeClaimVerificationOpts opts) {
if (opts.getLeewaySeconds() < 0) {
throw new IllegalArgumentException("Leeway seconds cannot be negative");
if (opts.getAllowedClockSkewSeconds() < 0) {
throw new IllegalArgumentException("Allowed clock skew seconds cannot be negative");
}
this.opts = opts;
@ -61,7 +61,7 @@ public class TimeClaimVerifier {
long iat = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_IAT);
if ((currentTimestamp() + opts.getLeewaySeconds()) < iat) {
if ((currentTimestamp() + opts.getAllowedClockSkewSeconds()) < iat) {
throw new VerificationException("JWT was issued in the future");
}
}
@ -82,7 +82,7 @@ public class TimeClaimVerifier {
long exp = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_EXP);
if ((currentTimestamp() - opts.getLeewaySeconds()) >= exp) {
if ((currentTimestamp() - opts.getAllowedClockSkewSeconds()) >= exp) {
throw new VerificationException("JWT has expired");
}
}
@ -103,7 +103,7 @@ public class TimeClaimVerifier {
long nbf = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_NBF);
if ((currentTimestamp() + opts.getLeewaySeconds()) < nbf) {
if ((currentTimestamp() + opts.getAllowedClockSkewSeconds()) < nbf) {
throw new VerificationException("JWT is not yet valid");
}
}
@ -117,7 +117,7 @@ public class TimeClaimVerifier {
public void verifyAge(JsonNode jwtPayload, int maxAge) throws VerificationException {
long iat = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_IAT);
if ((currentTimestamp() - iat - opts.getLeewaySeconds()) > maxAge) {
if ((currentTimestamp() - iat - opts.getAllowedClockSkewSeconds()) > maxAge) {
throw new VerificationException("JWT is too old");
}
}

View File

@ -20,12 +20,13 @@ 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 UndisclosedArrayElement extends Disclosable implements SdJwtArrayElement {
public static final String SD_CLAIM_NAME = "...";
private final JsonNode arrayElement;
private UndisclosedArrayElement(SdJwtSalt salt, JsonNode arrayElement) {
@ -35,7 +36,7 @@ public class UndisclosedArrayElement extends Disclosable implements SdJwtArrayEl
@Override
public JsonNode getVisibleValue(String hashAlg) {
return SdJwtUtils.mapper.createObjectNode().put(SD_CLAIM_NAME, getDisclosureDigest(hashAlg));
return SdJwtUtils.mapper.createObjectNode().put(CLAIM_NAME_SD_UNDISCLOSED_ARRAY, getDisclosureDigest(hashAlg));
}
@Override

View File

@ -33,12 +33,14 @@ import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.JwkParsingUtils;
import org.keycloak.sdjwt.SdJws;
import org.keycloak.sdjwt.SdJwtUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_ISSUER;
import static org.keycloak.OID4VCConstants.JWT_VC_ISSUER_END_POINT;
/**
* A trusted Issuer for running SD-JWT VP verification.
*
@ -53,8 +55,6 @@ import com.fasterxml.jackson.databind.JsonNode;
*/
public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
private static final String JWT_VC_ISSUER_END_POINT = "/.well-known/jwt-vc-issuer";
private final Pattern issuerUriPattern;
private final HttpDataFetcher httpDataFetcher;
@ -87,7 +87,7 @@ public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
public List<SignatureVerifierContext> resolveIssuerVerifyingKeys(IssuerSignedJWT issuerSignedJWT)
throws VerificationException {
// Read iss (claim) and kid (header)
String iss = Optional.ofNullable(issuerSignedJWT.getPayload().get(SdJws.CLAIM_NAME_ISSUER))
String iss = Optional.ofNullable(issuerSignedJWT.getPayload().get(CLAIM_NAME_ISSUER))
.map(JsonNode::asText)
.orElse("");
String kid = issuerSignedJWT.getHeader().getKeyId();

View File

@ -19,6 +19,8 @@ package org.keycloak.sdjwt.vp;
import org.keycloak.sdjwt.TimeClaimVerificationOpts;
import static org.keycloak.OID4VCConstants.SD_JWT_KEY_BINDING_DEFAULT_ALLOWED_MAX_AGE;
/**
* Options for Key Binding JWT verification.
*
@ -26,8 +28,6 @@ import org.keycloak.sdjwt.TimeClaimVerificationOpts;
*/
public class KeyBindingJwtVerificationOpts extends TimeClaimVerificationOpts {
public static final int DEFAULT_ALLOWED_MAX_AGE = 5 * 60; // 5 minutes
/**
* Specifies the Verifier's policy whether to check Key Binding
*/
@ -48,9 +48,9 @@ public class KeyBindingJwtVerificationOpts extends TimeClaimVerificationOpts {
String aud,
boolean validateExpirationClaim,
boolean validateNotBeforeClaim,
int leewaySeconds
int allowClockSkewSeconds
) {
super(true, validateExpirationClaim, validateNotBeforeClaim, leewaySeconds);
super(true, validateExpirationClaim, validateNotBeforeClaim, allowClockSkewSeconds);
this.keyBindingRequired = keyBindingRequired;
this.allowedMaxAge = allowedMaxAge;
this.nonce = nonce;
@ -81,7 +81,7 @@ public class KeyBindingJwtVerificationOpts extends TimeClaimVerificationOpts {
private boolean keyBindingRequired = true;
protected boolean validateIssuedAtClaim = true;
private int allowedMaxAge = DEFAULT_ALLOWED_MAX_AGE;
private int allowedMaxAge = SD_JWT_KEY_BINDING_DEFAULT_ALLOWED_MAX_AGE;
private String nonce;
private String aud;
@ -126,7 +126,7 @@ public class KeyBindingJwtVerificationOpts extends TimeClaimVerificationOpts {
aud,
requireExpirationClaim,
requireNotBeforeClaim,
leewaySeconds
allowedClockSkewSeconds
);
}
}

View File

@ -36,7 +36,6 @@ import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
import org.keycloak.sdjwt.SdJwt;
import org.keycloak.sdjwt.SdJwtUtils;
import org.keycloak.sdjwt.SdJwtVerificationContext;
@ -44,6 +43,10 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM;
import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER;
import static org.keycloak.OID4VCConstants.SD_HASH;
/**
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
@ -114,11 +117,11 @@ public class SdJwtVP {
}
public static SdJwtVP of(String sdJwtString) {
int disclosureStart = sdJwtString.indexOf(SdJwt.DELIMITER);
int disclosureEnd = sdJwtString.lastIndexOf(SdJwt.DELIMITER);
int disclosureStart = sdJwtString.indexOf(SDJWT_DELIMITER);
int disclosureEnd = sdJwtString.lastIndexOf(SDJWT_DELIMITER);
if (disclosureStart == -1) {
throw new IllegalArgumentException("SD-JWT is malformed, expected to contain a '" + SdJwt.DELIMITER + "'");
throw new IllegalArgumentException("SD-JWT is malformed, expected to contain a '" + SDJWT_DELIMITER + "'");
}
String issuerSignedJWTString = sdJwtString.substring(0, disclosureStart);
@ -131,14 +134,14 @@ public class SdJwtVP {
IssuerSignedJWT issuerSignedJWT = IssuerSignedJWT.fromJws(issuerSignedJWTString);
ObjectNode issuerPayload = (ObjectNode) issuerSignedJWT.getPayload();
String hashAlgorithm = Optional.ofNullable(issuerPayload.get(IssuerSignedJWT.CLAIM_NAME_SD_HASH_ALGORITHM))
String hashAlgorithm = Optional.ofNullable(issuerPayload.get(CLAIM_NAME_SD_HASH_ALGORITHM))
.map(JsonNode::asText)
.orElse(JavaAlgorithm.SHA256.toLowerCase());
Map<String, ArrayNode> claims = new HashMap<>();
Map<String, String> disclosures = new HashMap<>();
List<String> split = Arrays.stream(disclosuresString.split(SdJwt.DELIMITER))
List<String> split = Arrays.stream(disclosuresString.split(SDJWT_DELIMITER))
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
@ -215,10 +218,10 @@ public class SdJwtVP {
sb.append(sdJwtVpString);
} else {
sb.append(issuerSignedJWT.toJws());
sb.append(SdJwt.DELIMITER);
sb.append(SDJWT_DELIMITER);
for (String disclosureDigest : disclosureDigests) {
sb.append(disclosures.get(disclosureDigest));
sb.append(SdJwt.DELIMITER);
sb.append(SDJWT_DELIMITER);
}
}
String unboundPresentation = sb.toString();
@ -226,7 +229,7 @@ public class SdJwtVP {
return unboundPresentation;
}
String sd_hash = SdJwtUtils.hashAndBase64EncodeNoPad(unboundPresentation.getBytes(), getHashAlgorithm());
keyBindingClaims = ((ObjectNode) keyBindingClaims).put("sd_hash", sd_hash);
keyBindingClaims = ((ObjectNode) keyBindingClaims).put(SD_HASH, sd_hash);
KeyBindingJWT keyBindingJWT = KeyBindingJWT.from(keyBindingClaims, holdSignatureSignerContext, jwsType);
sb.append(keyBindingJWT.toJws());
return sb.toString();

View File

@ -33,6 +33,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.ClassRule;
import org.junit.Test;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_UNDISCLOSED_ARRAY;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
@ -275,7 +278,7 @@ public abstract class SdJwtVerificationTest {
public void sdJwtVerificationShouldFail_IfSdArrayElementIsNotString() throws JsonProcessingException {
ObjectNode claimSet = mapper.createObjectNode();
claimSet.put("given_name", "John");
claimSet.set("_sd", mapper.readTree("[123]"));
claimSet.set(CLAIM_NAME_SD, mapper.readTree("[123]"));
SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build();
@ -292,7 +295,7 @@ public abstract class SdJwtVerificationTest {
@Test
public void sdJwtVerificationShouldFail_IfForbiddenClaimNames() {
for (String forbiddenClaimName : Arrays.asList("_sd", "...")) {
for (String forbiddenClaimName : Arrays.asList(CLAIM_NAME_SD, CLAIM_NAME_SD_UNDISCLOSED_ARRAY)) {
ObjectNode claimSet = mapper.createObjectNode();
claimSet.put(forbiddenClaimName, "Value");

View File

@ -41,6 +41,8 @@ import org.keycloak.crypto.SignatureVerifierContext;
import com.fasterxml.jackson.databind.JsonNode;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_JWK;
/**
* Import test-settings from:
* <a href="https://github.com/openwallet-foundation-labs/sd-jwt-python/blob/main/src/sd_jwt/utils/demo_settings.yml">
@ -119,8 +121,8 @@ public class TestSettings {
}
private static PublicKey readPublicKey(JsonNode keyData) {
if (keyData.has("jwk")) {
keyData = keyData.get("jwk");
if (keyData.has(CLAIM_NAME_JWK)) {
keyData = keyData.get(CLAIM_NAME_JWK);
}
String curveName = keyData.get("crv").asText();
String base64UrlEncodedX = keyData.get("x").asText();

View File

@ -22,9 +22,9 @@ import org.keycloak.common.VerificationException;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_EXP;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_IAT;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_NBF;
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.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
@ -35,15 +35,15 @@ import static org.junit.Assert.assertThrows;
public class TimeClaimVerifierTest {
private static final long CURRENT_TIMESTAMP = 1609459200L; // Fixed timestamp: 2021-01-01 00:00:00 UTC
private static final int DEFAULT_LEEWAY_SECONDS = 20;
private static final int DEFAULT_CLOCK_SKEW_SECONDS = 20;
private final TimeClaimVerifier timeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_LEEWAY_SECONDS, false);
private final TimeClaimVerifier strictTimeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_LEEWAY_SECONDS, true);
private final TimeClaimVerifier timeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_CLOCK_SKEW_SECONDS, false);
private final TimeClaimVerifier strictTimeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_CLOCK_SKEW_SECONDS, true);
static class FixedTimeClaimVerifier extends TimeClaimVerifier {
public FixedTimeClaimVerifier(int leewaySeconds, boolean requireClaims) {
super(createOptsWithLeeway(leewaySeconds, requireClaims));
public FixedTimeClaimVerifier(int allowedClockSkewSeconds, boolean requireClaims) {
super(createOptsWithAllowedClockSkew(allowedClockSkewSeconds, requireClaims));
}
@Override
@ -74,7 +74,7 @@ public class TimeClaimVerifierTest {
@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 leeway
payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP + 19); // Issued 19 seconds in the future, within the 20 second clock skew
timeClaimVerifier.verifyIssuedAtClaim(payload);
}
@ -101,9 +101,9 @@ public class TimeClaimVerifierTest {
@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 leeway
payload.put(CLAIM_NAME_EXP, CURRENT_TIMESTAMP - 19); // 19 seconds ago, within the 20 second clock skew
// No exception expected for JWT expiring within leeway
// No exception expected for JWT expiring within clock skew
timeClaimVerifier.verifyExpirationClaim(payload);
}
@ -126,13 +126,13 @@ public class TimeClaimVerifierTest {
timeClaimVerifier.verifyNotBeforeClaim(payload);
}
// Test for verifyNotBeforeClaim (edge case: valid exactly at current time with leeway)
// 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 leeway
payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP + 19); // 19 seconds in the future, within the 20 second clock skew
// No exception expected for JWT becoming valid within leeway
// No exception expected for JWT becoming valid within clock skew
timeClaimVerifier.verifyNotBeforeClaim(payload);
}
@ -164,17 +164,17 @@ public class TimeClaimVerifierTest {
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 leeway
payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 320); // 320 seconds old, within the 20 second clock skew
timeClaimVerifier.verifyAge(payload, maxAgeAllowed);
}
@Test
public void instantiationShouldFailIfLeewayNegative() {
public void instantiationShouldFailIfClockSkewNegative() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> new TimeClaimVerifier(createOptsWithLeeway(-1, false)));
() -> new TimeClaimVerifier(createOptsWithAllowedClockSkew(-1, false)));
assertEquals("Leeway seconds cannot be negative", exception.getMessage());
assertEquals("Allowed clock skew seconds cannot be negative", exception.getMessage());
}
@Test
@ -206,9 +206,9 @@ public class TimeClaimVerifierTest {
assertEquals("Missing 'nbf' claim or null", exceptionNbf.getMessage());
}
private static TimeClaimVerificationOpts createOptsWithLeeway(int leewaySeconds, boolean requireClaims) {
private static TimeClaimVerificationOpts createOptsWithAllowedClockSkew(int allowedClockSkewSeconds, boolean requireClaims) {
return TimeClaimVerificationOpts.builder()
.withLeewaySeconds(leewaySeconds)
.withAllowedClockSkew(allowedClockSkewSeconds)
.withRequireIssuedAtClaim(requireClaims)
.withRequireExpirationClaim(requireClaims)
.withRequireNotBeforeClaim(requireClaims)

View File

@ -26,7 +26,6 @@ import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.rule.CryptoInitRule;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.SdJws;
import org.keycloak.sdjwt.SdJwtUtils;
import org.keycloak.sdjwt.TestUtils;
import org.keycloak.sdjwt.vp.SdJwtVP;
@ -37,6 +36,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.ClassRule;
import org.junit.Test;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_ISSUER;
import static org.keycloak.OID4VCConstants.JWT_VC_ISSUER_END_POINT;
import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
@ -342,7 +344,7 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest {
String causeErrorMessage
) {
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerSignedJWT.getPayload().get(SdJws.CLAIM_NAME_ISSUER).asText(),
issuerSignedJWT.getPayload().get(CLAIM_NAME_ISSUER).asText(),
mockFetcher
);
@ -408,7 +410,7 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest {
throw new UnknownHostException("Unavailable URI");
}
if (uri.endsWith("/.well-known/jwt-vc-issuer")) {
if (uri.endsWith(JWT_VC_ISSUER_END_POINT)) {
return metadata;
} else if (uri.endsWith("/api/vci/jwks")) {
return jwks;

View File

@ -19,6 +19,7 @@ package org.keycloak.sdjwt.sdjwtvp;
import java.util.Arrays;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.Algorithm;
import org.keycloak.rule.CryptoInitRule;
import org.keycloak.sdjwt.DisclosureSpec;
import org.keycloak.sdjwt.IssuerSignedJWT;
@ -32,6 +33,8 @@ 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;
@ -144,12 +147,11 @@ public abstract class SdJwtVPTest {
@Test
public void testS6_2_PresentationPositive() throws VerificationException {
String jwsType = "dc+sd-jwt";
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");
String presentation = sdJwtVP.present(null, keyBindingClaims,
TestSettings.getInstance().getHolderSignerContext(), jwsType);
TestSettings.getInstance().getHolderSignerContext(), SD_JWT_VC_FORMAT);
SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation);
assertTrue(presenteSdJwtVP.getKeyBindingJWT().isPresent());
@ -159,23 +161,22 @@ public abstract class SdJwtVPTest {
// Verify with public key from cnf claim
presenteSdJwtVP.getKeyBindingJWT().get()
.verifySignature(TestSettings.verifierContextFrom(presenteSdJwtVP.getCnfClaim(), "ES256"));
.verifySignature(TestSettings.verifierContextFrom(presenteSdJwtVP.getCnfClaim(), Algorithm.ES256));
}
@Test(expected = VerificationException.class)
public void testS6_2_PresentationNegative() throws VerificationException {
String jwsType = "dc+sd-jwt";
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");
String presentation = sdJwtVP.present(null, keyBindingClaims,
TestSettings.getInstance().getHolderSignerContext(), jwsType);
TestSettings.getInstance().getHolderSignerContext(), SD_JWT_VC_FORMAT);
SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation);
assertTrue(presenteSdJwtVP.getKeyBindingJWT().isPresent());
// Verify with public key from cnf claim
presenteSdJwtVP.getKeyBindingJWT().get()
.verifySignature(TestSettings.verifierContextFrom(presenteSdJwtVP.getCnfClaim(), "ES256"));
.verifySignature(TestSettings.verifierContextFrom(presenteSdJwtVP.getCnfClaim(), Algorithm.ES256));
// Verify with wrong public key from settings (iisuer)
presenteSdJwtVP.getKeyBindingJWT().get().verifySignature(TestSettings.getInstance().getIssuerVerifierContext());
@ -183,20 +184,19 @@ public abstract class SdJwtVPTest {
@Test
public void testS6_2_PresentationPartialDisclosure() throws VerificationException {
String jwsType = "dc+sd-jwt";
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");
// disclose only the given_name
String presentation = sdJwtVP.present(Arrays.asList("jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4"),
keyBindingClaims, TestSettings.getInstance().getHolderSignerContext(), jwsType);
keyBindingClaims, TestSettings.getInstance().getHolderSignerContext(), SD_JWT_VC_FORMAT);
SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation);
assertTrue(presenteSdJwtVP.getKeyBindingJWT().isPresent());
// Verify with public key from cnf claim
presenteSdJwtVP.getKeyBindingJWT().get()
.verifySignature(TestSettings.verifierContextFrom(presenteSdJwtVP.getCnfClaim(), "ES256"));
.verifySignature(TestSettings.verifierContextFrom(presenteSdJwtVP.getCnfClaim(), Algorithm.ES256));
}

View File

@ -22,11 +22,11 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.keycloak.OID4VCConstants;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.rule.CryptoInitRule;
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
import org.keycloak.sdjwt.SdJwt;
import org.keycloak.sdjwt.TestSettings;
import org.keycloak.sdjwt.TestUtils;
import org.keycloak.sdjwt.vp.KeyBindingJWT;
@ -39,9 +39,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.ClassRule;
import org.junit.Test;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_EXP;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_IAT;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_NBF;
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;
@ -238,7 +238,7 @@ public abstract class SdJwtVPVerificationTest {
ObjectNode kbPayload = exampleKbPayload();
// This hash is not a string
kbPayload.set("sd_hash", mapper.valueToTree(1234));
kbPayload.set(OID4VCConstants.SD_HASH, mapper.valueToTree(1234));
testShouldFailGeneric2(
kbPayload,
@ -253,7 +253,7 @@ public abstract class SdJwtVPVerificationTest {
ObjectNode kbPayload = exampleKbPayload();
// This hash makes no sense
kbPayload.put("sd_hash", "c3FmZHFmZGZlZXNkZmZi");
kbPayload.put(OID4VCConstants.SD_HASH, "c3FmZHFmZGZlZXNkZmZi");
testShouldFailGeneric2(
kbPayload,
@ -279,11 +279,11 @@ public abstract class SdJwtVPVerificationTest {
}
@Test
public void testShouldTolerateKbIssuedInTheFutureWithinLeeway() throws VerificationException {
public void testShouldTolerateKbIssuedInTheFutureWithinClockSkew() throws VerificationException {
long now = Instant.now().getEpochSecond();
ObjectNode kbPayload = exampleKbPayload();
// Issued just 5 seconds in the future. Should pass with a leeway of 10 seconds.
// 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));
SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayload);
@ -291,7 +291,7 @@ public abstract class SdJwtVPVerificationTest {
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts()
.withLeewaySeconds(10)
.withAllowedClockSkew(10)
.build()
);
}
@ -330,11 +330,11 @@ public abstract class SdJwtVPVerificationTest {
}
@Test
public void testShouldTolerateExpiredKbWithinLeeway() throws VerificationException {
public void testShouldTolerateExpiredKbWithinClockSkew() throws VerificationException {
long now = Instant.now().getEpochSecond();
ObjectNode kbPayload = exampleKbPayload();
// Expires just 5 seconds ago. Should pass with a leeway of 10 seconds.
// Expires just 5 seconds ago. Should pass with a clock skew of 10 seconds.
kbPayload.set(CLAIM_NAME_EXP, mapper.valueToTree(now - 5));
SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayload);
@ -343,7 +343,7 @@ public abstract class SdJwtVPVerificationTest {
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts()
.withRequireExpirationClaim(true)
.withLeewaySeconds(10)
.withAllowedClockSkew(10)
.build()
);
}
@ -478,8 +478,8 @@ public abstract class SdJwtVPVerificationTest {
ObjectNode payload = mapper.createObjectNode();
payload.put("nonce", "1234567890");
payload.put("aud", "https://verifier.example.org");
payload.put("sd_hash", "X9RrrfWt_70gHzOcovGSIt4Fms9Tf2g2hjlWVI_cxZg");
payload.set("iat", mapper.valueToTree(1702315679));
payload.put(OID4VCConstants.SD_HASH, "X9RrrfWt_70gHzOcovGSIt4Fms9Tf2g2hjlWVI_cxZg");
payload.set(CLAIM_NAME_IAT, mapper.valueToTree(1702315679));
return payload;
}
@ -492,7 +492,7 @@ public abstract class SdJwtVPVerificationTest {
);
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s20.1-sdjwt+kb.txt");
String sdJwtWithoutKb = sdJwtVPString.substring(0, sdJwtVPString.lastIndexOf(SdJwt.DELIMITER) + 1);
String sdJwtWithoutKb = sdJwtVPString.substring(0, sdJwtVPString.lastIndexOf(OID4VCConstants.SDJWT_DELIMITER) + 1);
return SdJwtVP.of(sdJwtWithoutKb + keyBindingJWT.toJws());
}

View File

@ -19,6 +19,9 @@
package org.keycloak.constants;
/**
* Keycloak specific constants related to OID4VC and related functionality. Useful for example for internal constants (EG. name of Keycloak realm attributes).
* For protocol constants defined in the specification, see {@link org.keycloak.OID4VCConstants}
*
* @author Pascal Knüppel
*/
public final class Oid4VciConstants {
@ -27,15 +30,6 @@ public final class Oid4VciConstants {
public static final String C_NONCE_LIFETIME_IN_SECONDS = "vc.c-nonce-lifetime-seconds";
public static final String CREDENTIAL_SUBJECT = "credentialSubject";
public static final String SIGNED_METADATA_JWT_TYPE = "openidvci-issuer-metadata+jwt";
// --- Endpoints/Well-Known ---
public static final String WELL_KNOWN_OPENID_CREDENTIAL_ISSUER = "openid-credential-issuer";
public static final String RESPONSE_TYPE_IMG_PNG = "image/png";
public static final String CREDENTIAL_OFFER_URI_CODE_SCOPE = "credential-offer";
// --- Keybinding/Credential Builder ---
public static final String SOURCE_ENDPOINT = "source_endpoint";
public static final String BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE = "batch_credential_issuance.batch_size";

View File

@ -31,6 +31,7 @@ import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import static org.keycloak.OID4VCConstants.SD_JWT_VC_FORMAT;
import static org.keycloak.constants.Oid4VciConstants.OID4VC_PROTOCOL;
/**
@ -44,7 +45,7 @@ public class CredentialScopeModel implements ClientScopeModel {
public static final String SD_JWT_VISIBLE_CLAIMS_DEFAULT = "id,iat,nbf,exp,jti";
public static final int SD_JWT_DECOYS_DEFAULT = 10;
public static final String FORMAT_DEFAULT = "dc+sd-jwt";
public static final String FORMAT_DEFAULT = SD_JWT_VC_FORMAT;
public static final String HASH_ALGORITHM_DEFAULT = "SHA-256";
public static final String TOKEN_TYPE_DEFAULT = "JWS";
public static final int EXPIRY_IN_SECONDS_DEFAULT = 31536000; // 1 year

View File

@ -47,9 +47,9 @@ import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OID4VCConstants;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.events.Errors;
@ -169,8 +169,8 @@ public class OID4VCIssuerEndpoint {
public static final String NONCE_PATH = "nonce";
public static final String CREDENTIAL_PATH = "credential";
public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
public static final String RESPONSE_TYPE_IMG_PNG = Oid4VciConstants.RESPONSE_TYPE_IMG_PNG;
public static final String CREDENTIAL_OFFER_URI_CODE_SCOPE = Oid4VciConstants.CREDENTIAL_OFFER_URI_CODE_SCOPE;
public static final String RESPONSE_TYPE_IMG_PNG = OID4VCConstants.RESPONSE_TYPE_IMG_PNG;
public static final String CREDENTIAL_OFFER_URI_CODE_SCOPE = OID4VCConstants.CREDENTIAL_OFFER_URI_CODE_SCOPE;
private final KeycloakSession session;
private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
private final TimeProvider timeProvider;

View File

@ -62,7 +62,9 @@ import org.keycloak.wellknown.WellKnownProvider;
import org.apache.http.HttpHeaders;
import org.jboss.logging.Logger;
import static org.keycloak.constants.Oid4VciConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.OID4VCConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER;
import static org.keycloak.constants.Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.crypto.KeyType.RSA;
import static org.keycloak.jose.jwk.RSAPublicJWK.RS256;
@ -183,18 +185,18 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
* @return The batch credential issuance configuration or null if not configured or invalid
*/
public static CredentialIssuer.BatchCredentialIssuance getBatchCredentialIssuance(RealmModel realm) {
String batchSize = realm.getAttribute(Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE);
String batchSize = realm.getAttribute(BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE);
if (batchSize != null) {
try {
int parsedBatchSize = Integer.parseInt(batchSize);
if (parsedBatchSize < 2) {
LOGGER.warnf("%s must be 2 or greater, but was %d. Skipping batch_credential_issuance.", Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, parsedBatchSize);
LOGGER.warnf("%s must be 2 or greater, but was %d. Skipping batch_credential_issuance.", BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, parsedBatchSize);
return null;
}
return new CredentialIssuer.BatchCredentialIssuance()
.setBatchSize(parsedBatchSize);
} catch (Exception e) {
LOGGER.warnf(e, "Failed to parse %s from realm attributes.", Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE);
LOGGER.warnf(e, "Failed to parse %s from realm attributes.", BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE);
}
}
return null;
@ -549,7 +551,7 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
UriBuilder base = session.getContext().getUri().getBaseUriBuilder();
String logKey = session.getContext().getRealm().getName();
URI successor = ServerMetadataResource.wellKnownOAuthProviderUrl(base)
.build(Oid4VciConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER, logKey);
.build(WELL_KNOWN_OPENID_CREDENTIAL_ISSUER, logKey);
HttpResponse httpResponse = session.getContext().getHttpResponse();
httpResponse.setHeader("Warning", "299 - \"Deprecated endpoint; use " + successor + "\"");

View File

@ -18,7 +18,7 @@
package org.keycloak.protocol.oid4vc.issuance;
import org.keycloak.Config;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.OID4VCConstants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
@ -34,7 +34,7 @@ import org.keycloak.wellknown.WellKnownProviderFactory;
*/
public class OID4VCIssuerWellKnownProviderFactory implements WellKnownProviderFactory, OID4VCEnvironmentProviderFactory {
public static final String PROVIDER_ID = Oid4VciConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER;
public static final String PROVIDER_ID = OID4VCConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER;
@Override
public WellKnownProvider create(KeycloakSession session) {
@ -60,4 +60,4 @@ public class OID4VCIssuerWellKnownProviderFactory implements WellKnownProviderFa
public String getId() {
return PROVIDER_ID;
}
}
}

View File

@ -26,14 +26,14 @@ import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.databind.JsonNode;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_CNF;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_JWK;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class SdJwtCredentialBody implements CredentialBody {
private static final String CNF_CLAIM = "cnf";
private static final String JWK_CLAIM = "jwk";
private final SdJwt.Builder sdJwtBuilder;
private final Map<String, Object> claimSet;
@ -43,7 +43,7 @@ public class SdJwtCredentialBody implements CredentialBody {
}
public void addKeyBinding(JWK jwk) throws CredentialBuilderException {
claimSet.put(CNF_CLAIM, Map.of(JWK_CLAIM, jwk));
claimSet.put(CLAIM_NAME_CNF, Map.of(CLAIM_NAME_JWK, jwk));
}
public Map<String, Object> getClaimSet() {

View File

@ -25,7 +25,6 @@ import java.util.Optional;
import java.util.stream.Stream;
import org.keycloak.Config;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
@ -39,6 +38,8 @@ import org.keycloak.provider.ProviderConfigProperty;
import org.apache.commons.collections4.ListUtils;
import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT;
/**
* Base class for OID4VC Mappers, to provide common configuration and functionality for all of them
*
@ -91,7 +92,7 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
protected List<String> getAttributePrefix() {
return switch (Optional.ofNullable(format).orElse("")) {
case Format.JWT_VC, Format.LDP_VC -> List.of(Oid4VciConstants.CREDENTIAL_SUBJECT);
case Format.JWT_VC, Format.LDP_VC -> List.of(CREDENTIAL_SUBJECT);
default -> Collections.emptyList();
};
}

View File

@ -20,6 +20,8 @@ package org.keycloak.protocol.oid4vc.model;
import java.util.Collections;
import java.util.Set;
import static org.keycloak.OID4VCConstants.SD_JWT_VC_FORMAT;
/**
* Enum of supported credential formats
*
@ -40,7 +42,7 @@ public class Format {
/**
* SD-JWT-Credentials {@see https://drafts.oauth.net/oauth-sd-jwt-vc/draft-ietf-oauth-sd-jwt-vc.html}
*/
public static final String SD_JWT_VC = "dc+sd-jwt";
public static final String SD_JWT_VC = SD_JWT_VC_FORMAT;
public static final Set<String> SUPPORTED_FORMATS = Collections.unmodifiableSet(Set.of(JWT_VC, LDP_VC, SD_JWT_VC));
public static final Set<String> SUPPORTED_FORMATS = Collections.unmodifiableSet(Set.of(JWT_VC, LDP_VC, SD_JWT_VC_FORMAT));
}

View File

@ -23,7 +23,6 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
@ -35,6 +34,8 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT;
import static org.junit.Assert.assertEquals;
/**
@ -86,7 +87,7 @@ public class JwtCredentialBuilderTest extends CredentialBuilderTest {
private JsonNode parseCredentialSubject(JWSInput jwsInput) throws JWSInputException {
JsonNode payload = jwsInput.readJsonContent(JsonNode.class);
return payload.get("vc").get(Oid4VciConstants.CREDENTIAL_SUBJECT);
return payload.get("vc").get(CREDENTIAL_SUBJECT);
}
private Map<String, Object> exampleCredentialClaims() {

View File

@ -33,10 +33,10 @@ import org.keycloak.sdjwt.vp.SdJwtVP;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.junit.Test;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM;
import static org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder.ISSUER_CLAIM;
import static org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder.VERIFIABLE_CREDENTIAL_TYPE_CLAIM;
import static org.keycloak.sdjwt.IssuerSignedJWT.CLAIM_NAME_SD_HASH_ALGORITHM;
import static org.keycloak.sdjwt.IssuerSignedJWT.CLAIM_NAME_SELECTIVE_DISCLOSURE;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@ -120,7 +120,7 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest {
credentialBuildConfig.getTokenJwsType(),
jwt.getHeader().getType());
ArrayNode sdArrayNode = (ArrayNode) jwt.getPayload().get(CLAIM_NAME_SELECTIVE_DISCLOSURE);
ArrayNode sdArrayNode = (ArrayNode) jwt.getPayload().get(CLAIM_NAME_SD);
if (sdArrayNode != null) {
assertEquals("The algorithm should be included",
credentialBuildConfig.getHashAlgorithm(),

View File

@ -35,7 +35,6 @@ import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.Time;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
@ -92,7 +91,8 @@ import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Test;
import static org.keycloak.constants.Oid4VciConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.constants.Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
@ -113,7 +113,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
Map<String, String> attributes = Optional.ofNullable(testRealm.getAttributes()).orElseGet(HashMap::new);
attributes.put("credential_response_encryption.encryption_required", "true");
attributes.put(ATTR_ENCRYPTION_REQUIRED, "true");
attributes.put(Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, "10");
attributes.put(BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, "10");
attributes.put(ATTR_REQUEST_ZIP_ALGS, DEFLATE_COMPRESSION);
testRealm.setAttributes(attributes);
@ -447,7 +447,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
realm.setAttribute(ATTR_ENCRYPTION_REQUIRED, "true");
realm.setAttribute(ATTR_REQUEST_ZIP_ALGS, DEFLATE_COMPRESSION);
realm.setAttribute(Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, "10");
realm.setAttribute(BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, "10");
OID4VCIssuerWellKnownProvider provider = new OID4VCIssuerWellKnownProvider(session);
return provider.getIssuerMetadata();
@ -773,7 +773,7 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
RealmModel testRealm = session.realms().createRealm("test-batch-validation-" + batchSize);
try {
testRealm.setAttribute(Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, batchSize);
testRealm.setAttribute(BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE, batchSize);
CredentialIssuer.BatchCredentialIssuance result = OID4VCIssuerWellKnownProvider.getBatchCredentialIssuance(testRealm);

View File

@ -35,7 +35,6 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
@ -77,6 +76,8 @@ import org.apache.http.message.BasicNameValuePair;
import org.junit.Assert;
import org.junit.Test;
import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
@ -756,7 +757,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
{
Claim claim = jwtVcClaims.get(0);
assertEquals("The jwt_vc-credential claim credentialSubject.given_name is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.given_name is present.",
"given_name",
@ -774,7 +775,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
{
Claim claim = jwtVcClaims.get(1);
assertEquals("The jwt_vc-credential claim credentialSubject.family_name is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.family_name is present.",
"family_name",
@ -792,7 +793,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
{
Claim claim = jwtVcClaims.get(2);
assertEquals("The jwt_vc-credential claim credentialSubject.birthdate is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.birthdate is present.",
"birthdate",
@ -810,7 +811,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
{
Claim claim = jwtVcClaims.get(3);
assertEquals("The jwt_vc-credential claim credentialSubject.email is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.email is present.",
"email",
@ -828,7 +829,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
{
Claim claim = jwtVcClaims.get(4);
assertEquals("The jwt_vc-credential claim credentialSubject.scope-name is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.scope-name is present.",
"scope-name",

View File

@ -19,6 +19,8 @@ package org.keycloak.testsuite.oid4vc.issuance.signing;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@ -57,6 +59,6 @@ public class OID4VCSdJwtAuthorizationCodeFlowTest extends OID4VCAuthorizationCod
// Verify it looks like an SD-JWT (contains dots and ~)
assertTrue("SD-JWT should contain dots", sdJwtString.contains("."));
assertTrue("SD-JWT should contain tilde", sdJwtString.contains("~"));
assertTrue("SD-JWT should contain tilde", sdJwtString.contains(SDJWT_DELIMITER));
}
}

View File

@ -19,6 +19,8 @@ package org.keycloak.testsuite.oid4vc.issuance.signing;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@ -57,6 +59,6 @@ public class OID4VCSdJwtAuthorizationDetailsFlowTest extends OID4VCAuthorization
// Verify it looks like an SD-JWT (contains dots and ~)
assertTrue("SD-JWT should contain dots", sdJwtString.contains("."));
assertTrue("SD-JWT should contain tilde", sdJwtString.contains("~"));
assertTrue("SD-JWT should contain tilde", sdJwtString.contains(SDJWT_DELIMITER));
}
}

View File

@ -51,6 +51,10 @@ import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.junit.Test;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SD_HASH_ALGORITHM;
import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@ -235,7 +239,7 @@ public class SdJwtCredentialSignerTest extends OID4VCTest {
}
// the sd-jwt is dot-concatenated header.payload.signature~disclosure1~___~disclosureN
// we first split the disclosuers
String[] splittedSdToken = sdJwt.split("~");
String[] splittedSdToken = sdJwt.split(SDJWT_DELIMITER);
// and then split the actual token part
String[] splittedToken = splittedSdToken[0].split("\\.");
@ -261,9 +265,9 @@ public class SdJwtCredentialSignerTest extends OID4VCTest {
assertEquals("The issuer should be set in the token.", TEST_DID.toString(), theToken.getIssuer());
assertEquals("The type should be included", "https://credentials.example.com/test-credential", theToken.getOtherClaims().get("vct"));
List<String> sds = (List<String>) theToken.getOtherClaims().get("_sd");
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("_sd_alg"));
assertEquals("The algorithm should be included", "sha-256", 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;