mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
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:
parent
8ee23aaa15
commit
a4c583246d
51
core/src/main/java/org/keycloak/OID4VCConstants.java
Normal file
51
core/src/main/java/org/keycloak/OID4VCConstants.java
Normal 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() {
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 + "\"");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user