SdJwtVerificationTest.java

/*
 * 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.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.ClassRule;
import org.junit.Test;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.rule.CryptoInitRule;

import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import org.keycloak.crypto.SignatureSignerContext;

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;

/**
 * @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
 */
public abstract class SdJwtVerificationTest {

    @ClassRule
    public static CryptoInitRule cryptoInitRule = new CryptoInitRule();

    static ObjectMapper mapper = new ObjectMapper();
    static TestSettings testSettings = TestSettings.getInstance();

    @Test
    public void settingsTest() {
        SignatureSignerContext issuerSignerContext = testSettings.issuerSigContext;
        assertNotNull(issuerSignerContext);
    }

    @Test
    public void testSdJwtVerification_FlatSdJwt() throws VerificationException {
        for (String hashAlg : Arrays.asList("sha-256", "sha-384", "sha-512")) {
            SdJwt sdJwt = exampleFlatSdJwtV1()
                    .withHashAlgorithm(hashAlg)
                    .build();

            sdJwt.verify(
                    defaultIssuerVerifyingKeys(),
                    defaultIssuerSignedJwtVerificationOpts().build()
            );
        }
    }

    @Test
    public void testSdJwtVerification_EnforceIdempotence() throws VerificationException {
        SdJwt sdJwt = exampleFlatSdJwtV1().build();

        sdJwt.verify(
                defaultIssuerVerifyingKeys(),
                defaultIssuerSignedJwtVerificationOpts().build()
        );

        sdJwt.verify(
                defaultIssuerVerifyingKeys(),
                defaultIssuerSignedJwtVerificationOpts().build()
        );
    }

    @Test
    public void testSdJwtVerification_SdJwtWithUndisclosedNestedFields() throws VerificationException {
        SdJwt sdJwt = exampleSdJwtWithUndisclosedNestedFieldsV1().build();

        sdJwt.verify(
                defaultIssuerVerifyingKeys(),
                defaultIssuerSignedJwtVerificationOpts().build()
        );
    }

    @Test
    public void testSdJwtVerification_SdJwtWithUndisclosedArrayElements() throws Exception {
        SdJwt sdJwt = exampleSdJwtWithUndisclosedArrayElementsV1().build();

        sdJwt.verify(
                defaultIssuerVerifyingKeys(),
                defaultIssuerSignedJwtVerificationOpts().build()
        );
    }

    @Test
    public void testSdJwtVerification_RecursiveSdJwt() throws Exception {
        SdJwt sdJwt = exampleRecursiveSdJwtV1().build();

        sdJwt.verify(
                defaultIssuerVerifyingKeys(),
                defaultIssuerSignedJwtVerificationOpts().build()
        );
    }

    @Test
    public void sdJwtVerificationShouldFail_OnInsecureHashAlg() {
        SdJwt sdJwt = exampleFlatSdJwtV1()
                .withHashAlgorithm("sha-224") // not deemed secure
                .build();

        VerificationException exception = assertThrows(
                VerificationException.class,
                () -> sdJwt.verify(
                        defaultIssuerVerifyingKeys(),
                        defaultIssuerSignedJwtVerificationOpts().build()
                )
        );

        assertEquals("Unexpected or insecure hash algorithm: sha-224", exception.getMessage());
    }

    @Test
    public void sdJwtVerificationShouldFail_WithWrongVerifier() {
        SdJwt sdJwt = exampleFlatSdJwtV1().build();
        VerificationException exception = assertThrows(
                VerificationException.class,
                () -> sdJwt.verify(
                        Collections.singletonList(testSettings.holderVerifierContext), // wrong verifier
                        defaultIssuerSignedJwtVerificationOpts().build()
                )
        );

        assertThat(exception.getMessage(), is("Invalid Issuer-Signed JWT: Signature could not be verified"));
    }

    @Test
    public void sdJwtVerificationShouldFail_IfExpired() {
        long now = Instant.now().getEpochSecond();

        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("given_name", "John");
        claimSet.put("exp", now - 1000); // expired 1000 seconds ago

        // Exp claim is plain
        SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build();
        // Exp claim is undisclosed
        SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder()
                .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet()))
                .withUndisclosedClaim("exp", "eluV5Og3gSNII8EYnsxA_A")
                .build()).build();

        for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
            VerificationException exception = assertThrows(
                    VerificationException.class,
                    () -> sdJwt.verify(
                            defaultIssuerVerifyingKeys(),
                            defaultIssuerSignedJwtVerificationOpts()
                                    .withValidateExpirationClaim(true)
                                    .build()
                    )
            );

            assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage());
            assertEquals("JWT has expired", exception.getCause().getMessage());
        }
    }

    @Test
    public void sdJwtVerificationShouldFail_IfExpired_CaseExpInvalid() {
        // exp: null
        ObjectNode claimSet1 = mapper.createObjectNode();
        claimSet1.put("given_name", "John");

        // exp: invalid
        ObjectNode claimSet2 = mapper.createObjectNode();
        claimSet1.put("given_name", "John");
        claimSet1.put("exp", "should-not-be-a-string");

        DisclosureSpec disclosureSpec = DisclosureSpec.builder()
                .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A")
                .build();

        SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet1, disclosureSpec).build();
        SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet2, disclosureSpec).build();

        for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
            VerificationException exception = assertThrows(
                    VerificationException.class,
                    () -> sdJwt.verify(
                            defaultIssuerVerifyingKeys(),
                            defaultIssuerSignedJwtVerificationOpts()
                                    .withValidateExpirationClaim(true)
                                    .build()
                    )
            );

            assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage());
            assertEquals("Missing or invalid 'exp' claim", exception.getCause().getMessage());
        }
    }

    @Test
    public void sdJwtVerificationShouldFail_IfIssuedInTheFuture() {
        long now = Instant.now().getEpochSecond();

        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("given_name", "John");
        claimSet.put("iat", now + 1000); // issued in the future

        // Exp claim is plain
        SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build();
        // Exp claim is undisclosed
        SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder()
                .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet()))
                .withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A")
                .build()).build();

        for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
            VerificationException exception = assertThrows(
                    VerificationException.class,
                    () -> sdJwt.verify(
                            defaultIssuerVerifyingKeys(),
                            defaultIssuerSignedJwtVerificationOpts()
                                    .withValidateIssuedAtClaim(true)
                                    .build()
                    )
            );

            assertEquals("Issuer-Signed JWT: Invalid `iat` claim", exception.getMessage());
            assertEquals("JWT issued in the future", exception.getCause().getMessage());
        }
    }

    @Test
    public void sdJwtVerificationShouldFail_IfNbfInvalid() {
        long now = Instant.now().getEpochSecond();

        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("given_name", "John");
        claimSet.put("nbf", now + 1000); // now will be too soon to accept the jwt

        // Exp claim is plain
        SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build();
        // Exp claim is undisclosed
        SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder()
                .withRedListedClaimNames(DisclosureRedList.of(Collections.emptySet()))
                .withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A")
                .build()).build();

        for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
            VerificationException exception = assertThrows(
                    VerificationException.class,
                    () -> sdJwt.verify(
                            defaultIssuerVerifyingKeys(),
                            defaultIssuerSignedJwtVerificationOpts()
                                    .withValidateNotBeforeClaim(true)
                                    .build()
                    )
            );

            assertEquals("Issuer-Signed JWT: Invalid `nbf` claim", exception.getMessage());
            assertEquals("JWT is not yet valid", exception.getCause().getMessage());
        }
    }

    @Test
    public void sdJwtVerificationShouldFail_IfSdArrayElementIsNotString() throws JsonProcessingException {
        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("given_name", "John");
        claimSet.set("_sd", mapper.readTree("[123]"));

        SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder().build()).build();

        VerificationException exception = assertThrows(
                VerificationException.class,
                () -> sdJwt.verify(
                        defaultIssuerVerifyingKeys(),
                        defaultIssuerSignedJwtVerificationOpts().build()
                )
        );

        assertEquals("Unexpected non-string element inside _sd array: 123", exception.getMessage());
    }

    @Test
    public void sdJwtVerificationShouldFail_IfForbiddenClaimNames() {
        for (String forbiddenClaimName : Arrays.asList("_sd", "...")) {
            ObjectNode claimSet = mapper.createObjectNode();
            claimSet.put(forbiddenClaimName, "Value");

            SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder()
                    .withUndisclosedClaim(forbiddenClaimName, "eluV5Og3gSNII8EYnsxA_A")
                    .build()).build();

            VerificationException exception = assertThrows(
                    VerificationException.class,
                    () -> sdJwt.verify(
                            defaultIssuerVerifyingKeys(),
                            defaultIssuerSignedJwtVerificationOpts().build()
                    )
            );

            assertEquals("Disclosure claim name must not be '_sd' or '...'", exception.getMessage());
        }
    }

    @Test
    public void sdJwtVerificationShouldFail_IfDuplicateDigestValue() {
        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("given_name", "John"); // this same field will also be nested

        SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder()
                .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A")
                .withDecoyClaim("G02NSrQfjFXQ7Io09syajA")
                .withDecoyClaim("G02NSrQfjFXQ7Io09syajA")
                .build()).build();

        VerificationException exception = assertThrows(
                VerificationException.class,
                () -> sdJwt.verify(
                        defaultIssuerVerifyingKeys(),
                        defaultIssuerSignedJwtVerificationOpts().build()
                )
        );

        assertTrue(exception.getMessage().startsWith("A digest was encountered more than once:"));
    }

    @Test
    public void sdJwtVerificationShouldFail_IfDuplicateSaltValue() {
        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("given_name", "John");
        claimSet.put("family_name", "Doe");

        String salt = "eluV5Og3gSNII8EYnsxA_A";
        SdJwt sdJwt = exampleFlatSdJwtV2(claimSet, DisclosureSpec.builder()
                .withUndisclosedClaim("given_name", salt)
                // We are reusing the same salt value, and that is the problem
                .withUndisclosedClaim("family_name", salt)
                .build()).build();

        VerificationException exception = assertThrows(
                VerificationException.class,
                () -> sdJwt.verify(
                        defaultIssuerVerifyingKeys(),
                        defaultIssuerSignedJwtVerificationOpts().build()
                )
        );

        assertEquals("A salt value was reused: " + salt, exception.getMessage());
    }

    private List<SignatureVerifierContext> defaultIssuerVerifyingKeys() {
        return Collections.singletonList(testSettings.issuerVerifierContext);
    }

    private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() {
        return IssuerSignedJwtVerificationOpts.builder()
                .withValidateIssuedAtClaim(false)
                .withValidateExpirationClaim(false)
                .withValidateNotBeforeClaim(false);
    }

    private SdJwt.Builder exampleFlatSdJwtV1() {
        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c");
        claimSet.put("given_name", "John");
        claimSet.put("family_name", "Doe");
        claimSet.put("email", "john.doe@example.com");

        DisclosureSpec disclosureSpec = DisclosureSpec.builder()
                .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A")
                .withUndisclosedClaim("family_name", "6Ij7tM-a5iVPGboS5tmvVA")
                .withUndisclosedClaim("email", "eI8ZWm9QnKPpNPeNenHdhQ")
                .withDecoyClaim("G02NSrQfjFXQ7Io09syajA")
                .build();

        return SdJwt.builder()
                .withDisclosureSpec(disclosureSpec)
                .withClaimSet(claimSet)
                .withSigner(testSettings.issuerSigContext);
    }

    private SdJwt.Builder exampleFlatSdJwtV2(ObjectNode claimSet, DisclosureSpec disclosureSpec) {
        return SdJwt.builder()
                .withDisclosureSpec(disclosureSpec)
                .withClaimSet(claimSet)
                .withSigner(testSettings.issuerSigContext);
    }

    private SdJwt exampleAddrSdJwt() {
        ObjectNode addressClaimSet = mapper.createObjectNode();
        addressClaimSet.put("street_address", "Rue des Oliviers");
        addressClaimSet.put("city", "Paris");
        addressClaimSet.put("country", "France");

        DisclosureSpec addrDisclosureSpec = DisclosureSpec.builder()
                .withUndisclosedClaim("street_address", "AJx-095VPrpTtN4QMOqROA")
                .withUndisclosedClaim("city", "G02NSrQfjFXQ7Io09syajA")
                .withDecoyClaim("G02NSrQfjFXQ7Io09syajA")
                .build();

        return SdJwt.builder()
                .withDisclosureSpec(addrDisclosureSpec)
                .withClaimSet(addressClaimSet)
                .build();
    }

    private SdJwt.Builder exampleSdJwtWithUndisclosedNestedFieldsV1() {
        SdJwt addrSdJWT = exampleAddrSdJwt();

        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c");
        claimSet.put("given_name", "John");
        claimSet.put("family_name", "Doe");
        claimSet.put("email", "john.doe@example.com");
        claimSet.set("address", addrSdJWT.asNestedPayload());

        DisclosureSpec disclosureSpec = DisclosureSpec.builder()
                .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A")
                .withUndisclosedClaim("family_name", "6Ij7tM-a5iVPGboS5tmvVA")
                .withUndisclosedClaim("email", "eI8ZWm9QnKPpNPeNenHdhQ")
                .build();

        return SdJwt.builder()
                .withDisclosureSpec(disclosureSpec)
                .withClaimSet(claimSet)
                .withNestedSdJwt(addrSdJWT)
                .withSigner(testSettings.issuerSigContext);
    }

    private SdJwt.Builder exampleSdJwtWithUndisclosedArrayElementsV1() throws JsonProcessingException {
        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c");
        claimSet.put("given_name", "John");
        claimSet.put("family_name", "Doe");
        claimSet.put("email", "john.doe@example.com");
        claimSet.set("nationalities", mapper.readTree("[\"US\", \"DE\"]"));

        DisclosureSpec disclosureSpec = DisclosureSpec.builder()
                .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A")
                .withUndisclosedClaim("family_name", "6Ij7tM-a5iVPGboS5tmvVA")
                .withUndisclosedClaim("email", "eI8ZWm9QnKPpNPeNenHdhQ")
                .withUndisclosedArrayElt("nationalities", 1, "nPuoQnkRFq3BIeAm7AnXFA")
                .withDecoyArrayElt("nationalities", 2, "G02NSrQfjFXQ7Io09syajA")
                .build();

        return SdJwt.builder()
                .withDisclosureSpec(disclosureSpec)
                .withClaimSet(claimSet)
                .withSigner(testSettings.issuerSigContext);
    }

    private SdJwt.Builder exampleRecursiveSdJwtV1() {
        SdJwt addrSdJWT = exampleAddrSdJwt();

        ObjectNode claimSet = mapper.createObjectNode();
        claimSet.put("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c");
        claimSet.put("given_name", "John");
        claimSet.put("family_name", "Doe");
        claimSet.put("email", "john.doe@example.com");
        claimSet.set("address", addrSdJWT.asNestedPayload());

        DisclosureSpec disclosureSpec = DisclosureSpec.builder()
                .withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A")
                .withUndisclosedClaim("family_name", "6Ij7tM-a5iVPGboS5tmvVA")
                .withUndisclosedClaim("email", "eI8ZWm9QnKPpNPeNenHdhQ")
                // Making the whole address object selectively disclosable makes the process recursive
                .withUndisclosedClaim("address", "BZFzhQsdPfZY1WSL-1GXKg")
                .build();

        return SdJwt.builder()
                .withDisclosureSpec(disclosureSpec)
                .withClaimSet(claimSet)
                .withNestedSdJwt(addrSdJWT)
                .withSigner(testSettings.issuerSigContext);
    }
}