org.keycloak.sdjwt.SdJwtVerificationContext Maven / Gradle / Ivy
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.sdjwt;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.AsymmetricSignatureVerifierContext;
import org.keycloak.crypto.ECDSASignatureVerifierContext;
import org.keycloak.crypto.KeyType;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.sdjwt.vp.KeyBindingJWT;
import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts;
import org.keycloak.util.JWKSUtils;
import java.time.Instant;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Runs SD-JWT verification in isolation with only essential properties.
*
* @author Ingrid Kamga
*/
public class SdJwtVerificationContext {
private String sdJwtVpString;
private final IssuerSignedJWT issuerSignedJwt;
private final Map disclosures;
private KeyBindingJWT keyBindingJwt;
public SdJwtVerificationContext(
String sdJwtVpString,
IssuerSignedJWT issuerSignedJwt,
Map disclosures,
KeyBindingJWT keyBindingJwt) {
this(issuerSignedJwt, disclosures);
this.keyBindingJwt = keyBindingJwt;
this.sdJwtVpString = sdJwtVpString;
}
public SdJwtVerificationContext(IssuerSignedJWT issuerSignedJwt, Map disclosures) {
this.issuerSignedJwt = issuerSignedJwt;
this.disclosures = disclosures;
}
public SdJwtVerificationContext(IssuerSignedJWT issuerSignedJwt, List disclosureStrings) {
this.issuerSignedJwt = issuerSignedJwt;
this.disclosures = computeDigestDisclosureMap(disclosureStrings);
}
private Map computeDigestDisclosureMap(List disclosureStrings) {
return disclosureStrings.stream()
.map(disclosureString -> {
String digest = SdJwtUtils.hashAndBase64EncodeNoPad(
disclosureString.getBytes(), issuerSignedJwt.getSdHashAlg());
return new AbstractMap.SimpleEntry(digest, disclosureString);
})
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
/**
* Verifies SD-JWT as to whether the Issuer-signed JWT's signature and disclosures are valid.
*
* Upon receiving an SD-JWT, a Holder or a Verifier needs to ensure that:
* - the Issuer-signed JWT is valid, i.e., it is signed by the Issuer and the signature is valid, and
* - all Disclosures are valid and correspond to a respective digest value in the Issuer-signed JWT
* (directly in the payload or recursively included in the contents of other Disclosures).
*
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
* must be specified for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that associated public keys
* belong to the intended issuer.
* @throws VerificationException if verification failed
*/
public void verifyIssuance(
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts
) throws VerificationException {
// Validate the Issuer-signed JWT.
validateIssuerSignedJwt(issuerSignedJwtVerificationOpts.getVerifier());
// Validate disclosures.
JsonNode disclosedPayload = validateDisclosuresDigests();
// Validate time claims.
// Issuers will typically include claims controlling the validity of the SD-JWT in plaintext in the
// SD-JWT payload, but there is no guarantee they would do so. Therefore, Verifiers cannot reliably
// depend on that and need to operate as though security-critical claims might be selectively disclosable.
validateIssuerSignedJwtTimeClaims(disclosedPayload, issuerSignedJwtVerificationOpts);
}
/**
* Verifies SD-JWT presentation.
*
*
* Upon receiving a Presentation, in addition to the checks in {@link #verifyIssuance}, Verifiers need
* to ensure that if Key Binding is required, the Key Binding JWT is signed by the Holder and valid.
*
*
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
* must be specified for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that associated public keys
* belong to the intended issuer.
* @param keyBindingJwtVerificationOpts Options to parameterize the Key Binding JWT verification.
* Must, among others, specify the Verifier's policy whether
* to check Key Binding.
* @throws VerificationException if verification failed
*/
public void verifyPresentation(
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
) throws VerificationException {
// If Key Binding is required and a Key Binding JWT is not provided,
// the Verifier MUST reject the Presentation.
if (keyBindingJwtVerificationOpts.isKeyBindingRequired() && keyBindingJwt == null) {
throw new VerificationException("Missing Key Binding JWT");
}
// Upon receiving a Presentation, in addition to the checks in {@link #verifyIssuance}...
verifyIssuance(issuerSignedJwtVerificationOpts);
// Validate Key Binding JWT if required
if (keyBindingJwtVerificationOpts.isKeyBindingRequired()) {
validateKeyBindingJwt(keyBindingJwtVerificationOpts);
}
}
/**
* Validate Issuer-signed JWT
*
*
* Upon receiving an SD-JWT, a Holder or a Verifier needs to ensure that:
* - the Issuer-signed JWT is valid, i.e., it is signed by the Issuer and the signature is valid
*
*
* @throws VerificationException if verification failed
*/
private void validateIssuerSignedJwt(SignatureVerifierContext verifier) throws VerificationException {
// Check that the _sd_alg claim value is understood and the hash algorithm is deemed secure
issuerSignedJwt.verifySdHashAlgorithm();
// Validate the signature over the Issuer-signed JWT
try {
issuerSignedJwt.verifySignature(verifier);
} catch (VerificationException e) {
throw new VerificationException("Invalid Issuer-Signed JWT", e);
}
}
/**
* Validate Key Binding JWT
*
* @throws VerificationException if verification failed
*/
private void validateKeyBindingJwt(
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
) throws VerificationException {
// Check that the typ of the Key Binding JWT is kb+jwt
validateKeyBindingJwtTyp();
// Determine the public key for the Holder from the SD-JWT
JsonNode cnf = issuerSignedJwt.getCnfClaim().orElseThrow(
() -> new VerificationException("No cnf claim in Issuer-signed JWT for key binding")
);
// Ensure that a signing algorithm was used that was deemed secure for the application.
// The none algorithm MUST NOT be accepted.
SignatureVerifierContext holderVerifier = buildHolderVerifier(cnf);
// Validate the signature over the Key Binding JWT
try {
keyBindingJwt.verifySignature(holderVerifier);
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT invalid", e);
}
// Check that the creation time of the Key Binding JWT is within an acceptable window.
validateKeyBindingJwtTimeClaims(keyBindingJwtVerificationOpts);
// Determine that the Key Binding JWT is bound to the current transaction and was created
// for this Verifier (replay protection) by validating nonce and aud claims.
preventKeyBindingJwtReplay(keyBindingJwtVerificationOpts);
// The same hash algorithm as for the Disclosures MUST be used (defined by the _sd_alg element
// in the Issuer-signed JWT or the default value, as defined in Section 5.1.1).
validateKeyBindingJwtSdHashIntegrity();
// Check that the Key Binding JWT is a valid JWT in all other respects
// -> Covered in part by `keyBindingJwt` being an instance of SdJws?
// -> Time claims are checked above
}
/**
* Validate Key Binding JWT's typ header attribute
*
* @throws VerificationException if verification failed
*/
private void validateKeyBindingJwtTyp() throws VerificationException {
String typ = keyBindingJwt.getHeader().getType();
if (!typ.equals(KeyBindingJWT.TYP)) {
throw new VerificationException("Key Binding JWT is not of declared typ " + KeyBindingJWT.TYP);
}
}
/**
* Build holder verifier from JWK node.
*
* @throws VerificationException if unable
*/
private SignatureVerifierContext buildHolderVerifier(JsonNode cnf) throws VerificationException {
Objects.requireNonNull(cnf);
// Read JWK
JsonNode cnfJwk = cnf.get("jwk");
if (cnfJwk == null) {
throw new UnsupportedOperationException("Only cnf/jwk claim supported");
}
// Parse JWK
KeyWrapper keyWrapper;
try {
JWK jwk = SdJwtUtils.mapper.convertValue(cnfJwk, JWK.class);
keyWrapper = JWKSUtils.getKeyWrapper(jwk);
Objects.requireNonNull(keyWrapper);
} catch (Exception e) {
throw new VerificationException("Malformed or unsupported cnf/jwk claim");
}
// Build verifier
// KeyType.EC
if (keyWrapper.getType().equals(KeyType.EC)) {
if (keyWrapper.getAlgorithm() == null) {
Objects.requireNonNull(keyWrapper.getCurve());
String alg = null;
switch (keyWrapper.getCurve()) {
case "P-256":
alg = "ES256";
break;
case "P-384":
alg = "ES384";
break;
case "P-521":
alg = "ES512";
break;
}
keyWrapper.setAlgorithm(alg);
}
return new ECDSASignatureVerifierContext(keyWrapper);
}
// KeyType.RSA
if (keyWrapper.getType().equals(KeyType.RSA)) {
return new AsymmetricSignatureVerifierContext(keyWrapper);
}
// KeyType is not supported
// This is unreachable as of now given that `JWKSUtils.getKeyWrapper` will fail
// on JWKs with key type not equal to EC or RSA.
throw new VerificationException("cnf/jwk alg is unsupported or deemed not secure");
}
/**
* Validate Issuer-Signed JWT time claims.
*
*
* Check that the SD-JWT is valid using claims such as nbf, iat, and exp in the processed payload.
* If a required validity-controlling claim is missing, the SD-JWT MUST be rejected.
*
*
* @throws VerificationException if verification failed
*/
private void validateIssuerSignedJwtTimeClaims(
JsonNode payload,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts
) throws VerificationException {
long now = Instant.now().getEpochSecond();
try {
if (issuerSignedJwtVerificationOpts.mustValidateIssuedAtClaim()
&& now < SdJwtUtils.readTimeClaim(payload, "iat")) {
throw new VerificationException("JWT issued in the future");
}
} catch (VerificationException e) {
throw new VerificationException("Issuer-Signed JWT: Invalid `iat` claim", e);
}
try {
if (issuerSignedJwtVerificationOpts.mustValidateExpirationClaim()
&& now >= SdJwtUtils.readTimeClaim(payload, "exp")) {
throw new VerificationException("JWT has expired");
}
} catch (VerificationException e) {
throw new VerificationException("Issuer-Signed JWT: Invalid `exp` claim", e);
}
try {
if (issuerSignedJwtVerificationOpts.mustValidateNotBeforeClaim()
&& now < SdJwtUtils.readTimeClaim(payload, "nbf")) {
throw new VerificationException("JWT is not yet valid");
}
} catch (VerificationException e) {
throw new VerificationException("Issuer-Signed JWT: Invalid `nbf` claim", e);
}
}
/**
* Validate key binding JWT time claims.
*
* @throws VerificationException if verification failed
*/
private void validateKeyBindingJwtTimeClaims(
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
) throws VerificationException {
// Check that the creation time of the Key Binding JWT, as determined by the iat claim,
// is within an acceptable window
try {
keyBindingJwt.verifyIssuedAtClaim();
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT: Invalid `iat` claim", e);
}
try {
keyBindingJwt.verifyAge(keyBindingJwtVerificationOpts.getAllowedMaxAge());
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT is too old");
}
// Check other time claims
try {
if (keyBindingJwtVerificationOpts.mustValidateExpirationClaim()) {
keyBindingJwt.verifyExpClaim();
}
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT: Invalid `exp` claim", e);
}
try {
if (keyBindingJwtVerificationOpts.mustValidateNotBeforeClaim()) {
keyBindingJwt.verifyNotBeforeClaim();
}
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT: Invalid `nbf` claim", e);
}
}
/**
* Validate disclosures' digests
*
*
* Upon receiving an SD-JWT, a Holder or a Verifier needs to ensure that:
* - all Disclosures are valid and correspond to a respective digest value in the Issuer-signed JWT
* (directly in the payload or recursively included in the contents of other Disclosures)
*
*
*
* We additionally check that salt values are not reused:
* The salt value MUST be unique for each claim that is to be selectively disclosed.
*
*
* @return the fully disclosed SdJwt payload
* @throws VerificationException if verification failed
*/
private JsonNode validateDisclosuresDigests() throws VerificationException {
// Validate SdJwt digests by attempting full recursive disclosing.
Set visitedSalts = new HashSet<>();
Set visitedDigests = new HashSet<>();
Set visitedDisclosureStrings = new HashSet<>();
JsonNode disclosedPayload = validateViaRecursiveDisclosing(
SdJwtUtils.deepClone(issuerSignedJwt.getPayload()),
visitedSalts, visitedDigests, visitedDisclosureStrings);
// Validate all disclosures where visited
validateDisclosuresVisits(visitedDisclosureStrings);
return disclosedPayload;
}
/**
* Validate SdJwt digests by attempting full recursive disclosing.
*
*
* By recursively disclosing all disclosable fields in the SdJwt payload, validation rules are
* enforced regarding the conformance of linked disclosures. Additional rules should be enforced
* after calling this method based on the visited data arguments.
*
*
* @return the fully disclosed SdJwt payload
*/
private JsonNode validateViaRecursiveDisclosing(
JsonNode currentNode,
Set visitedSalts,
Set visitedDigests,
Set visitedDisclosureStrings
) throws VerificationException {
if (!currentNode.isObject() && !currentNode.isArray()) {
return currentNode;
}
// Find all objects having an _sd key that refers to an array of strings.
if (currentNode.isObject()) {
ObjectNode currentObjectNode = ((ObjectNode) currentNode);
JsonNode sdArray = currentObjectNode.get(IssuerSignedJWT.CLAIM_NAME_SELECTIVE_DISCLOSURE);
if (sdArray != null && sdArray.isArray()) {
for (JsonNode el : sdArray) {
if (!el.isTextual()) {
throw new VerificationException(
"Unexpected non-string element inside _sd array: " + el
);
}
// 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.
String digest = el.asText();
markDigestAsVisited(digest, visitedDigests);
String disclosure = disclosures.get(digest);
if (disclosure != null) {
// Mark disclosure as visited
visitedDisclosureStrings.add(disclosure);
// Validate disclosure format
DisclosureFields decodedDisclosure = validateSdArrayDigestDisclosureFormat(disclosure);
// Mark salt as visited
markSaltAsVisited(decodedDisclosure.getSaltValue(), visitedSalts);
// Insert, at the level of the _sd key, a new claim using the claim name
// and claim value from the Disclosure
currentObjectNode.set(
decodedDisclosure.getClaimName(),
decodedDisclosure.getClaimValue()
);
}
}
}
// 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);
// Remove the claim _sd_alg from the SD-JWT payload.
currentObjectNode.remove(IssuerSignedJWT.CLAIM_NAME_SD_HASH_ALGORITHM);
}
// Find all array elements that are objects with one key, that key being ... and referring to a string
if (currentNode.isArray()) {
ArrayNode currentArrayNode = ((ArrayNode) currentNode);
ArrayList indexesToRemove = new ArrayList<>();
for (int i = 0; i < currentArrayNode.size(); ++i) {
JsonNode itemNode = currentArrayNode.get(i);
if (itemNode.isObject() && itemNode.size() == 1) {
// Check single "..." field
Map.Entry field = itemNode.fields().next();
if (field.getKey().equals(UndisclosedArrayElement.SD_CLAIM_NAME)
&& 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.
String digest = field.getValue().asText();
markDigestAsVisited(digest, visitedDigests);
String disclosure = disclosures.get(digest);
if (disclosure != null) {
// Mark disclosure as visited
visitedDisclosureStrings.add(disclosure);
// Validate disclosure format
DisclosureFields decodedDisclosure = validateArrayElementDigestDisclosureFormat(disclosure);
// Mark salt as visited
markSaltAsVisited(decodedDisclosure.getSaltValue(), visitedSalts);
// Replace the array element with the value from the Disclosure.
// Removal is done below.
currentArrayNode.set(i, decodedDisclosure.getClaimValue());
} else {
// Remove all array elements for which the digest was not found in the previous step.
indexesToRemove.add(i);
}
}
}
}
// Remove all array elements for which the digest was not found in the previous step.
indexesToRemove.forEach(currentArrayNode::remove);
}
for (JsonNode childNode : currentNode) {
validateViaRecursiveDisclosing(childNode, visitedSalts, visitedDigests, visitedDisclosureStrings);
}
return currentNode;
}
/**
* Mark digest as visited.
*
*
* If any digest value is encountered more than once in the Issuer-signed JWT payload
* (directly or recursively via other Disclosures), the SD-JWT MUST be rejected.
*
*
* @throws VerificationException if not first visit
*/
private void markDigestAsVisited(String digest, Set visitedDigests)
throws VerificationException {
if (!visitedDigests.add(digest)) {
// If add returns false, then it is a duplicate
throw new VerificationException("A digest was encountered more than once: " + digest);
}
}
/**
* Mark salt as visited.
*
*
* The salt value MUST be unique for each claim that is to be selectively disclosed.
*
*
* @throws VerificationException if not first visit
*/
private void markSaltAsVisited(String salt, Set visitedSalts)
throws VerificationException {
if (!visitedSalts.add(salt)) {
// If add returns false, then it is a duplicate
throw new VerificationException("A salt value was reused: " + salt);
}
}
/**
* Validate disclosure assuming digest was found in an object's _sd key.
*
*
* If the contents of the respective Disclosure is not a JSON-encoded array of three elements
* (salt, claim name, claim value), the SD-JWT MUST be rejected.
*
*
*
* If the claim name is _sd or ..., the SD-JWT MUST be rejected.
*
*
* @return decoded disclosure (salt, claim name, claim value)
*/
private DisclosureFields validateSdArrayDigestDisclosureFormat(String disclosure)
throws VerificationException {
ArrayNode arrayNode = SdJwtUtils.decodeDisclosureString(disclosure);
// Check if the array has exactly three elements
if (arrayNode.size() != 3) {
throw new VerificationException("A field disclosure must contain exactly three elements");
}
// If the claim name is _sd or ..., the SD-JWT MUST be rejected.
List denylist = Arrays.asList(new String[]{
IssuerSignedJWT.CLAIM_NAME_SELECTIVE_DISCLOSURE,
UndisclosedArrayElement.SD_CLAIM_NAME
});
String claimName = arrayNode.get(1).asText();
if (denylist.contains(claimName)) {
throw new VerificationException("Disclosure claim name must not be '_sd' or '...'");
}
// Return decoded disclosure
return new DisclosureFields(
arrayNode.get(0).asText(),
claimName,
arrayNode.get(2)
);
}
/**
* Validate disclosure assuming digest was found as an undisclosed array element.
*
*
* If the contents of the respective Disclosure is not a JSON-encoded array of
* two elements (salt, value), the SD-JWT MUST be rejected.
*
*
* @return decoded disclosure (salt, value)
*/
private DisclosureFields validateArrayElementDigestDisclosureFormat(String disclosure)
throws VerificationException {
ArrayNode arrayNode = SdJwtUtils.decodeDisclosureString(disclosure);
// Check if the array has exactly two elements
if (arrayNode.size() != 2) {
throw new VerificationException("An array element disclosure must contain exactly two elements");
}
// Return decoded disclosure
return new DisclosureFields(
arrayNode.get(0).asText(),
null,
arrayNode.get(1)
);
}
/**
* Validate all disclosures where visited
*
*
* If any Disclosure was not referenced by digest value in the Issuer-signed JWT (directly or recursively via
* other Disclosures), the SD-JWT MUST be rejected.
*
*
* @throws VerificationException if not the case
*/
private void validateDisclosuresVisits(Set visitedDisclosureStrings)
throws VerificationException {
if (visitedDisclosureStrings.size() < disclosures.size()) {
throw new VerificationException("At least one disclosure is not protected by digest");
}
}
/**
* Run checks for replay protection.
*
*
* Determine that the Key Binding JWT is bound to the current transaction and was created for this
* Verifier (replay protection) by validating nonce and aud claims.
*
*
* @throws VerificationException if verification failed
*/
private void preventKeyBindingJwtReplay(
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
) throws VerificationException {
JsonNode nonce = keyBindingJwt.getPayload().get("nonce");
if (nonce == null || !nonce.isTextual()
|| !nonce.asText().equals(keyBindingJwtVerificationOpts.getNonce())) {
throw new VerificationException("Key binding JWT: Unexpected `nonce` value");
}
JsonNode aud = keyBindingJwt.getPayload().get("aud");
if (aud == null || !aud.isTextual()
|| !aud.asText().equals(keyBindingJwtVerificationOpts.getAud())) {
throw new VerificationException("Key binding JWT: Unexpected `aud` value");
}
}
/**
* Validate integrity of Key Binding JWT's sd_hash.
*
*
* Calculate the digest over the Issuer-signed JWT and Disclosures and verify that it matches
* the value of the sd_hash claim in the Key Binding JWT.
*
*
* @throws VerificationException if verification failed
*/
private void validateKeyBindingJwtSdHashIntegrity() throws VerificationException {
Objects.requireNonNull(sdJwtVpString);
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);
String toHash = sdJwtVpString.substring(0, lastDelimiterIndex + 1);
String digest = SdJwtUtils.hashAndBase64EncodeNoPad(
toHash.getBytes(), issuerSignedJwt.getSdHashAlg());
if (!digest.equals(sdHash.asText())) {
throw new VerificationException("Key binding JWT: Invalid `sd_hash` digest");
}
}
/**
* Plain record for disclosure fields.
*/
private static class DisclosureFields {
String saltValue;
String claimName;
JsonNode claimValue;
public DisclosureFields(String saltValue, String claimName, JsonNode claimValue) {
this.saltValue = saltValue;
this.claimName = claimName;
this.claimValue = claimValue;
}
public String getSaltValue() {
return saltValue;
}
public String getClaimName() {
return claimName;
}
public JsonNode getClaimValue() {
return claimValue;
}
}
}