All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.keycloak.sdjwt.SdJwtVerificationContext Maven / Gradle / Ivy

There is a newer version: 26.0.5
Show newest version
/*
 * 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; } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy