diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..887e8c4 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,50 @@ +name: Check PR + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-java@v1 + with: + java-version: '17' + java-package: jdk + + - id: bump + uses: zwaldowski/match-label-action@v1 + with: + allowed: major,minor,patch + + - uses: zwaldowski/semver-release-action@v2 + with: + dry_run: true + bump: ${{ steps.bump.outputs.match }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + comment: + runs-on: ubuntu-latest + if: always() + steps: + - uses: technote-space/workflow-conclusion-action@v2 + - name: Checkout + uses: actions/checkout@v1 + + - name: Comment PR + if: env.WORKFLOW_CONCLUSION == 'failure' + uses: thollander/actions-comment-pull-request@1.0.2 + with: + message: "Please apply one of the following labels to the PR: 'patch', 'minor', 'major'." + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0b7bfc1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + + generate-version: + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.out.outputs.version }} + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-java@v1 + with: + java-version: '11' + java-package: jdk + + - id: pr + uses: actions-ecosystem/action-get-merged-pull-request@v1.0.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - uses: zwaldowski/semver-release-action@v2 + with: + dry_run: true + bump: patch + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set version output + id: out + run: echo "::set-output name=version::$(echo ${VERSION})" + + build-and-deploy: + + needs: [ "generate-version" ] + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v2 + + - uses: crazy-max/ghaction-import-gpg@v5 + with: + gpg_private_key: ${{ secrets.GPG_SECRET_KEY }} + passphrase: ${{ secrets.GPG_SECRET_KEY_PASSWORD }} + git_user_signingkey: true + git_commit_gpgsign: true + + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + server-id: sonatype.org + server-username: SONATYPE_ORG_USERNAME + server-password: SONATYPE_ORG_PASSWORD + gpg-private-key: ${{ secrets.GPG_SECRET_KEY }} + gpg-passphrase: GPG_PASSPHRASE + + - name: Set version + run: | + mvn versions:set -DnewVersion=${{ needs.generate-version.outputs.version }} + + - name: Run tests + run: | + mvn clean test + + - name: Build and release it + env: + SONATYPE_ORG_USERNAME: ${{ secrets.SONATYPE_ORG_USERNAME }} + SONATYPE_ORG_PASSWORD: ${{ secrets.SONATYPE_ORG_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_SECRET_KEY_PASSWORD }} + run: | + mvn install deploy -Prelease -Dgpg.keyname=563C5DE0C079D6AD + + + git-release: + needs: [ "generate-version", "build-and-deploy" ] + + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v2 + + - uses: "marvinpinto/action-automatic-releases@latest" + with: + repo_token: "${{ secrets.GITHUB_TOKEN }}" + automatic_release_tag: ${{ needs.generate-version.outputs.version }} + prerelease: false + title: ${{ needs.generate-version.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..66ed8a5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Test + +on: + push: + +jobs: + test: + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + + - uses: actions/checkout@v2 + + - uses: actions/setup-java@v1 + with: + java-version: '21' + java-package: jdk + + - name: Run tests + run: mvn clean test \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b865054 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# DCQL-Java + +A Java implementation of the [Digital Credentials Query Language(DCQL)](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-digital-credentials-query-l). + +## Maven + +The library is avaliable at maven central: + +## Example usage + +In order to evaluate DCQL-Queries, a list of [VerifiableCredentials](https://en.wikipedia.org/wiki/Verifiable_credentials) has to be provided. +The library itself uses a minimum of dependencies, therefor parsing of credentials and queries needs to be done by the caller. +A possible option is [Jackson](https://github.com/FasterXML/jackson). In order to properly deserialize a query, the [ObjectMapper](https://www.baeldung.com/jackson-object-mapper-tutorial) +needs to be configured as following: + +```java + ObjectMapper objectMapper = new ObjectMapper(); + // future and backwards compatible, just ignore unsupported parts + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // properties should be translated following snake-case, e.g. `claimSet` becomes `claim_set`and vice versa + objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + SimpleModule deserializerModule = new SimpleModule(); + // help deserialization of the enums. See test/java/io/github/wistefan/dcql/helper for their implementations + deserializerModule.addDeserializer(CredentialFormat.class, new CredentialFormatDeserializer()); + deserializerModule.addDeserializer(TrustedAuthorityType.class, new TrustedAuthorityTypeDeserializer()); + objectMapper.registerModule(deserializerModule); +``` + +Since credentials are usually not standard json-format, additional helper might be required. In case of sd-jwt and jwt credentials, +a library like [Nimbus JOSE+JWT](https://mvnrepository.com/artifact/com.nimbusds/nimbus-jose-jwt) can be used. See examples for loading SD and JWT credentials +in the [ParseCredentialTest](./src/test/java/io/github/wistefan/dcql/example/ParseCredentialTest.java) + +After loading the credentials and providing query, evaluation is straight-forward: +```java + // this configuration would support all CredentialFormats currently included in DCQL. + DCQLEvaluator dcqlEvaluator = new DCQLEvaluator(List.of( + new JwtCredentialEvaluator(), + new DcSdJwtCredentialEvaluator(), + new VcSdJwtCredentialEvaluator(), + new MDocCredentialEvaluator(), + new LdpCredentialEvaluator())); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(dcqlQuery, credentialsList); +``` + +The [QueryResult](./src/main/java/io/github/wistefan/dcql/QueryResult.java) provides a quick success indicator and the filtered list of credentials to be used. +In case of SD-JWT Credentials, only the requested elements are disclosed. + +## Limitations + +As of now, DCQL-Java only supports querying for trusted authorities of type [Authority Key Identifier("aki")](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-authority-key-identifier). +In order to do so, a [bouncycastle](https://www.bouncycastle.org/) implementation needs to be provided: + +```xml + + org.bouncycastle + bcprov-jdk18on + ${version.org.bouncycastle} + + + org.bouncycastle + bcpkix-jdk18on + ${version.org.bouncycastle} + +``` + +## License + +DCQL-Java is licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full license text. + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9791971 --- /dev/null +++ b/pom.xml @@ -0,0 +1,299 @@ + + + 4.0.0 + io.github.wistefan + dcql-java + 0.0.1 + jar + + + + ${project.author.name} + ${project.author.email} + + + + ${project.groupId}:${project.artifactId} + ${project.description} + ${project.url} + + + ${project.license.name} + ${project.license.url} + + + + scm:git:git://github.com/wistefan/dcql-java.git + scm:git:git@github.com:wistefan/dcql-java.git + https://github.com/wistefan/dcql-java/tree/main + HEAD + + + + + + sonatype.org + https://central.sonatype.com/repository/maven-snapshots/ + + + + + 21 + 21 + + + Stefan Wiedemann + stefan.wiedemann@seamware.com + A Java-Implementation of the DCQL(Digital Credentials Query Language). + + DCQL Java + https://github.com/wistefan/dcql-java + Apache License 2.0 + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + DCQL Java + stefan.wiedemann@seamware.com + UTF-8 + UTF-8 + + + key + pass + + + 1.18.30 + + 2.0.17 + + + 1.81 + + + 3.6.1 + 3.3.1 + + 3.14.0 + + 3.21.0 + 4.9.4.2 + 0.8.13 + 3.11.3 + + 0.8.0 + 3.2.8 + 3.5.3 + + + + 5.13.4 + 2.20.0 + 10.5 + + + + + org.projectlombok + lombok + ${version.org.projectlombok} + provided + + + + org.bouncycastle + bcprov-jdk18on + ${version.org.bouncycastle} + provided + + + org.bouncycastle + bcpkix-jdk18on + ${version.org.bouncycastle} + provided + + + + org.slf4j + slf4j-api + ${version.org.slf4j} + provided + + + + com.fasterxml.jackson.core + jackson-databind + ${version.com.fasterxml.jackson.core} + test + + + com.fasterxml.jackson.core + jackson-core + ${version.com.fasterxml.jackson.core} + test + + + org.junit.jupiter + junit-jupiter-engine + ${version.org.junit.jupiter} + test + + + org.junit.jupiter + junit-jupiter-api + ${version.org.junit.jupiter} + test + + + org.junit.jupiter + junit-jupiter-params + ${version.org.junit.jupiter} + test + + + com.nimbusds + nimbus-jose-jwt + ${version.com.nimbusds.nimbus-jose-jwt} + test + + + + + + + + src/main/resources + true + + + + + org.codehaus.mojo + build-helper-maven-plugin + ${version.org.codehaus.mojo.build-helper-maven-plugin} + + + org.apache.maven.plugins + maven-compiler-plugin + ${version.org.apache.maven.plugins.maven-compiler-plugin} + + ${jdk.version} + ${jdk.version} + + + org.projectlombok + lombok + ${version.org.projectlombok} + + + + + + test-compile + + testCompile + + + + + org.projectlombok + lombok + ${version.org.projectlombok} + + + + + + + + org.apache.maven.plugins + maven-site-plugin + ${version.org.apache.maven.plugins.maven-site-plugin} + + + org.apache.maven.plugins + maven-surefire-plugin + ${version.org.apache.maven.plugins.maven-surfire-plugin} + + + com.github.spotbugs + spotbugs-maven-plugin + ${version.com.github.spotbugs.maven-plugin} + + true + false + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${version.org.apache.maven.plugins.maven-javadoc-plugin} + + + org.apache.maven.plugins + maven-source-plugin + ${version.org.apache.maven.plugins.maven-source-plugin} + + + attach-sources + + jar + + + + + + + + + + + release + + + + org.sonatype.central + central-publishing-maven-plugin + ${version.org.sonatype.central.publishing-plugin} + true + + sonatype.org + true + published + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${version.org.apache.maven.plugins.maven-javadoc-plugin} + + ${java.home}/bin/javadoc + + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + ${version.org.apache.maven.plugins.maven-gpg-plugin} + + + sign-artifacts + verify + + sign + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/io/github/wistefan/dcql/ClaimsEvaluator.java b/src/main/java/io/github/wistefan/dcql/ClaimsEvaluator.java new file mode 100644 index 0000000..8d48bdd --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/ClaimsEvaluator.java @@ -0,0 +1,237 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.ClaimsQuery; +import io.github.wistefan.dcql.model.credential.*; +import lombok.extern.slf4j.Slf4j; + +import java.security.NoSuchAlgorithmException; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Evaluator for ClaimsQueries{@see https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-claims-query} + */ +@Slf4j +public class ClaimsEvaluator { + + // key for selective disclosure values inside the VC + private static final String SD_KEY = "_sd"; + + /** + * Evaluate claims query for MDoc-Credentials. + */ + public static Optional evaluateClaimsForMDocCredential(ClaimsQuery claimsQuery, MDocCredential credential) { + List selectedClaims = new ArrayList<>(); + try { + selectedClaims = selectClaimsByPath(credential.getPayload(), claimsQuery.getPath()); + } catch (IllegalArgumentException iae) { + log.debug("Did not find the requested claims.", iae); + return Optional.empty(); + } + + if (claimsQuery.getValues() == null || claimsQuery.getValues().isEmpty()) { + return Optional.of(credential); + } + + // checks if a value exists in the selected claims, that is not in the list of allowedValues. + if (selectedClaims.stream().allMatch(sC -> claimsQuery.getValues().contains(sC.value()))) { + return Optional.of(credential); + } + return Optional.empty(); + } + + /** + * Evaluate claims query for SD-JWT-Credentials. The evaluator will check the disclosure and only include the requested + * disclosures in the resulting credential. + */ + public static Optional evaluateClaimsForSdJwtCredential(ClaimsQuery claimsQuery, SdJwtCredential credential) { + List selectedClaims = new ArrayList<>(); + try { + selectedClaims = selectClaimsByPathDisclosures(credential.getJwtCredential().getPayload(), claimsQuery.getPath(), credential.getDisclosures()); + } catch (IllegalArgumentException iae) { + log.debug("Did not find the requested claims.", iae); + return Optional.empty(); + } + + if (claimsQuery.getValues() == null || claimsQuery.getValues().isEmpty()) { + return Optional.of(cleanUpDisclosures(selectedClaims, credential)); + } + + // checks if a value exists in the selected claims, that is not in the list of allowedValues. + if (selectedClaims.stream().allMatch(sC -> claimsQuery.getValues().contains(sC.value()))) { + return Optional.of(cleanUpDisclosures(selectedClaims, credential)); + } + return Optional.empty(); + } + + private static SdJwtCredential cleanUpDisclosures(List selectedClaims, SdJwtCredential credential) { + Set hashsToInclude = selectedClaims.stream().map(SelectedClaim::hash).collect(Collectors.toSet()); + List cleanedDisclosures = credential.getDisclosures() + .stream() + .filter(disclosure -> hashsToInclude.contains(disclosure.getSdHash())) + .toList(); + return new SdJwtCredential(credential.getRaw(), credential.getJwtCredential(), cleanedDisclosures); + } + + /** + * Evaluate the claims query for JWT Credentials + */ + public static Optional evaluateClaimsForJwtCredential(ClaimsQuery claimsQuery, JwtCredential credential) { + List selectedClaims = new ArrayList<>(); + try { + selectedClaims = selectClaimsByPath(credential.getPayload(), claimsQuery.getPath()); + } catch (IllegalArgumentException iae) { + log.debug("Did not find the requested claims.", iae); + return Optional.empty(); + } + if (claimsQuery.getValues() == null || claimsQuery.getValues().isEmpty()) { + return Optional.of(credential); + } + + // checks if a value exists in the selected claims, that is not in the list of allowedValues. + if (selectedClaims.stream().allMatch(sC -> claimsQuery.getValues().contains(sC.value()))) { + return Optional.of(credential); + } + return Optional.empty(); + } + + /** + * Evaluate the claims query for LDP Credentials + */ + public static Optional evaluateClaimsForLdpCredential(ClaimsQuery claimsQuery, LdpCredential credential) { + List selectedClaims = new ArrayList<>(); + try { + selectedClaims = selectClaimsByPath(credential.getTheCredential(), claimsQuery.getPath()); + } catch (IllegalArgumentException iae) { + log.debug("Did not find the requested claims.", iae); + return Optional.empty(); + } + if (claimsQuery.getValues() == null || claimsQuery.getValues().isEmpty()) { + return Optional.of(credential); + } + + // checks if a value exists in the selected claims, that is not in the list of allowedValues. + if (selectedClaims.stream().allMatch(sC -> claimsQuery.getValues().contains(sC.value()))) { + return Optional.of(credential); + } + return Optional.empty(); + } + + + private static List selectClaimsByPath(Map credential, List claimPath) { + return processPath(credential, claimPath, null); + } + + private static List selectClaimsByPathDisclosures(Map credential, List claimPath, + List disclosures) { + return processPath(credential, claimPath, disclosures); + } + + private static List processPath( + Map credential, + List claimPath, + List disclosures) { + if (credential == null || claimPath == null || claimPath.isEmpty()) { + throw new IllegalArgumentException("Credential and claimPath must not be null or empty"); + } + + // Start with root + List current = new ArrayList<>(); + current.add(new SelectedClaim(credential, null)); + + for (Object component : claimPath) { + List nextSelection = new ArrayList<>(); + + for (SelectedClaim candidateWrapper : current) { + Object candidate = candidateWrapper.value; + + // If map contains _sd, reveal it and MERGE revealed entries with the original map + if (disclosures != null && candidate instanceof Map mapCandidate && mapCandidate.containsKey(SD_KEY)) { + Object sdObj = mapCandidate.get(SD_KEY); + Map revealed = null; + revealed = getStringSelectedClaimMap(disclosures, sdObj); + + // Merge: start with revealed, then copy original entries (except "_sd"), + // so explicit values in the original map overwrite revealed ones if keys collide. + Map merged = new LinkedHashMap<>(); + merged.putAll(revealed); + for (Map.Entry e : mapCandidate.entrySet()) { + String k = String.valueOf(e.getKey()); + if (SD_KEY.equals(k)) continue; + merged.put(k, e.getValue()); + } + candidate = merged; + } + + // Process path component + if (component instanceof String key) { + if (!(candidate instanceof Map map)) { + throw new IllegalArgumentException("Expected object for key lookup but found: " + candidate); + } + if (map.containsKey(key)) { + Object val = map.get(key); + if (val instanceof SelectedClaim sc) { + nextSelection.add(sc); + } else { + nextSelection.add(new SelectedClaim(val, null)); + } + } + } else if (component == null) { + if (!(candidate instanceof List list)) { + throw new IllegalArgumentException("Expected array for null selector but found: " + candidate); + } + for (Object elem : list) { + if (elem instanceof SelectedClaim sc) { + nextSelection.add(sc); + } else { + nextSelection.add(new SelectedClaim(elem, null)); + } + } + } else if (component instanceof Integer index && index >= 0) { + if (!(candidate instanceof List list)) { + throw new IllegalArgumentException("Expected array for index selector but found: " + candidate); + } + if (index < list.size()) { + Object val = list.get(index); + if (val instanceof SelectedClaim sc) { + nextSelection.add(sc); + } else { + nextSelection.add(new SelectedClaim(val, null)); + } + } + } else { + throw new IllegalArgumentException("Invalid claim path component: " + component); + } + } + if (nextSelection.isEmpty()) { + throw new IllegalArgumentException("No elements selected at path component: " + component); + } + current = nextSelection; + } + return current; + } + + + private static Map getStringSelectedClaimMap(List disclosures, Object sdObj) { + if (!(sdObj instanceof List sdList)) { + throw new IllegalArgumentException("_sd field must be a list"); + } + + Map revealed = new LinkedHashMap<>(); + for (Object hashObj : sdList) { + if (!(hashObj instanceof String hash)) continue; + for (Disclosure disclosure : disclosures) { + String sdHash = disclosure.getSdHash(); + if (hash.equals(sdHash)) { + revealed.put(disclosure.getClaim(), new SelectedClaim(disclosure.getValue(), sdHash)); + } + } + } + return revealed; + } + + // helper record for selective disclosure claims to be used for the evaluation of SD-Credentials. + private record SelectedClaim(Object value, String hash) { + } + +} diff --git a/src/main/java/io/github/wistefan/dcql/CredentialEvaluator.java b/src/main/java/io/github/wistefan/dcql/CredentialEvaluator.java new file mode 100644 index 0000000..1f865ff --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/CredentialEvaluator.java @@ -0,0 +1,30 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.Credential; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.CredentialQuery; +import io.github.wistefan.dcql.model.credential.CredentialBase; + +import java.util.List; + +/** + * Evaluator interface to execute queries on a certain type of credentials + */ +public interface CredentialEvaluator { + + /** + * Returns the {@link CredentialFormat} suppored by that evaluator. + */ + CredentialFormat supportedFormat(); + + /** + * Translates the list of {@link Credential}s into the concrete types supported by that evaluator. Will fail if + * the list contains other types. + */ + List translate(List credentials); + + /** + * Evaluate the query on the list of credentials. + */ + List evaluate(CredentialQuery credentialQuery, List credentialsList); +} diff --git a/src/main/java/io/github/wistefan/dcql/CredentialMapper.java b/src/main/java/io/github/wistefan/dcql/CredentialMapper.java new file mode 100644 index 0000000..c34ead1 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/CredentialMapper.java @@ -0,0 +1,89 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.Credential; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.credential.*; + +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class to map raw {@link Credential}s into concrete Credential-Classes. + */ +public class CredentialMapper { + + /** + * Convert the list of raw credentials into typed credentials. + * + * @param credentialFormat format of the credentials in the list + * @param rawCredentials raw credentials to be mapped + * @return the list of typed credentials + */ + public static List toCredentials(CredentialFormat credentialFormat, List rawCredentials) { + return rawCredentials.stream() + .filter(CredentialBase.class::isInstance) + .map(CredentialBase.class::cast) + .map(rC -> new Credential(credentialFormat, rC)) + .toList(); + } + + /** + * Return the {@link LdpCredential}s from the given list. Fails if the list is multi-credential. + */ + public static List toLdpCredentials(List credentialsList) { + List ldpCredentialsList = new ArrayList<>(); + for (Credential c : credentialsList) { + if (c.getRawCredential() instanceof LdpCredential ldpCredential) { + ldpCredentialsList.add(ldpCredential); + } else { + throw new IllegalArgumentException("The given credential does not contain an ldp_vc."); + } + } + return ldpCredentialsList; + } + + /** + * Return the {@link MDocCredential}s from the given list. Fails if the list is multi-credential. + */ + public static List toMDocCredentials(List credentialsList) { + List mDocCredentialsList = new ArrayList<>(); + for (Credential c : credentialsList) { + if (c.getRawCredential() instanceof MDocCredential mDocCredential) { + mDocCredentialsList.add(mDocCredential); + } else { + throw new IllegalArgumentException("The given credential does not contain an mso_mdoc."); + } + } + return mDocCredentialsList; + } + + /** + * Return the {@link JwtCredential}s from the given list. Fails if the list is multi-credential. + */ + public static List toJWTCredentials(List credentialsList) { + List jwtCredentialsList = new ArrayList<>(); + for (Credential c : credentialsList) { + if (c.getRawCredential() instanceof JwtCredential jwtCredential) { + jwtCredentialsList.add(jwtCredential); + } else { + throw new IllegalArgumentException("The given credential does not contain an jwt_vc_json."); + } + } + return jwtCredentialsList; + } + + /** + * Return the {@link SdJwtCredential}s from the given list. Fails if the list is multi-credential. + */ + public static List toSdJWTCredentials(List credentialsList) { + List sdJwtCredentialsList = new ArrayList<>(); + for (Credential c : credentialsList) { + if (c.getRawCredential() instanceof SdJwtCredential sdJWTCredential) { + sdJwtCredentialsList.add(sdJWTCredential); + } else { + throw new IllegalArgumentException("The given credential does not contain an vc+sd-jwt/dc+sd-jwt."); + } + } + return sdJwtCredentialsList; + } +} diff --git a/src/main/java/io/github/wistefan/dcql/DCQLEvaluator.java b/src/main/java/io/github/wistefan/dcql/DCQLEvaluator.java new file mode 100644 index 0000000..114828d --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/DCQLEvaluator.java @@ -0,0 +1,154 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.*; +import io.github.wistefan.dcql.model.credential.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +/** + * Evaluator for DCQL Queries{@see https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-digital-credentials-query-l} + */ +@RequiredArgsConstructor +@Slf4j +public class DCQLEvaluator { + + // default key for non-credential-set results. + private static final String DEFAULT_KEY = "credentials"; + + private final List credentialEvaluators; + + public QueryResult evaluateDCQLQuery(DcqlQuery dcqlQuery, List credentialsList) { + if (containsCredentialSets(dcqlQuery)) { + // linked map to contain set order + Map> resultMap = new LinkedHashMap<>(); + validateIds(dcqlQuery.getCredentials()); + Map credentialQueryMap = new HashMap<>(); + dcqlQuery.getCredentials() + .forEach(cq -> credentialQueryMap.put(cq.getId(), cq)); + for (CredentialSetQuery credentialSetQuery : dcqlQuery.getCredentialSets()) { + List credentialsForSet = evaluateCredentialSetQuery(credentialQueryMap, credentialSetQuery, credentialsList); + if (credentialsForSet.isEmpty() && credentialSetQuery.getRequired()) { + log.debug("The query cannot be fulfilled, since a required set is empty."); + return new QueryResult(false, Map.of()); + } + resultMap.put(purposeOrRandom(credentialSetQuery), credentialsForSet); + } + return new QueryResult(true, resultMap); + } else { + List selectedCredentials = new ArrayList<>(); + for (CredentialQuery cq : dcqlQuery.getCredentials()) { + List credentialsFullfilling = evaluateCredentialQuery(cq, credentialsList); + if (credentialsFullfilling.isEmpty()) { + log.debug("When one of the credentials requirements is not fulfilled, the query should fail."); + return new QueryResult(false, Map.of()); + } + if (!cq.getMultiple() && credentialsFullfilling.size() != 1) { + log.debug("Multiple credentials where returend for a query not allowing multiple."); + return new QueryResult(false, Map.of()); + } + selectedCredentials.addAll(credentialsFullfilling); + } + // if no sets are requested, put the credentials at one + return new QueryResult(true, Map.of(DEFAULT_KEY, selectedCredentials)); + } + + } + + private List evaluateCredentialSetQuery(Map credentialQueryMap, + CredentialSetQuery credentialSetQuery, + List credentials) { + for (List option : credentialSetQuery.getOptions()) { + // set to prevent duplicates + Set fullfillingCredentials = new HashSet<>(); + fullfillingCredentials.addAll( + option.stream() + .map(credentialQueryMap::get) + .map(cq -> evaluateCredentialQuery(cq, credentials)) + .flatMap(List::stream) + .collect(Collectors.toSet())); + // return the first option that fulfills the query + if (!fullfillingCredentials.isEmpty()) { + return new ArrayList<>(fullfillingCredentials); + } + } + return List.of(); + } + + private List evaluateCredentialQuery(CredentialQuery credentialQuery, List credentialsList) { + + if (!containsClaims(credentialQuery) + && containsClaimSets(credentialQuery)) { + throw new IllegalArgumentException("Queries with claim_set require to have claims, too."); + } + + List filteredByFormat = filterByFormat(credentialQuery.getFormat(), credentialsList); + CredentialEvaluator credentialEvaluator = this.credentialEvaluators + .stream() + .filter(evaluator -> evaluator.supportedFormat() == credentialQuery.getFormat()) + .findAny() + .orElseThrow(() -> new IllegalArgumentException(String.format("The format %s is not supported. Consider registering a matching evaluator.", credentialQuery.getFormat()))); + return credentialEvaluator.evaluate(credentialQuery, credentialEvaluator.translate(filteredByFormat)); + } + + // The method returns the first claim set that is fullfilled. It can contain multiple credentials, that would + // fulfill the set individually, leaving the choice of what to share to the upstream. + protected static List evaluateForClaimSet(CredentialQuery credentialQuery, List initialCredentials, BiFunction, List> evaluationFunction) { + Map claimsQueryMap = new HashMap<>(); + credentialQuery.getClaims() + .forEach(cq -> claimsQueryMap.put(cq.getId(), cq)); + + for (List claimSet : credentialQuery.getClaimSets()) { + List credentialsForClaimSet = new ArrayList<>(initialCredentials); + for (String claimId : claimSet) { + ClaimsQuery claimsQuery = claimsQueryMap.get(claimId); + credentialsForClaimSet = evaluationFunction.apply(claimsQuery, credentialsForClaimSet); + } + if (!credentialsForClaimSet.isEmpty()) { + return CredentialMapper.toCredentials(credentialQuery.getFormat(), credentialsForClaimSet); + } + } + return List.of(); + } + + + private static List filterByFormat(CredentialFormat credentialFormat, List credentialsList) { + return credentialsList.stream() + .filter(c -> c.getCredentialFormat() == credentialFormat) + .toList(); + } + + + public static boolean containsClaims(CredentialQuery credentialQuery) { + return credentialQuery.getClaims() != null && !credentialQuery.getClaims().isEmpty(); + } + + public static boolean containsClaimSets(CredentialQuery credentialQuery) { + return credentialQuery.getClaimSets() != null && !credentialQuery.getClaimSets().isEmpty(); + } + + public static boolean containsMeta(CredentialQuery credentialQuery) { + return credentialQuery.getMeta() != null && !credentialQuery.getMeta().isEmpty(); + } + + public static boolean containsTrustAuthorities(CredentialQuery credentialQuery) { + return credentialQuery.getTrustedAuthorities() != null && !credentialQuery.getTrustedAuthorities().isEmpty(); + } + + public static boolean containsCredentialSets(DcqlQuery dcqlQuery) { + return dcqlQuery.getCredentialSets() != null && !dcqlQuery.getCredentialSets().isEmpty(); + } + + private static void validateIds(List credentialQueries) { + if (credentialQueries.stream().anyMatch(cq -> cq.getId() == null)) { + throw new IllegalArgumentException("All credentialQueries need to contain an id."); + } + } + + private static Object purposeOrRandom(CredentialSetQuery credentialSetQuery) { + return Optional.ofNullable(credentialSetQuery.getPurpose()).orElse(UUID.randomUUID().toString()); + } +} diff --git a/src/main/java/io/github/wistefan/dcql/DcSdJwtCredentialEvaluator.java b/src/main/java/io/github/wistefan/dcql/DcSdJwtCredentialEvaluator.java new file mode 100644 index 0000000..278f4ac --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/DcSdJwtCredentialEvaluator.java @@ -0,0 +1,11 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.CredentialFormat; + +public class DcSdJwtCredentialEvaluator extends SdJwtCredentialEvaluator { + @Override + public CredentialFormat supportedFormat() { + return CredentialFormat.DC_SD_JWT; + } + +} diff --git a/src/main/java/io/github/wistefan/dcql/EvaluationException.java b/src/main/java/io/github/wistefan/dcql/EvaluationException.java new file mode 100644 index 0000000..aeeab84 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/EvaluationException.java @@ -0,0 +1,11 @@ +package io.github.wistefan.dcql; + +public class EvaluationException extends RuntimeException { + public EvaluationException(String message) { + super(message); + } + + public EvaluationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/io/github/wistefan/dcql/JwtCredentialEvaluator.java b/src/main/java/io/github/wistefan/dcql/JwtCredentialEvaluator.java new file mode 100644 index 0000000..3491d34 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/JwtCredentialEvaluator.java @@ -0,0 +1,67 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.*; +import io.github.wistefan.dcql.model.credential.JwtCredential; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.github.wistefan.dcql.DCQLEvaluator.*; + +/** + * Evaluator implementation for JWT Credentials + */ +public class JwtCredentialEvaluator implements CredentialEvaluator { + @Override + public CredentialFormat supportedFormat() { + return CredentialFormat.JWT_VC_JSON; + } + + @Override + public List translate(List credentials) { + return CredentialMapper.toJWTCredentials(credentials); + + } + + @Override + public List evaluate(CredentialQuery credentialQuery, List jwtCredentials) { + if (containsMeta(credentialQuery)) { + jwtCredentials = filterJwtByMetadata(credentialQuery.getMeta(), jwtCredentials); + } + if (containsTrustAuthorities(credentialQuery)) { + for (TrustedAuthorityQuery taq : credentialQuery.getTrustedAuthorities()) { + jwtCredentials = jwtCredentials.stream() + .filter(credential -> TrustedAuthoritiesEvaluator.evaluateQueryForJwtCredential(taq, credential)) + .toList(); + } + } + if (containsClaims(credentialQuery) && !containsClaimSets(credentialQuery)) { + for (ClaimsQuery cq : credentialQuery.getClaims()) { + jwtCredentials = evaluateJwtCredentialsClaimQuery(cq, jwtCredentials); + } + } else if (containsClaims(credentialQuery)) { + return evaluateForClaimSet(credentialQuery, jwtCredentials, JwtCredentialEvaluator::evaluateJwtCredentialsClaimQuery); + } + return CredentialMapper.toCredentials(CredentialFormat.JWT_VC_JSON, jwtCredentials); + } + + private static List filterJwtByMetadata(Map metaData, List credentialsList) { + W3CMetaData w3CMetaData = W3CMetaData.fromMeta(metaData); + return credentialsList.stream() + .filter(jwtCredential -> + w3CMetaData.getTypeValues() + .stream() + .anyMatch(metaTypes -> new HashSet<>(jwtCredential.getType()).containsAll(metaTypes))) + .toList(); + } + + private static List evaluateJwtCredentialsClaimQuery(ClaimsQuery cq, List jwtCredentials) { + return jwtCredentials.stream() + .map(credential -> ClaimsEvaluator.evaluateClaimsForJwtCredential(cq, credential)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + } +} diff --git a/src/main/java/io/github/wistefan/dcql/LdpCredentialEvaluator.java b/src/main/java/io/github/wistefan/dcql/LdpCredentialEvaluator.java new file mode 100644 index 0000000..dccb7a5 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/LdpCredentialEvaluator.java @@ -0,0 +1,65 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.*; +import io.github.wistefan.dcql.model.credential.LdpCredential; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.github.wistefan.dcql.DCQLEvaluator.*; + +/** + * Evaluator for LDP Credentials + */ +public class LdpCredentialEvaluator implements CredentialEvaluator { + + @Override + public CredentialFormat supportedFormat() { + return CredentialFormat.LDP_VC; + } + + @Override + public List translate(List credentials) { + return CredentialMapper.toLdpCredentials(credentials); + } + + @Override + public List evaluate(CredentialQuery credentialQuery, List ldpCredentials) { + + if (containsMeta(credentialQuery)) { + ldpCredentials = filterLdpByMetadata(credentialQuery.getMeta(), ldpCredentials); + } + + if (containsClaims(credentialQuery) && !containsClaimSets(credentialQuery)) { + for (ClaimsQuery cq : credentialQuery.getClaims()) { + ldpCredentials = evaluateLdpCredentialsClaimQuery(cq, ldpCredentials); + } + } else if (containsClaims(credentialQuery)) { + return evaluateForClaimSet(credentialQuery, ldpCredentials, LdpCredentialEvaluator::evaluateLdpCredentialsClaimQuery); + } + + return CredentialMapper.toCredentials(CredentialFormat.LDP_VC, ldpCredentials); + } + + + private static List filterLdpByMetadata(Map metaData, List credentialsList) { + W3CMetaData w3CMetaData = W3CMetaData.fromMeta(metaData); + return credentialsList.stream() + .filter(ldpCredential -> + w3CMetaData.getTypeValues() + .stream() + .anyMatch(metaTypes -> new HashSet<>(ldpCredential.getType()).containsAll(metaTypes))) + .toList(); + } + + private static List evaluateLdpCredentialsClaimQuery(ClaimsQuery cq, List ldpCredentials) { + return ldpCredentials.stream() + .map(credential -> ClaimsEvaluator.evaluateClaimsForLdpCredential(cq, credential)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + } + +} diff --git a/src/main/java/io/github/wistefan/dcql/MDocCredentialEvaluator.java b/src/main/java/io/github/wistefan/dcql/MDocCredentialEvaluator.java new file mode 100644 index 0000000..5042fb5 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/MDocCredentialEvaluator.java @@ -0,0 +1,91 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.*; +import io.github.wistefan.dcql.model.credential.MDocCredential; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.github.wistefan.dcql.DCQLEvaluator.*; + +/** + * Evaluator for MDoc Credentials + */ +public class MDocCredentialEvaluator implements CredentialEvaluator { + + // key to the namespaces in an MDoc credential + private static final String MDOC_NAMESPACE_KEY = "namespaces"; + + @Override + public CredentialFormat supportedFormat() { + return CredentialFormat.MSO_MDOC; + } + + @Override + public List translate(List credentials) { + return CredentialMapper.toMDocCredentials(credentials); + } + + @Override + public List evaluate(CredentialQuery credentialQuery, List mDocCredentials) { + if (containsMeta(credentialQuery)) { + mDocCredentials = filterMDocByMetadata(credentialQuery.getMeta(), mDocCredentials); + } + if (containsTrustAuthorities(credentialQuery)) { + for (TrustedAuthorityQuery taq : credentialQuery.getTrustedAuthorities()) { + mDocCredentials = mDocCredentials.stream() + .filter(credential -> TrustedAuthoritiesEvaluator.evaluateQueryForMDocCredential(taq, credential)) + .toList(); + } + } + translateMDocQueries(credentialQuery); + if (containsClaims(credentialQuery) && !containsClaimSets(credentialQuery)) { + for (ClaimsQuery cq : credentialQuery.getClaims()) { + mDocCredentials = evaluateMDocCredentialsClaimQuery(cq, mDocCredentials); + } + } else if (containsClaims(credentialQuery)) { + return evaluateForClaimSet(credentialQuery, mDocCredentials, MDocCredentialEvaluator::evaluateMDocCredentialsClaimQuery); + } + return CredentialMapper.toCredentials(CredentialFormat.MSO_MDOC, mDocCredentials); + } + + private static List filterMDocByMetadata(Map metaData, List credentialsList) { + MDocMetaData mDocMetaData = MDocMetaData.fromMeta(metaData); + return credentialsList.stream() + .filter(mDocCredential -> mDocCredential.getDocType().equals(mDocMetaData.getDocType())) + .toList(); + } + + private static CredentialQuery translateMDocQueries(CredentialQuery credentialQuery) { + if (credentialQuery.getClaims() == null) { + return credentialQuery; + } + credentialQuery.getClaims() + .forEach(cq -> { + if (isMDocClaimsQuery(cq) && cq.getNamespace() != null) { + cq.setPath(List.of(MDOC_NAMESPACE_KEY, cq.getNamespace(), cq.getClaimName())); + } else { + cq.getPath().addFirst(MDOC_NAMESPACE_KEY); + } + }); + return credentialQuery; + } + + private static List evaluateMDocCredentialsClaimQuery(ClaimsQuery cq, List mDocCredentials) { + return mDocCredentials.stream() + .map(credential -> ClaimsEvaluator.evaluateClaimsForMDocCredential(cq, credential)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + } + + private static boolean isMDocClaimsQuery(ClaimsQuery claimsQuery) { + if ((claimsQuery.getNamespace() != null && claimsQuery.getClaimName() == null) || (claimsQuery.getNamespace() == null && claimsQuery.getClaimName() != null)) { + throw new IllegalArgumentException("When a namespace or claim_name is set, the other parameter is mandatory."); + } + return claimsQuery.getIntent_to_retain() != null || claimsQuery.getNamespace() != null; + } + + +} diff --git a/src/main/java/io/github/wistefan/dcql/QueryResult.java b/src/main/java/io/github/wistefan/dcql/QueryResult.java new file mode 100644 index 0000000..4f4ca47 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/QueryResult.java @@ -0,0 +1,17 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.Credential; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Result of a DCQL evaluation + * + * @param success - did the query succeed + * @param credentials - the credentials returned by the query. If credential_sets is present, they are keyed by their + * purpose or if omitted a random id. + */ +public record QueryResult(boolean success, Map> credentials) { +} diff --git a/src/main/java/io/github/wistefan/dcql/SdJwtCredentialEvaluator.java b/src/main/java/io/github/wistefan/dcql/SdJwtCredentialEvaluator.java new file mode 100644 index 0000000..c5640ae --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/SdJwtCredentialEvaluator.java @@ -0,0 +1,99 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.*; +import io.github.wistefan.dcql.model.credential.Disclosure; +import io.github.wistefan.dcql.model.credential.SdJwtCredential; + +import java.util.*; +import java.util.stream.Collectors; + +import static io.github.wistefan.dcql.DCQLEvaluator.*; + +/** + * Evaluator implementation for SD-JWT Credentials + */ +public abstract class SdJwtCredentialEvaluator implements CredentialEvaluator { + + @Override + public List translate(List credentials) { + return CredentialMapper.toSdJWTCredentials(credentials); + } + + @Override + public List evaluate(CredentialQuery credentialQuery, List sdJwtCredentials) { + if (containsMeta(credentialQuery)) { + sdJwtCredentials = filterSdJwtByMetadata(credentialQuery.getMeta(), sdJwtCredentials); + } + if (containsTrustAuthorities(credentialQuery)) { + for (TrustedAuthorityQuery taq : credentialQuery.getTrustedAuthorities()) { + sdJwtCredentials = sdJwtCredentials.stream() + .filter(credential -> TrustedAuthoritiesEvaluator.evaluateQueryForSDJwtCredential(taq, credential)) + .toList(); + } + } + if (containsClaims(credentialQuery) && !containsClaimSets(credentialQuery)) { + sdJwtCredentials = evaluateSdJwtCredentialsQuery(credentialQuery, sdJwtCredentials); + } else if (containsClaims(credentialQuery)) { + return evaluateSdJwtForClaimSet(credentialQuery, sdJwtCredentials); + } else { + sdJwtCredentials = sdJwtCredentials.stream() + // keep the original credential untouched + .map(sdJwtCredential -> new SdJwtCredential(sdJwtCredential.getRaw(), sdJwtCredential.getJwtCredential(), List.of())) + .toList(); + } + return CredentialMapper.toCredentials(credentialQuery.getFormat(), sdJwtCredentials); + } + + private static List filterSdJwtByMetadata(Map metaData, List credentialsList) { + JwtMetaData jwtMetaData = JwtMetaData.fromMeta(metaData); + return credentialsList.stream() + .filter(sdJwtCredential -> jwtMetaData.getVctValues().contains(sdJwtCredential.getVct())) + .toList(); + } + + private static List evaluateSdJwtCredentialsQuery(CredentialQuery credentialQuery, List sdJwtCredentials) { + List disclosedCredentials = new ArrayList<>(); + for (SdJwtCredential credential : sdJwtCredentials) { + Set selectedDisclosures = credentialQuery.getClaims() + .stream() + .map(cq -> ClaimsEvaluator.evaluateClaimsForSdJwtCredential(cq, credential)) + .filter(Optional::isPresent) + .map(Optional::get) + .map(SdJwtCredential::getDisclosures) + .flatMap(List::stream) + .collect(Collectors.toSet()); + disclosedCredentials.add(new SdJwtCredential(credential.getRaw(), credential.getJwtCredential(), new ArrayList<>(selectedDisclosures))); + } + return disclosedCredentials; + } + + + private static List evaluateSdJwtForClaimSet(CredentialQuery credentialQuery, List sdJwtCredentials) { + Map claimsQueryMap = new HashMap<>(); + credentialQuery.getClaims() + .forEach(cq -> claimsQueryMap.put(cq.getId(), cq)); + + for (List claimSet : credentialQuery.getClaimSets()) { + List disclosedCredentials = new ArrayList<>(); + for (SdJwtCredential credential : sdJwtCredentials) { + Set disclosures = new HashSet<>(); + for (String claimId : claimSet) { + ClaimsQuery claimsQuery = claimsQueryMap.get(claimId); + disclosures.addAll(new HashSet<>( + ClaimsEvaluator.evaluateClaimsForSdJwtCredential(claimsQuery, credential) + .map(SdJwtCredential::getDisclosures) + .orElse(new ArrayList<>()))); + } + if (!disclosures.isEmpty()) { + disclosedCredentials.add(new SdJwtCredential(credential.getRaw(), credential.getJwtCredential(), new ArrayList<>(disclosures))); + } + } + + if (!disclosedCredentials.isEmpty()) { + return CredentialMapper.toCredentials(credentialQuery.getFormat(), disclosedCredentials); + } + } + return List.of(); + } + +} diff --git a/src/main/java/io/github/wistefan/dcql/TrustedAuthoritiesEvaluator.java b/src/main/java/io/github/wistefan/dcql/TrustedAuthoritiesEvaluator.java new file mode 100644 index 0000000..f904c07 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/TrustedAuthoritiesEvaluator.java @@ -0,0 +1,89 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.TrustedAuthorityQuery; +import io.github.wistefan.dcql.model.credential.JwtCredential; +import io.github.wistefan.dcql.model.credential.MDocCredential; +import io.github.wistefan.dcql.model.credential.SdJwtCredential; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier; + +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +@Slf4j +public class TrustedAuthoritiesEvaluator { + + private static final String AKI_EXTENSION = "2.5.29.35"; + + public static boolean evaluateQueryForMDocCredential(TrustedAuthorityQuery query, MDocCredential credential) { + return switch (query.getType()) { + case AKI -> isInChain(credential.getHeaders().getX5Chain(), query.getValues()); + case ETSI_TL -> isInEtsiTl(credential.getHeaders().getX5Chain(), query.getValues()); + case OPENID_FEDERATION -> isInOpenIdFederation(query.getValues()); + }; + } + + public static boolean evaluateQueryForSDJwtCredential(TrustedAuthorityQuery query, SdJwtCredential credential) { + return evaluateQueryForJwtCredential(query, credential.getJwtCredential()); + } + + public static boolean evaluateQueryForJwtCredential(TrustedAuthorityQuery query, JwtCredential credential) { + return switch (query.getType()) { + case AKI -> isInChain(credential.getX5Chain(), query.getValues()); + case ETSI_TL -> isInEtsiTl(credential.getX5Chain(), query.getValues()); + case OPENID_FEDERATION -> isInOpenIdFederation(query.getValues()); + }; + } + + + // ---- OpenID Federation ---- + private static boolean isInOpenIdFederation(List federationValues) { + throw new UnsupportedOperationException("Querying for OpenId Federation Trust Authorities is not yet supported."); + } + + // ---- ETSI TL ---- + private static boolean isInEtsiTl(List x5chain, List etsiTls) { + throw new UnsupportedOperationException("Querying for etsi-tl is not supported at the moment."); + } + + // ---- AKI ---- + + private static boolean isInChain(List x5chain, List akiValues) { + return x5chain.stream() + .map(TrustedAuthoritiesEvaluator::getAuthorityKeyIdentifier) + .filter(Optional::isPresent) + .map(Optional::get) + .map(byteArray -> Base64.getUrlEncoder().encodeToString(byteArray)) + .anyMatch(akiValues::contains); + } + + private static List decodeAki(List akiValues) { + return akiValues.stream() + .map(v -> Base64.getUrlDecoder().decode(v)) + .toList(); + } + + public static Optional getAuthorityKeyIdentifier(X509Certificate certificate) { + + byte[] extValue = certificate.getExtensionValue(AKI_EXTENSION); + if (extValue == null) { + + return Optional.empty(); + } + ASN1OctetString akiOctet = ASN1OctetString.getInstance(extValue); + ASN1Primitive akiObj = null; + try { + akiObj = ASN1Primitive.fromByteArray(akiOctet.getOctets()); + } catch (IOException e) { + log.debug("Certificate does not contain a valid aki.", e); + return Optional.empty(); + } + AuthorityKeyIdentifier aki = AuthorityKeyIdentifier.getInstance(akiObj); + return Optional.ofNullable(aki.getKeyIdentifier()); + } +} diff --git a/src/main/java/io/github/wistefan/dcql/VcSdJwtCredentialEvaluator.java b/src/main/java/io/github/wistefan/dcql/VcSdJwtCredentialEvaluator.java new file mode 100644 index 0000000..0e956d9 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/VcSdJwtCredentialEvaluator.java @@ -0,0 +1,10 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.CredentialFormat; + +public class VcSdJwtCredentialEvaluator extends SdJwtCredentialEvaluator { + @Override + public CredentialFormat supportedFormat() { + return CredentialFormat.VC_SD_JWT; + } +} diff --git a/src/main/java/io/github/wistefan/dcql/model/ClaimsQuery.java b/src/main/java/io/github/wistefan/dcql/model/ClaimsQuery.java new file mode 100644 index 0000000..4cc7832 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/ClaimsQuery.java @@ -0,0 +1,59 @@ + +package io.github.wistefan.dcql.model; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Pojo containing the structur of a claims-query {@see https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-6.3} + */ +@Data +@NoArgsConstructor +public class ClaimsQuery { + + public ClaimsQuery(String id, List path, List values) { + this.id = id; + this.path = path; + this.values = values; + } + + /** + * REQUIRED if claim_sets is present in the Credential Query; OPTIONAL otherwise. A string identifying the + * particular claim. The value MUST be a non-empty string consisting of alphanumeric, underscore (_), or hyphen (-) + * characters. Within the particular claims array, the same id MUST NOT be present more than once. + */ + private String id; + + /** + * The value MUST be a non-empty array representing a claims path pointer that specifies the path to a claim within + * the Credential. + */ + private List path; + + /** + * A non-empty array of strings, integers or boolean values that specifies the expected values of the claim. If the + * values property is present, the Wallet SHOULD return the claim only if the type and value of the claim both match + * exactly for at least one of the elements in the array. + */ + private List values; + + // ---- MDoc Specific parameters ---- + + /** + * MDoc specific parameter. The flag can be set to inform that the reader wishes to keep(store) the data. In case of + * false, its data is only used to be dispalyed and verified. + */ + private Boolean intent_to_retain; + + /** + * Refers to a namespace inside an mdoc + */ + private String namespace; + + /** + * Identifier for the data-element in the namespace + */ + private String claimName; +} diff --git a/src/main/java/io/github/wistefan/dcql/model/Credential.java b/src/main/java/io/github/wistefan/dcql/model/Credential.java new file mode 100644 index 0000000..ace5663 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/Credential.java @@ -0,0 +1,18 @@ +package io.github.wistefan.dcql.model; + +import io.github.wistefan.dcql.model.credential.CredentialBase; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * General holder of all credentials together with their format + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Credential { + + private CredentialFormat credentialFormat; + private CredentialBase rawCredential; +} diff --git a/src/main/java/io/github/wistefan/dcql/model/CredentialFormat.java b/src/main/java/io/github/wistefan/dcql/model/CredentialFormat.java new file mode 100644 index 0000000..5c05f20 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/CredentialFormat.java @@ -0,0 +1,36 @@ +package io.github.wistefan.dcql.model; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * Format of a concrete Verifiable Credential + */ +public enum CredentialFormat { + + MSO_MDOC("mso_mdoc"), + VC_SD_JWT("vc+sd-jwt"), + DC_SD_JWT("dc+sd-jwt"), + LDP_VC("ldp_vc"), + JWT_VC_JSON("jwt_vc_json"); + + @Getter + private final String value; + + CredentialFormat(String value) { + this.value = value; + } + + public static CredentialFormat fromValue(String value) { + return Arrays.stream(values()) + .filter(eV -> eV.getValue().equals(value)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException(String.format("Unknown value %s.", value))); + } + + public String getValue() { + return value; + } + +} diff --git a/src/main/java/io/github/wistefan/dcql/model/CredentialQuery.java b/src/main/java/io/github/wistefan/dcql/model/CredentialQuery.java new file mode 100644 index 0000000..c7b6887 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/CredentialQuery.java @@ -0,0 +1,66 @@ + +package io.github.wistefan.dcql.model; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * A Credential Query is an object representing a request for a presentation of one or more matching Credentials. + * {@see https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-6.1} + */ +@Data +public class CredentialQuery { + + /** + * A string identifying the Credential in the response and, if provided, the constraints in credential_sets. The + * value MUST be a non-empty string consisting of alphanumeric, underscore (_), or hyphen (-) characters. Within the + * Authorization Request, the same id MUST NOT be present more than once. + */ + private String id; + + /** + * A string that specifies the format of the requested Credential. + */ + private CredentialFormat format; + + /** + * A boolean which indicates whether multiple Credentials can be returned for this Credential Query. If omitted, the + * default value is false. + */ + private Boolean multiple = false; + + /** + * A non-empty array of objects that specifies claims in the requested Credential. Verifiers MUST NOT point to the + * same claim more than once in a single query. Wallets SHOULD ignore such duplicate claim queries. + */ + private List claims; + + /** + * An object defining additional properties requested by the Verifier that apply to the metadata and validity data + * of the Credential. The properties of this object are defined per Credential Format. If empty, no specific + * constraints are placed on the metadata or validity of the requested Credential. + */ + private Map meta; + + /** + * A boolean which indicates whether the Verifier requires a Cryptographic Holder Binding proof. The default value + * is true, i.e., a Verifiable Presentation with Cryptographic Holder Binding is required. If set to false, the + * Verifier accepts a Credential without Cryptographic Holder Binding proof. + */ + private Boolean requireCryptographicHolderBinding; + + /** + * A non-empty array containing arrays of identifiers for elements in claims that specifies which combinations of + * claims for the Credential are requested. + */ + private List> claimSets; + + /** + * A non-empty array of objects that specifies expected authorities or trust frameworks that certify Issuers, that + * the Verifier will accept. Every Credential returned by the Wallet SHOULD match at least one of the conditions + * present in the corresponding trusted_authorities array if present. + */ + private List trustedAuthorities; +} diff --git a/src/main/java/io/github/wistefan/dcql/model/CredentialSetQuery.java b/src/main/java/io/github/wistefan/dcql/model/CredentialSetQuery.java new file mode 100644 index 0000000..b4ddb29 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/CredentialSetQuery.java @@ -0,0 +1,36 @@ + +package io.github.wistefan.dcql.model; + +import lombok.Data; + +import java.util.List; + +/** + * A Credential Set Query is an object representing a request for one or more Credentials to satisfy a particular use + * case with the Verifier. + * {@see https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-6.2} + */ +@Data +public class CredentialSetQuery { + + /** + * A non-empty array, where each value in the array is a list of Credential Query identifiers representing one set + * of Credentials that satisfies the use case. The value of each element in the options array is a non-empty array + * of identifiers which reference elements in credentials. + */ + private List> options; + + /** + * A boolean which indicates whether this set of Credentials is required to satisfy the particular use case at the + * Verifier. + */ + private Boolean required = true; + + /** + * A string, number or object specifying the purpose of the query. This specification does not define a specific + * structure or specific values for this property. The purpose is intended to be used by the Verifier to communicate + * the reason for the query to the Wallet. The Wallet MAY use this information to show the user the reason for the + * request. + */ + private Object purpose; +} \ No newline at end of file diff --git a/src/main/java/io/github/wistefan/dcql/model/DcqlQuery.java b/src/main/java/io/github/wistefan/dcql/model/DcqlQuery.java new file mode 100644 index 0000000..c9980be --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/DcqlQuery.java @@ -0,0 +1,26 @@ + +package io.github.wistefan.dcql.model; + +import lombok.Data; + +import java.util.List; + +/** + * A JSON-encoded query that allows the Verifier to request presentations that match the query. + * {@see https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-digital-credentials-query-l} + */ +@Data +public class DcqlQuery { + + /** + * A non-empty array of Credential Queries that specify the requested Credentials. + */ + private List credentials; + + /** + * A non-empty array of Credential Set Queries that specifies additional constraints on which of the requested + * Credentials to return. + */ + private List credentialSets; + +} diff --git a/src/main/java/io/github/wistefan/dcql/model/JwtMetaData.java b/src/main/java/io/github/wistefan/dcql/model/JwtMetaData.java new file mode 100644 index 0000000..5811f8b --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/JwtMetaData.java @@ -0,0 +1,36 @@ +package io.github.wistefan.dcql.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.*; + +/** + * Holder of metadata-queries for the JWT formats(sd-jwt and jwt) + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class JwtMetaData { + private static final String VCT_VALUES_KEY = "vct_values"; + + private Set vctValues; + + /** + * Extract the supported metadata information for jwt credentials. + */ + public static JwtMetaData fromMeta(Map metaData) { + if (metaData.containsKey(VCT_VALUES_KEY) && metaData.get(VCT_VALUES_KEY) instanceof List vctValues) { + List vctStrings = vctValues.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + if (vctValues.size() != vctStrings.size()) { + throw new IllegalArgumentException(String.format("The vct_values %s contain invalid values.", vctValues)); + } + return new JwtMetaData(new HashSet<>(vctStrings)); + } + throw new IllegalArgumentException(String.format("Given metaData %s is not sdJwt-metadata.", metaData)); + } +} \ No newline at end of file diff --git a/src/main/java/io/github/wistefan/dcql/model/MDocMetaData.java b/src/main/java/io/github/wistefan/dcql/model/MDocMetaData.java new file mode 100644 index 0000000..438fce3 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/MDocMetaData.java @@ -0,0 +1,30 @@ +package io.github.wistefan.dcql.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Holder of metadata-queries for the MDoc format + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class MDocMetaData { + + private static final String DOCTYPE_KEY = "doctype_value"; + + private String docType; + + /** + * Extract the supported metadata(e.g. doctype_value) information for MDoc credentials. + */ + public static MDocMetaData fromMeta(Map metaData) { + if (metaData.containsKey(DOCTYPE_KEY) && metaData.get(DOCTYPE_KEY) instanceof String docType) { + return new MDocMetaData(docType); + } + throw new IllegalArgumentException(String.format("Given metaData %s is not mDoc-metadata.", metaData)); + } +} diff --git a/src/main/java/io/github/wistefan/dcql/model/TrustedAuthorityQuery.java b/src/main/java/io/github/wistefan/dcql/model/TrustedAuthorityQuery.java new file mode 100644 index 0000000..ff62736 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/TrustedAuthorityQuery.java @@ -0,0 +1,33 @@ + +package io.github.wistefan.dcql.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * An object representing information that helps to identify an authority or the trust framework that certifies Issuers. + * A Credential is identified as a match to a Trusted Authorities Query if it matches with one of the provided values in + * one of the provided types. + * {@see https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-6.1.1} + */ +@AllArgsConstructor +@NoArgsConstructor +@Data +public class TrustedAuthorityQuery { + + /** + * A string uniquely identifying the type of information about the issuer trust framework. Types defined by this + * specification are listed below. + */ + private TrustedAuthorityType type; + + /** + * A non-empty array of strings, where each string (value) contains information specific to the used Trusted + * Authorities Query type that allows the identification of an issuer, a trust framework, or a federation that an + * issuer belongs to. + */ + private List values; +} \ No newline at end of file diff --git a/src/main/java/io/github/wistefan/dcql/model/TrustedAuthorityType.java b/src/main/java/io/github/wistefan/dcql/model/TrustedAuthorityType.java new file mode 100644 index 0000000..b38dfdc --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/TrustedAuthorityType.java @@ -0,0 +1,33 @@ +package io.github.wistefan.dcql.model; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * Type of trusted authorities to be used in queries{@see https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-6.1.1} + */ +public enum TrustedAuthorityType { + + AKI("aki"), + ETSI_TL("etsi_tl"), + OPENID_FEDERATION("openid_federation"); + + @Getter + private final String value; + + TrustedAuthorityType(String value) { + this.value = value; + } + + public static TrustedAuthorityType fromValue(String value) { + return Arrays.stream(values()) + .filter(eV -> eV.getValue().equals(value)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException(String.format("Unknown value %s.", value))); + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/io/github/wistefan/dcql/model/W3CMetaData.java b/src/main/java/io/github/wistefan/dcql/model/W3CMetaData.java new file mode 100644 index 0000000..1b3877c --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/W3CMetaData.java @@ -0,0 +1,50 @@ +package io.github.wistefan.dcql.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * Holder of metadata-queries for the W3C credential formats(ldp, jwt, sd-jwt) + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class W3CMetaData { + + private static final String TYPE_VALUES_KEY = "type_values"; + + private List> typeValues; + + /** + * Extract the supported metadata(e.g. type_values) information for W3C credentials. + */ + public static W3CMetaData fromMeta(Map metaData) { + if (metaData.containsKey(TYPE_VALUES_KEY) && metaData.get(TYPE_VALUES_KEY) instanceof List typeValues) { + + List> typeValuesStrings = typeValues.stream() + .filter(List.class::isInstance) + .map(l -> mapToStringList((List) l)) + .toList(); + if (typeValuesStrings.size() != typeValues.size()) { + throw new IllegalArgumentException(String.format("The type_values %s contain invalid values.", typeValues)); + } + return new W3CMetaData(typeValues); + } + throw new IllegalArgumentException(String.format("Given metaData %s is not w3c-metadata.", metaData)); + } + + private static List mapToStringList(List listToMap) { + List stringList = listToMap.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + if (stringList.size() != listToMap.size()) { + throw new IllegalArgumentException("Not all list entries are strings"); + } + return stringList; + } +} diff --git a/src/main/java/io/github/wistefan/dcql/model/credential/CredentialBase.java b/src/main/java/io/github/wistefan/dcql/model/credential/CredentialBase.java new file mode 100644 index 0000000..9dd75aa --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/credential/CredentialBase.java @@ -0,0 +1,14 @@ +package io.github.wistefan.dcql.model.credential; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Base class for all credentials. Provides access to the raw credential, without any deserialization applied. + */ +@Getter +@RequiredArgsConstructor +public abstract class CredentialBase { + + protected final String raw; +} diff --git a/src/main/java/io/github/wistefan/dcql/model/credential/Disclosure.java b/src/main/java/io/github/wistefan/dcql/model/credential/Disclosure.java new file mode 100644 index 0000000..a67b6ad --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/credential/Disclosure.java @@ -0,0 +1,54 @@ +package io.github.wistefan.dcql.model.credential; + +import io.github.wistefan.dcql.EvaluationException; +import lombok.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * Disclosure object to provide values for an SD-JWT. + */ +@Data +@EqualsAndHashCode +public class Disclosure { + private String salt; + private String claim; + private Object value; + // the plain, encoded disclosure as it was provided in the original credential + private final String encodedDisclosure; + // the sd_hash of the disclosure, correlating with an _sd entry of the credential + private final String sdHash; + + public Disclosure(String salt, String claim, Object value, String encodedDisclosure, String sdAlgorithm) { + this.salt = salt; + this.claim = claim; + this.value = value; + this.encodedDisclosure = encodedDisclosure; + this.sdHash = generateSdHash(sdAlgorithm); + } + + /** + * Generate the hash of the disclosure, based on the algorithm(configured in the credential) + */ + private String generateSdHash(String sdAlgorithm) { + byte[] disclosureBytes = encodedDisclosure.getBytes(StandardCharsets.UTF_8); + MessageDigest digest = getMessageDigest(sdAlgorithm); + + byte[] hash = digest.digest(disclosureBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } + + private MessageDigest getMessageDigest(String sdAlgorithm) { + if (sdAlgorithm.equalsIgnoreCase("SHA-256")) { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new EvaluationException(String.format("SD-Algorithm %s is not supported.", sdAlgorithm), e); + } + } + throw new EvaluationException(String.format("SD-Algorithm %s is not supported.", sdAlgorithm)); + } +} diff --git a/src/main/java/io/github/wistefan/dcql/model/credential/JwtCredential.java b/src/main/java/io/github/wistefan/dcql/model/credential/JwtCredential.java new file mode 100644 index 0000000..e1d4ea1 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/credential/JwtCredential.java @@ -0,0 +1,89 @@ +package io.github.wistefan.dcql.model.credential; + +import lombok.Getter; + +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; + +/** + * Holder of JwtCredentials, providing access to its deserialized contents. + */ +@Getter +public class JwtCredential extends CredentialBase { + + private static final String VC_PAYLOAD_KEY = "vc"; + private static final String VCT_KEY = "vct"; + private static final String TYPE_KEY = "type"; + private static final String X5C_KEY = "x5c"; + + private final Map headers; + private final Map payload; + private final String signature; + + public JwtCredential(String raw, Map headers, Map payload, String signature) { + super(raw); + this.headers = headers; + this.payload = payload; + this.signature = signature; + } + + /** + * Returns the certificates from the "x5c" header from the credential, if the header exists + */ + public List getX5Chain() { + if (headers.containsKey(X5C_KEY) && headers.get(X5C_KEY) instanceof List x5Chain) { + List x509Certificates = x5Chain.stream() + .filter(X509Certificate.class::isInstance) + .map(X509Certificate.class::cast) + .toList(); + if (x5Chain.size() != x509Certificates.size()) { + throw new IllegalArgumentException("The x5c header contains invalid values."); + } + return x509Certificates; + } + // a x5c-header is not mandatory, thus an empty list is completely valid. + return List.of(); + } + + /** + * Returns the concrete "vc" entry from the payload. + */ + public Map getPayload() { + if (payload.containsKey(VC_PAYLOAD_KEY)) { + return (Map) payload.get(VC_PAYLOAD_KEY); + } + return payload; + } + + /** + * Returns contents of the "type" field from the credential + */ + public List getType() { + if (getPayload().containsKey(TYPE_KEY)) { + if (getPayload().get(TYPE_KEY) instanceof String typeString) { + return List.of(typeString); + } else if (getPayload().get(TYPE_KEY) instanceof List typeList) { + List typeStrings = typeList.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + if (typeStrings.size() == typeList.size()) { + return typeStrings; + } + } + } + throw new IllegalArgumentException("The type field contains invalid entries."); + } + + /** + * Returns contents of the "vct" field of the credential. + */ + public String getVct() { + if (getPayload().containsKey(VCT_KEY) && getPayload().get(VCT_KEY) instanceof String vctValue) { + return vctValue; + } + throw new IllegalArgumentException("Invalid credential. Does not contain a valid vct."); + } + +} diff --git a/src/main/java/io/github/wistefan/dcql/model/credential/LdpCredential.java b/src/main/java/io/github/wistefan/dcql/model/credential/LdpCredential.java new file mode 100644 index 0000000..35f66ea --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/credential/LdpCredential.java @@ -0,0 +1,43 @@ +package io.github.wistefan.dcql.model.credential; + +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +/** + * Holder of LdpCredentials, providing access to its deserialized contents. + */ +@Getter +public class LdpCredential extends CredentialBase { + + private static final String TYPE_KEY = "type"; + + private final Map theCredential; + + public LdpCredential(String raw, Map theCredential) { + super(raw); + this.theCredential = theCredential; + } + + /** + * Returns contents of the "type" field from the credential + */ + public List getType() { + if (theCredential.containsKey(TYPE_KEY)) { + if (theCredential.get(TYPE_KEY) instanceof String typeString) { + return List.of(typeString); + } else if (theCredential.get(TYPE_KEY) instanceof List typeList) { + List typeStrings = typeList.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + if (typeStrings.size() == typeList.size()) { + return typeStrings; + } + } + } + throw new IllegalArgumentException("The type field contains invalid entries."); + } + +} diff --git a/src/main/java/io/github/wistefan/dcql/model/credential/MDocCredential.java b/src/main/java/io/github/wistefan/dcql/model/credential/MDocCredential.java new file mode 100644 index 0000000..a7a927e --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/credential/MDocCredential.java @@ -0,0 +1,33 @@ +package io.github.wistefan.dcql.model.credential; + +import lombok.Getter; + +import java.util.Map; + +/** + * Holder of MDocCredentials, providing access to its deserialized contents. + */ +@Getter +public class MDocCredential extends CredentialBase { + + private static final String DOC_TYPE_KEY = "docType"; + + private MDocHeaders headers; + private Map payload; + + public MDocCredential(String raw, MDocHeaders headers, Map payload) { + super(raw); + this.headers = headers; + this.payload = payload; + } + + /** + * Returns contents of the "docType" field from the credential + */ + public String getDocType() { + if (payload.containsKey(DOC_TYPE_KEY) && payload.get(DOC_TYPE_KEY) instanceof String docType) { + return docType; + } + throw new IllegalArgumentException("The credential does not contain a valid docType."); + } +} diff --git a/src/main/java/io/github/wistefan/dcql/model/credential/MDocHeaders.java b/src/main/java/io/github/wistefan/dcql/model/credential/MDocHeaders.java new file mode 100644 index 0000000..9aa2565 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/credential/MDocHeaders.java @@ -0,0 +1,20 @@ +package io.github.wistefan.dcql.model.credential; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.security.cert.X509Certificate; +import java.util.List; + +/** + * Holds MDoc-Specific headers and provides access to them + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MDocHeaders { + + private String alg; + private List x5Chain; +} diff --git a/src/main/java/io/github/wistefan/dcql/model/credential/SdJwtCredential.java b/src/main/java/io/github/wistefan/dcql/model/credential/SdJwtCredential.java new file mode 100644 index 0000000..af9cd89 --- /dev/null +++ b/src/main/java/io/github/wistefan/dcql/model/credential/SdJwtCredential.java @@ -0,0 +1,59 @@ +package io.github.wistefan.dcql.model.credential; + +import lombok.Getter; + +import java.util.List; +import java.util.StringJoiner; + +/** + * Holder of SdJwtCredential, providing access to its deserialized contents. + */ +@Getter +public class SdJwtCredential extends CredentialBase { + + private static final String SD_JWT_SEPARATOR = "~"; + + /** + * The "standard"-jwt contents of the credential + */ + private JwtCredential jwtCredential; + /** + * The disclosures to provide access to the contents + */ + private List disclosures; + + public SdJwtCredential(String raw, JwtCredential jwtCredential, List disclosures) { + super(raw); + this.jwtCredential = jwtCredential; + this.disclosures = disclosures; + } + + /** + * For SD-JWT Credentials we cannot return the full raw-credential, since we might disclose claims that are not requested. + * Instead, the "raw" needs to be rebuilt from the jwt-part and the selected disclosures. + */ + @Override + public String getRaw() { + if (raw == null) { + return null; + } + String[] splittedRaw = super.getRaw().split(SD_JWT_SEPARATOR); + StringJoiner sdJoiner = new StringJoiner(SD_JWT_SEPARATOR); + // first element is the plain jwt. + sdJoiner.add(splittedRaw[0]); + disclosures.stream() + .map(Disclosure::getEncodedDisclosure) + .forEach(sdJoiner::add); + // the sd needs to end with an ~ + return sdJoiner + SD_JWT_SEPARATOR; + } + + public String getVct() { + return jwtCredential.getVct(); + } + + public List getType() { + return jwtCredential.getType(); + } + +} diff --git a/src/test/java/io/github/wistefan/dcql/ClaimsEvaluatorTest.java b/src/test/java/io/github/wistefan/dcql/ClaimsEvaluatorTest.java new file mode 100644 index 0000000..9fc42b0 --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/ClaimsEvaluatorTest.java @@ -0,0 +1,102 @@ +package io.github.wistefan.dcql; + +import io.github.wistefan.dcql.model.ClaimsQuery; +import io.github.wistefan.dcql.model.credential.Disclosure; +import io.github.wistefan.dcql.model.credential.JwtCredential; +import io.github.wistefan.dcql.model.credential.SdJwtCredential; +import io.github.wistefan.dcql.query.DcqlTest; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ClaimsEvaluatorTest { + + @ParameterizedTest + @MethodSource("jwtArgs") + public void testEvaluateForJwtCredential(ClaimsQuery claimsQuery, Map credential, boolean expectedResult) { + JwtCredential jwtCredential = new JwtCredential( null, null, credential, null); + assertEquals(expectedResult, ClaimsEvaluator.evaluateClaimsForJwtCredential(claimsQuery, jwtCredential).isPresent()); + } + + @ParameterizedTest + @MethodSource("sdJwtArgs") + public void testEvaluateForJwtCredential(ClaimsQuery claimsQuery, Map payload, List disclosures, Optional> expectedDisclosures) { + + SdJwtCredential sdJwtCredential = new SdJwtCredential( null, new JwtCredential( null, null, payload, null), disclosures); + Optional optionalSdJwtCredential = ClaimsEvaluator.evaluateClaimsForSdJwtCredential(claimsQuery, sdJwtCredential); + + assertEquals(expectedDisclosures.isPresent(), optionalSdJwtCredential.isPresent()); + expectedDisclosures.ifPresent(disclosureList -> assertEquals(disclosureList, optionalSdJwtCredential.get().getDisclosures())); + } + + public static Stream sdJwtArgs() { + return Stream.of( + + Arguments.of( + new ClaimsQuery("id", List.of("test", "a"), null), + Map.of("test", Map.of("_sd", + List.of(DcqlTest.getDisclosure("hash-a", "a", "b").getSdHash(), + DcqlTest.getDisclosure("hash-b", "c", "d").getSdHash(), "decoy"))), + List.of(DcqlTest.getDisclosure("hash-a", "a", "b"), DcqlTest.getDisclosure("hash-b", "c", "d")), + Optional.of(List.of(DcqlTest.getDisclosure("hash-a", "a", "b")))), + Arguments.of( + new ClaimsQuery("id", List.of("test", "a"), null), + Map.of("test", Map.of("_sd", List.of(DcqlTest.getDisclosure("hash-a", "a", "b").getSdHash(), + DcqlTest.getDisclosure("hash-b", "c", "d").getSdHash()))), + List.of(DcqlTest.getDisclosure("hash-a", "a", "b"), DcqlTest.getDisclosure("hash-b", "c", "d")), + Optional.of(List.of(DcqlTest.getDisclosure("hash-a", "a", "b")))), + Arguments.of( + new ClaimsQuery("id", List.of("test", "a"), List.of("b")), + Map.of("test", Map.of("_sd", List.of(DcqlTest.getDisclosure("hash-a", "a", "b").getSdHash(), + DcqlTest.getDisclosure("hash-b", "c", "d").getSdHash()))), + List.of(DcqlTest.getDisclosure("hash-a", "a", "b"), DcqlTest.getDisclosure("hash-b", "c", "d")), + Optional.of(List.of(DcqlTest.getDisclosure("hash-a", "a", "b")))), + Arguments.of( + new ClaimsQuery("id", List.of("test", "a"), List.of("c")), + Map.of("test", Map.of("_sd", List.of(DcqlTest.getDisclosure("hash-a", "a", "b").getSdHash(), + DcqlTest.getDisclosure("hash-b", "c", "d").getSdHash()))), + List.of(DcqlTest.getDisclosure("hash-a", "a", "b"), DcqlTest.getDisclosure("hash-b", "c", "d")), + Optional.empty()), + Arguments.of( + new ClaimsQuery("id", List.of("a"), List.of("b")), + Map.of("_sd", List.of(DcqlTest.getDisclosure("hash-a", "a", "b").getSdHash(), + DcqlTest.getDisclosure("hash-b", "c", "d").getSdHash())), + List.of(DcqlTest.getDisclosure("hash-a", "a", "b"), DcqlTest.getDisclosure("hash-b", "c", "d")), + Optional.of(List.of(DcqlTest.getDisclosure("hash-a", "a", "b")))), + Arguments.of( + new ClaimsQuery("id", List.of("a"), null), + Map.of("_sd", List.of(DcqlTest.getDisclosure("hash-a", "a", "b").getSdHash(), + DcqlTest.getDisclosure("hash-b", "c", "d").getSdHash())), + List.of(DcqlTest.getDisclosure("hash-a", "a", "b"), DcqlTest.getDisclosure("hash-b", "c", "d")), + Optional.of(List.of(DcqlTest.getDisclosure("hash-a", "a", "b")))), + Arguments.of( + new ClaimsQuery("id", List.of("test", "e"), List.of("f")), + Map.of("_sd", List.of(DcqlTest.getDisclosure("hash-a", "a", "b").getSdHash(), DcqlTest.getDisclosure("hash-b", "c", "d").getSdHash()), + "test", Map.of("_sd", List.of(DcqlTest.getDisclosure("hash-c", "e", "f").getSdHash()))), + List.of(DcqlTest.getDisclosure("hash-a", "a", "b"), DcqlTest.getDisclosure("hash-b", "c", "d"), DcqlTest.getDisclosure("hash-c", "e", "f")), + Optional.of(List.of(DcqlTest.getDisclosure("hash-c", "e", "f")))) + ); + } + + public static Stream jwtArgs() { + List nullList = new ArrayList<>(); + nullList.add("test"); + nullList.add(null); + nullList.add("a"); + return Stream.of( + Arguments.of(new ClaimsQuery("id", nullList, null), Map.of("test", List.of(Map.of("a", "b"), Map.of("a", "d"))), true), + Arguments.of(new ClaimsQuery("id", nullList, List.of("c")), Map.of("test", List.of(Map.of("a", "b"), Map.of("a", "d"))), false), + Arguments.of(new ClaimsQuery("id", List.of("test", "a"), null), Map.of("test", Map.of("a", "b", "c", "d")), true), + Arguments.of(new ClaimsQuery("id", List.of("test", "d"), null), Map.of("test", Map.of("a", "b", "c", "d")), false), + Arguments.of(new ClaimsQuery("id", List.of("test", "a"), List.of("b")), Map.of("test", Map.of("a", "b", "c", "d")), true) + ); + } +} \ No newline at end of file diff --git a/src/test/java/io/github/wistefan/dcql/example/ParseCredentialTest.java b/src/test/java/io/github/wistefan/dcql/example/ParseCredentialTest.java new file mode 100644 index 0000000..be9ca47 --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/example/ParseCredentialTest.java @@ -0,0 +1,94 @@ +package io.github.wistefan.dcql.example; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jwt.SignedJWT; +import io.github.wistefan.dcql.model.Credential; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.credential.Disclosure; +import io.github.wistefan.dcql.model.credential.JwtCredential; +import io.github.wistefan.dcql.model.credential.SdJwtCredential; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +public class ParseCredentialTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + public void readJwtCredential() throws Exception { + String jwtPath = "example/userCredential.jwt"; + String rawContent = loadFromFile(jwtPath); + SignedJWT signedJWT = SignedJWT.parse(rawContent); + assertDoesNotThrow(() -> { + JwtCredential jwtCredential = new JwtCredential(rawContent, signedJWT.getHeader().toJSONObject(), signedJWT.getJWTClaimsSet().toJSONObject(), signedJWT.getSignature().decodeToString()); + new Credential(CredentialFormat.JWT_VC_JSON, jwtCredential); + }); + } + + @Test + public void readSdJwtCredential() throws Exception { + String sdJwtPath = "example/legalPerson.sd_jwt"; + String rawContent = loadFromFile(sdJwtPath); + + assertDoesNotThrow(() -> { + // split by disclosure separator + String[] sdParts = rawContent.split("~"); + // parse the plain JWT + SignedJWT signedJWT = SignedJWT.parse(sdParts[0]); + Object algorithmClaim = signedJWT.getJWTClaimsSet().getClaim("_sd_alg"); + + // decode the disclosures + List disclosures = Arrays.asList(sdParts) + // everything after the first element + .subList(1, sdParts.length) + .stream() + .map(disclosure -> { + try { + return toDisclosure(disclosure, algorithmClaim); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList(); + SdJwtCredential sdJwtCredential = new SdJwtCredential(rawContent, + new JwtCredential(rawContent, signedJWT.getHeader().toJSONObject(), signedJWT.getJWTClaimsSet().toJSONObject(), signedJWT.getSignature().decodeToString()), + disclosures); + }); + } + + private static String loadFromFile(String path) throws IOException { + try (InputStream is = ParseCredentialTest.class.getClassLoader().getResourceAsStream(path)) { + if (is == null) { + throw new IllegalArgumentException("Resource not found: " + path); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + + // decode the encoded disclosure + private Disclosure toDisclosure(String encoded, Object sdAlgorithm) throws IOException { + byte[] sdBytes = Base64.getUrlDecoder().decode(encoded); + List sdContents = OBJECT_MAPPER.readValue(sdBytes, List.class); + String salt = null; + String claim = null; + if (sdContents.get(0) instanceof String saltElement) { + salt = saltElement; + } + if (sdContents.get(1) instanceof String claimElement) { + claim = claimElement; + } + if (sdAlgorithm instanceof String sdAlgorithmString) { + return new Disclosure(salt, claim, sdContents.get(2), encoded, sdAlgorithmString); + } + throw new IllegalArgumentException("Was not able to create disclosure."); + } +} diff --git a/src/test/java/io/github/wistefan/dcql/helper/CredentialFormatDeserializer.java b/src/test/java/io/github/wistefan/dcql/helper/CredentialFormatDeserializer.java new file mode 100644 index 0000000..3fe7577 --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/helper/CredentialFormatDeserializer.java @@ -0,0 +1,22 @@ +package io.github.wistefan.dcql.helper; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import io.github.wistefan.dcql.model.CredentialFormat; + +import java.io.IOException; + +public class CredentialFormatDeserializer extends StdDeserializer { + + public CredentialFormatDeserializer() { + super(CredentialFormat.class); + } + + @Override + public CredentialFormat deserialize(JsonParser jsonParser, DeserializationContext context) + throws IOException { + String value = jsonParser.getText(); + return CredentialFormat.fromValue(value); + } +} \ No newline at end of file diff --git a/src/test/java/io/github/wistefan/dcql/helper/TrustedAuthorityTypeDeserializer.java b/src/test/java/io/github/wistefan/dcql/helper/TrustedAuthorityTypeDeserializer.java new file mode 100644 index 0000000..36e8f8c --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/helper/TrustedAuthorityTypeDeserializer.java @@ -0,0 +1,22 @@ +package io.github.wistefan.dcql.helper; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import io.github.wistefan.dcql.model.TrustedAuthorityType; + +import java.io.IOException; + +public class TrustedAuthorityTypeDeserializer extends StdDeserializer { + + public TrustedAuthorityTypeDeserializer() { + super(TrustedAuthorityType.class); + } + + @Override + public TrustedAuthorityType deserialize(JsonParser jsonParser, DeserializationContext context) + throws IOException { + String value = jsonParser.getText(); + return TrustedAuthorityType.fromValue(value); + } +} \ No newline at end of file diff --git a/src/test/java/io/github/wistefan/dcql/query/DcqlClaimSetQueryTest.java b/src/test/java/io/github/wistefan/dcql/query/DcqlClaimSetQueryTest.java new file mode 100644 index 0000000..81bd4fc --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/query/DcqlClaimSetQueryTest.java @@ -0,0 +1,302 @@ +package io.github.wistefan.dcql.query; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.github.wistefan.dcql.*; +import io.github.wistefan.dcql.model.Credential; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.DcqlQuery; +import io.github.wistefan.dcql.model.credential.JwtCredential; +import io.github.wistefan.dcql.model.credential.MDocCredential; +import io.github.wistefan.dcql.model.credential.MDocHeaders; +import io.github.wistefan.dcql.model.credential.SdJwtCredential; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class DcqlClaimSetQueryTest extends DcqlTest { + + + private static final String MDOC_MVRC_QUERY = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "mso_mdoc", + "multiple": true, + "meta": { "doctype_value": "org.iso.7367.1.mVRC" }, + "claims": [ + { "id": "a", "namespace": "org.iso.7367.1", "claim_name": "vehicle_holder" }, + { "id": "b", "namespace": "org.iso.18013.5.1", "claim_name": "first_name" }, + { "id": "c", "namespace": "org.iso.18013.5.1", "claim_name": "first_name" } + ], + "claim_sets": [ + ["b","c"], + ["a"] + ], + "require_cryptographic_holder_binding": false + } + ] + } + """; + + private static final String MDOC_MVRC_QUERY_SINGLE = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "mso_mdoc", + "multiple": false, + "meta": { "doctype_value": "org.iso.7367.1.mVRC" }, + "claims": [ + { "id": "a", "namespace": "org.iso.7367.1", "claim_name": "vehicle_holder" }, + { "id": "b", "namespace": "org.iso.18013.5.1", "claim_name": "first_name" }, + { "id": "c", "namespace": "org.iso.18013.5.1", "claim_name": "first_name" } + ], + "claim_sets": [ + ["b","c"], + ["a"] + ], + "require_cryptographic_holder_binding": false + } + ] + } + """; + + private static final Credential MDOC_MVRC_FULL = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential(null, new MDocHeaders(null, List.of(generateTestCertificate(TEST_KEY))), Map.of( + "docType", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.7367.1", Map.of("vehicle_holder", "Martin Auer"), + "org.iso.18013.5.1", Map.of("first_name", "Martin", "last_name", "Auer") + ), + "authority", Map.of("type", "aki", "values", List.of("one")), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_MVRC_HOLDER = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential(null, new MDocHeaders(null, List.of(generateTestCertificate(TEST_KEY))), Map.of( + "docType", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.7367.1", Map.of("vehicle_holder", "Martin Auer") + ), + "authority", Map.of("type", "aki", "values", List.of("one")), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_MVRC_NAME = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential(null, new MDocHeaders(null, List.of(generateTestCertificate(TEST_KEY))), Map.of( + "docType", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.18013.5.1", Map.of("first_name", "Martin", "last_name", "Auer") + ), + "authority", Map.of("type", "aki", "values", List.of("one")), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_MVRC_LAST_NAME = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential(null, new MDocHeaders(null, List.of(generateTestCertificate(TEST_KEY))), Map.of( + "docType", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.18013.5.1", Map.of("last_name", "Auer") + ), + "authority", Map.of("type", "aki", "values", List.of("one")), + "cryptographic_holder_binding", true + ))); + + + private static final String SD_JWT_QUERY_ADDRESS = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "vc+sd-jwt", + "meta": { "vct_values": ["https://credentials.example.com/identity_credential", "https://credentials.example.com/address_credential"] }, + "claims": [ + { "id": "a", "path": ["address","street_address"] }, + { "id": "b", "path": ["street_address"] } + ], + "claim_sets": [ + ["b"], + ["a"] + ], + "require_cryptographic_holder_binding": false + } + ] + } + """; + + private static final String SD_JWT_QUERY_ALTERNATIVES = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "vc+sd-jwt", + "meta": { "vct_values": ["https://credentials.example.com/identity_credential", "https://credentials.example.com/address_credential","https://credentials.example.com/name_credential"] }, + "claims": [ + { "id": "a", "path": ["address","street_address"] }, + { "id": "b", "path": ["street_address"] }, + { "id": "c", "path": ["first_name"] }, + { "id": "d", "path": ["last_name"] } + ], + "claim_sets": [ + ["c","d"], + ["b"], + ["a"] + ], + "require_cryptographic_holder_binding": false + } + ] + } + """; + private static final Credential SD_JWT_VC_FULL = new Credential(CredentialFormat.VC_SD_JWT, + new SdJwtCredential(null, + new JwtCredential(null, null, + Map.of( + "vct", "https://credentials.example.com/identity_credential", + "name", Map.of("_sd", List.of(getDisclosure("salt-b", "first_name", "Arthur").getSdHash(), getDisclosure("salt-c", "last_name", "Dent").getSdHash())), + "address", Map.of("_sd", List.of(getDisclosure("salt-a", "street_address", "42 Market Street").getSdHash(), "hash-x")), + "cryptographic_holder_binding", false), null), + List.of(getDisclosure("salt-a", "street_address", "42 Market Street"), + getDisclosure("salt-b", "first_name", "Arthur"), + getDisclosure("salt-c", "last_name", "Dent")) + )); + + private static final Credential SD_JWT_VC_ADDRESS = new Credential(CredentialFormat.VC_SD_JWT, + new SdJwtCredential(null, + new JwtCredential(null, null, + Map.of( + "vct", "https://credentials.example.com/address_credential", + "_sd", List.of( + getDisclosure("salt-a", "street_address", "42 Market Street") + .getSdHash(), + "hash-x"), + "cryptographic_holder_binding", false), null), + List.of(getDisclosure("salt-a", "street_address", "42 Market Street")) + )); + + private static final Credential SD_JWT_VC_NAME = new Credential(CredentialFormat.VC_SD_JWT, + new SdJwtCredential(null, + new JwtCredential(null, null, + Map.of( + "vct", "https://credentials.example.com/name_credential", + "_sd", List.of( + getDisclosure("salt-b", "first_name", "Arthur").getSdHash(), + getDisclosure("salt-c", "last_name", "Dent").getSdHash()), + "cryptographic_holder_binding", false), null), + List.of(getDisclosure("salt-b", "first_name", "Arthur"), + getDisclosure("salt-c", "last_name", "Dent")) + )); + + + @Test + @DisplayName("sd-jwt query get alternative") + void sdJwtQueryGetAlternative() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(SD_JWT_QUERY_ALTERNATIVES, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(SD_JWT_VC_ADDRESS, SD_JWT_VC_FULL)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential credential = queryResult.credentials().get("credentials").get(0); + if (credential.getRawCredential() instanceof SdJwtCredential sdJwtCredential) { + assertEquals(1, sdJwtCredential.getDisclosures().size()); + } else { + fail("Did not get an SdJwt Credential."); + } + } + + + @Test + @DisplayName("sd-jwt query get for name") + void sdJwtQueryForName() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(SD_JWT_QUERY_ALTERNATIVES, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(SD_JWT_VC_NAME, SD_JWT_VC_ADDRESS, SD_JWT_VC_FULL)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential credential = queryResult.credentials().get("credentials").get(0); + if (credential.getRawCredential() instanceof SdJwtCredential sdJwtCredential) { + assertEquals(2, sdJwtCredential.getDisclosures().size()); + } else { + fail("Did not get an SdJwt Credential."); + } + } + + @Test + @DisplayName("sd-jwt query get for street_address within full") + void sdJwtQueryForStreetAddressInFull() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(SD_JWT_QUERY_ADDRESS, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(SD_JWT_VC_NAME, SD_JWT_VC_FULL)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential credential = queryResult.credentials().get("credentials").get(0); + if (credential.getRawCredential() instanceof SdJwtCredential sdJwtCredential) { + assertEquals(1, sdJwtCredential.getDisclosures().size()); + } else { + fail("Did not get an SdJwt Credential."); + } + } + + @Test + @DisplayName("sd-jwt query get for street_address") + void sdJwtQueryForStreetAddress() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(SD_JWT_QUERY_ADDRESS, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(SD_JWT_VC_ADDRESS, SD_JWT_VC_NAME, SD_JWT_VC_FULL)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential credential = queryResult.credentials().get("credentials").get(0); + + if (credential.getRawCredential() instanceof SdJwtCredential sdJwtCredential) { + assertEquals(1, sdJwtCredential.getDisclosures().size()); + } else { + fail("Did not get an SdJwt Credential."); + } + } + + @Test + @DisplayName("mdoc mvrc query get full doc") + void mdocMvrcQueryFullDocSet() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC_FULL, MDOC_MVRC_HOLDER)); + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential credential = queryResult.credentials().get("credentials").get(0); + assertEquals(credential, MDOC_MVRC_FULL); + } + + @Test + @DisplayName("mdoc mvrc query get second set") + void mdocMvrcQuerySecondSet() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC_LAST_NAME, MDOC_MVRC_HOLDER)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential credential = queryResult.credentials().get("credentials").get(0); + assertEquals(credential, MDOC_MVRC_HOLDER); + } + + @Test + @DisplayName("mdoc mvrc query gets the fullfilling credentials.") + void mdocMvrcQueryOnlyOne() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC_LAST_NAME, MDOC_MVRC_HOLDER, MDOC_MVRC_NAME, MDOC_MVRC_FULL)); + + assertTrue(queryResult.success()); + assertEquals(2, queryResult.credentials().get("credentials").size()); + List credentials = queryResult.credentials().get("credentials"); + assertTrue(credentials.contains(MDOC_MVRC_NAME)); + assertTrue(credentials.contains(MDOC_MVRC_FULL)); + } + + @Test + @DisplayName("mdoc mvrc query fails when multiple credentials match, but multiple is not allowed.") + void mdocMvrcQueryFailedMultiple() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY_SINGLE, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC_LAST_NAME, MDOC_MVRC_HOLDER, MDOC_MVRC_NAME, MDOC_MVRC_FULL)); + + assertFalse(queryResult.success()); + } +} diff --git a/src/test/java/io/github/wistefan/dcql/query/DcqlQueryComplexTest.java b/src/test/java/io/github/wistefan/dcql/query/DcqlQueryComplexTest.java new file mode 100644 index 0000000..b5c0320 --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/query/DcqlQueryComplexTest.java @@ -0,0 +1,207 @@ + +package io.github.wistefan.dcql.query; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.github.wistefan.dcql.QueryResult; +import io.github.wistefan.dcql.model.Credential; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.DcqlQuery; +import io.github.wistefan.dcql.model.credential.JwtCredential; +import io.github.wistefan.dcql.model.credential.MDocCredential; +import io.github.wistefan.dcql.model.credential.SdJwtCredential; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class DcqlQueryComplexTest extends DcqlTest { + + // --- Test Data --- + + private static final String COMPLEX_MDOC_QUERY = """ + { + "credentials": [ + { + "id": "mdl-id", + "format": "mso_mdoc", + "meta": { "doctype_value": "org.iso.18013.5.1.mDL" }, + "claims": [ + { "id": "given_name", "namespace": "org.iso.18013.5.1", "claim_name": "given_name" }, + { "id": "family_name", "namespace": "org.iso.18013.5.1", "claim_name": "family_name" }, + { "id": "portrait", "namespace": "org.iso.18013.5.1", "claim_name": "portrait" } + ] + }, + { + "id": "mdl-address", + "format": "mso_mdoc", + "meta": { "doctype_value": "org.iso.18013.5.1.mDL" }, + "claims": [ + { "id": "resident_address", "path": ["org.iso.18013.5.1", "resident_address"], "intent_to_retain": false }, + { "id": "resident_country", "path": ["org.iso.18013.5.1", "resident_country"], "intent_to_retain": true } + ] + }, + { + "id": "photo_card-id", + "format": "mso_mdoc", + "meta": { "doctype_value": "org.iso.23220.photoid.1" }, + "claims": [ + { "id": "given_name", "path": ["org.iso.23220.1", "given_name"] }, + { "id": "family_name", "path": ["org.iso.23220.1", "family_name"] }, + { "id": "portrait", "path": ["org.iso.23220.1", "portrait"] } + ] + }, + { + "id": "photo_card-address", + "format": "mso_mdoc", + "meta": { "doctype_value": "org.iso.23220.photoid.1" }, + "claims": [ + { "id": "resident_address", "path": ["org.iso.23220.1", "resident_address"] }, + { "id": "resident_country", "path": ["org.iso.23220.1", "resident_country"] } + ] + } + ], + "credential_sets": [ + { "purpose": "Identification", "options": [["mdl-id"], ["photo_card-id"]] }, + { "purpose": "Proof of address", "required": false, "options": [["mdl-address"], ["photo_card-address"]] } + ] + } + """; + + private static final Credential MDOC_MDL_ID = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, null, Map.of( + "credential_format", "mso_mdoc", + "docType", "org.iso.18013.5.1.mDL", + "namespaces", Map.of("org.iso.18013.5.1", Map.of("given_name", "Martin", "family_name", "Auer", "portrait", "https://example.com/portrait")), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_MDL_ADDRESS = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, null, Map.of( + "credential_format", "mso_mdoc", + "docType", "org.iso.18013.5.1.mDL", + "namespaces", Map.of("org.iso.18013.5.1", Map.of("resident_country", "Italy", "resident_address", "Via Roma 1", "non_disclosed", "secret")), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_PHOTO_CARD_ID = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, null, Map.of( + "credential_format", "mso_mdoc", + "docType", "org.iso.23220.photoid.1", + "namespaces", Map.of("org.iso.23220.1", Map.of("given_name", "Martin", "family_name", "Auer", "portrait", "https://example.com/portrait")), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_PHOTO_CARD_ADDRESS = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, null, Map.of( + "credential_format", "mso_mdoc", + "docType", "org.iso.23220.photoid.1", + "namespaces", Map.of("org.iso.23220.1", Map.of("resident_country", "Italy", "resident_address", "Via Roma 1", "non_disclosed", "secret")), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_EXAMPLE = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, null, Map.of( + "credential_format", "mso_mdoc", + "docType", "example_doctype", + "namespaces", Map.of("example_namespaces", Map.of("example_claim", "example_value")), + "cryptographic_holder_binding", true + ))); + + private static final Credential SD_JWT_VC_EXAMPLE = new Credential(CredentialFormat.VC_SD_JWT, new SdJwtCredential( null, + new JwtCredential( null, null, Map.of( + "credential_format", "vc+sd-jwt", + "vct", "https://credentials.example.com/identity_credential", + "claims", Map.of( + "first_name", "Arthur", + "last_name", "Dent", + "address", Map.of("street_address", "42 Market Street", "locality", "Milliways", "postal_code", "12345"), + "degrees", List.of( + Map.of("type", "Bachelor of Science", "university", "University of Betelgeuse"), + Map.of("type", "Master of Science", "university", "University of Betelgeuse") + ), + "nationalities", List.of("British", "Betelgeusian") + ), + "cryptographic_holder_binding", true + ), null), List.of())); + + + @Test + @DisplayName("fails with no credentials") + void failsWithNoCredentials() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(COMPLEX_MDOC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of()); + + assertFalse(queryResult.success()); + } + + @Test + @DisplayName("fails with credentials that do not satisfy a required claim_set") + void failsWithCredentialsThatDoNotSatisfyARequiredClaimSet() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(COMPLEX_MDOC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MDL_ADDRESS, MDOC_PHOTO_CARD_ADDRESS)); + + assertFalse(queryResult.success()); + } + + @Test + @DisplayName("return the requested sets") + void succeedsWithRequestedSets() throws JsonProcessingException { + List expectedIdCredentials = List.of(MDOC_MDL_ID); + List expectedPoaCredentials = List.of(MDOC_MDL_ADDRESS); + + var query = OBJECT_MAPPER.readValue(COMPLEX_MDOC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of( + MDOC_MDL_ID, + MDOC_MDL_ADDRESS, + MDOC_PHOTO_CARD_ID, + MDOC_PHOTO_CARD_ADDRESS, + MDOC_EXAMPLE, + SD_JWT_VC_EXAMPLE)); + + assertTrue(queryResult.success()); + assertTrue(queryResult.credentials().containsKey("Identification")); + assertTrue(queryResult.credentials().containsKey("Proof of address")); + + List identification = queryResult.credentials().get("Identification"); + List poa = queryResult.credentials().get("Proof of address"); + + assertEquals(1, identification.size()); + assertEquals(1, poa.size()); + + expectedIdCredentials.forEach( + ec -> assertTrue(identification.contains(ec))); + expectedPoaCredentials.forEach( + ec -> assertTrue(poa.contains(ec))); + } + + @Test + @DisplayName("return alternative if not included") + void returnAlternative() throws JsonProcessingException { + List expectedIdCredentials = List.of(MDOC_PHOTO_CARD_ID); + List expectedPoaCredentials = List.of(MDOC_MDL_ADDRESS); + + var query = OBJECT_MAPPER.readValue(COMPLEX_MDOC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of( + MDOC_MDL_ADDRESS, + MDOC_PHOTO_CARD_ID, + MDOC_PHOTO_CARD_ADDRESS, + MDOC_EXAMPLE, + SD_JWT_VC_EXAMPLE)); + + assertTrue(queryResult.success()); + assertTrue(queryResult.credentials().containsKey("Identification")); + assertTrue(queryResult.credentials().containsKey("Proof of address")); + + List identification = queryResult.credentials().get("Identification"); + List poa = queryResult.credentials().get("Proof of address"); + + assertEquals(1, identification.size()); + assertEquals(1, poa.size()); + + expectedIdCredentials.forEach( + ec -> assertTrue(identification.contains(ec))); + expectedPoaCredentials.forEach( + ec -> assertTrue(poa.contains(ec))); + } + +} diff --git a/src/test/java/io/github/wistefan/dcql/query/DcqlQueryTest.java b/src/test/java/io/github/wistefan/dcql/query/DcqlQueryTest.java new file mode 100644 index 0000000..7511109 --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/query/DcqlQueryTest.java @@ -0,0 +1,270 @@ + +package io.github.wistefan.dcql.query; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.github.wistefan.dcql.QueryResult; +import io.github.wistefan.dcql.model.Credential; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.DcqlQuery; +import io.github.wistefan.dcql.model.credential.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class DcqlQueryTest extends DcqlTest { + + private static final String MDOC_MVRC_QUERY = "{\n" + + " \"credentials\": [\n" + + " {\n" + + " \"id\": \"my_credential\",\n" + + " \"format\": \"mso_mdoc\",\n" + + " \"meta\": { \"doctype_value\": \"org.iso.7367.1.mVRC\" },\n" + + " \"require_cryptographic_holder_binding\": true,\n" + + " \"claims\": [\n" + + " { \"path\": [\"org.iso.7367.1\", \"vehicle_holder\"], \"intent_to_retain\": false },\n" + + " { \"path\": [\"org.iso.18013.5.1\", \"first_name\"], \"intent_to_retain\": true }\n" + + " ],\n" + + " \"trusted_authorities\": [\n" + + " { \"type\": \"aki\", \"values\": [\"" + Base64.getUrlEncoder().encodeToString(generateTestAki(TEST_KEY).getKeyIdentifier()) + "\"] }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + private static final String MDOC_NAMESPACE_MVRC_QUERY = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "mso_mdoc", + "meta": { "doctype_value": "org.iso.7367.1.mVRC" }, + "claims": [ + { "namespace": "org.iso.7367.1", "claim_name": "vehicle_holder" }, + { "namespace": "org.iso.18013.5.1", "claim_name": "first_name" } + ], + "require_cryptographic_holder_binding": false + } + ] + } + """; + + private static final Credential MDOC_MVRC = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential(null, new MDocHeaders(null, List.of(generateTestCertificate(TEST_KEY))), Map.of( + "docType", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.7367.1", Map.of("vehicle_holder", "Martin Auer"), + "org.iso.18013.5.1", Map.of("first_name", "Martin Auer") + ), + "authority", Map.of("type", "aki", "values", List.of("one")), + "cryptographic_holder_binding", true + ))); + + private static final Credential EXAMPLE_MDOC = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, null, Map.of( + "docType", "example_doctype", + "namespaces", Map.of("example_namespaces", Map.of("example_claim", "example_value")), + "authority", Map.of("type", "aki", "values", List.of("something")), + "cryptographic_holder_binding", true + ))); + + private static final Credential EXAMPLE_SD_JWT_VC = new Credential(CredentialFormat.VC_SD_JWT, + new SdJwtCredential( null, + new JwtCredential( null, null, + Map.of( + "vct", "https://credentials.example.com/identity_credential", + "_sd", List.of(getDisclosure("hash-b", "first_name", "Arthur").getSdHash(), getDisclosure("hash-c", "last_name", "Dent").getSdHash()), + "address", Map.of("_sd", List.of(getDisclosure("hash-a", "street_address", "42 Market Street").getSdHash(), "hash-x")), + "cryptographic_holder_binding", false), null), + List.of(getDisclosure("hash-a", "street_address", "42 Market Street"), + getDisclosure("hash-b", "first_name", "Arthur"), + getDisclosure("hash-c", "last_name", "Dent")) + )); + + private static final Credential EXAMPLE_W3C_LDP_VC = new Credential(CredentialFormat.LDP_VC, new LdpCredential( null, Map.of( + "type", List.of("https://www.w3.org/2018/credentials#VerifiableCredential", "https://example.org/examples#AlumniCredential", "https://example.org/examples#BachelorDegree"), + "credentialSubject", Map.of("first_name", "Arthur", "last_name", "Dent", "address", Map.of("street_address", "42 Market Street")), + "cryptographic_holder_binding", false + ))); + + + private static final String SD_JWT_VC_EXAMPLE_QUERY = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "vc+sd-jwt", + "meta": { "vct_values": ["https://credentials.example.com/identity_credential"] }, + "claims": [ { "path": ["last_name"] }, { "path": ["first_name"] }, { "path": ["address", "street_address"] } ], + "require_cryptographic_holder_binding": false + } + ] + } + """; + + private static final String SD_JWT_VC_MULTIPLE_EXAMPLE_QUERY = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "vc+sd-jwt", + "multiple": true, + "meta": { "vct_values": ["https://credentials.example.com/identity_credential"] }, + "claims": [ { "path": ["last_name"] }, { "path": ["first_name"] }, { "path": ["address", "street_address"] } ], + "require_cryptographic_holder_binding": false + } + ] + } + """; + + private static final String SD_JWT_VC_NO_CLAIMS_EXAMPLE_QUERY = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "vc+sd-jwt", + "multiple": true, + "meta": { "vct_values": ["https://credentials.example.com/identity_credential"] }, + "require_cryptographic_holder_binding": false + } + ] + } + """; + + private static final String W3C_LDP_VC_QUERY = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "ldp_vc", + "meta": { + "type_values": [ + ["https://example.org/examples#AlumniCredential", "https://example.org/examples#BachelorDegree"], + ["https://www.w3.org/2018/credentials#VerifiableCredential", "https://example.org/examples#UniversityDegreeCredential"] + ] + }, + "claims": [ { "path": ["credentialSubject", "last_name"] }, { "path": ["credentialSubject", "first_name"] }, { "path": ["credentialSubject", "address", "street_address"] } ], + "require_cryptographic_holder_binding": false + } + ] + } + """; + + + @Test + @DisplayName("mdoc mvrc query fails with invalid mdoc") + void mdocMvrcQueryFailsWithInvalidMdoc() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(EXAMPLE_MDOC)); + + assertFalse(queryResult.success()); + } + + @Test + @DisplayName("mdoc mvrc example with multiple credentials succeeds") + void mdocMvrcExampleWithMultipleCredentialsSucceeds() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(EXAMPLE_MDOC, MDOC_MVRC)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + assertEquals(MDOC_MVRC, queryResult.credentials().get("credentials").get(0)); + } + + @Test + @DisplayName("w3cLdpVc example succeeds") + void w3cLdpVcExampleSucceeds() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(W3C_LDP_VC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(EXAMPLE_W3C_LDP_VC)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + assertEquals(EXAMPLE_W3C_LDP_VC, queryResult.credentials().get("credentials").get(0)); + } + + @Test + @DisplayName("w3cLdpVc query fails with invalid type values") + void w3cLdpVcQueryFailsWithInvalidTypeValues() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(W3C_LDP_VC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC)); + + assertFalse(queryResult.success()); + } + + @Test + @DisplayName("mdocMvrc example using namespaces succeeds") + void mdocMvrcExampleUsingNamespacesSucceeds() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_NAMESPACE_MVRC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + } + + @Test + @DisplayName("sdJwtVc example with multiple credentials succeeds") + void sdJwtVcExampleWithMultipleCredentialsSucceeds() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(SD_JWT_VC_EXAMPLE_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(EXAMPLE_MDOC, EXAMPLE_SD_JWT_VC)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential theCredential = queryResult.credentials().get("credentials").get(0); + assertEquals(CredentialFormat.VC_SD_JWT, theCredential.getCredentialFormat()); + if (theCredential.getRawCredential() instanceof SdJwtCredential sdJwtCredential) { + assertEquals(3, sdJwtCredential.getDisclosures().size()); + } else { + fail("It should be an SD-JWT credential."); + } + } + + @Test + @DisplayName("sdJwtVc with 'multiple' set to true succeeds") + void sdJwtVcWithMultipleSetToTrueSucceeds() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(SD_JWT_VC_MULTIPLE_EXAMPLE_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(EXAMPLE_SD_JWT_VC, EXAMPLE_SD_JWT_VC)); + assertTrue(queryResult.success()); + assertEquals(2, queryResult.credentials().get("credentials").size()); + } + + @Test + @DisplayName("sdJwtVc with 'multiple' set to true but only one credential in the presentation matches") + void sdJwtVcWithMultipleButOneMatch() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(SD_JWT_VC_MULTIPLE_EXAMPLE_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(EXAMPLE_SD_JWT_VC, EXAMPLE_MDOC)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential credential = queryResult.credentials().get("credentials").get(0); + assertEquals(CredentialFormat.VC_SD_JWT, credential.getCredentialFormat()); + if (credential.getRawCredential() instanceof SdJwtCredential sdJwtCredential) { + assertEquals(3, sdJwtCredential.getDisclosures().size()); + } else { + fail("An SdJwtCredential should be contained."); + } + } + + @Test + @DisplayName("sdJwtVc with no claims should not disclose anything.") + void sdJwtVcWithNoClaims() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(SD_JWT_VC_NO_CLAIMS_EXAMPLE_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(EXAMPLE_SD_JWT_VC, EXAMPLE_MDOC)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + Credential credential = queryResult.credentials().get("credentials").get(0); + assertEquals(CredentialFormat.VC_SD_JWT, credential.getCredentialFormat()); + if (credential.getRawCredential() instanceof SdJwtCredential sdJwtCredential) { + assertTrue(sdJwtCredential.getDisclosures().isEmpty()); + } else { + fail("An SdJwtCredential should be contained."); + } + } + +} diff --git a/src/test/java/io/github/wistefan/dcql/query/DcqlQueryTrustedAuthoritiesTest.java b/src/test/java/io/github/wistefan/dcql/query/DcqlQueryTrustedAuthoritiesTest.java new file mode 100644 index 0000000..c61db8a --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/query/DcqlQueryTrustedAuthoritiesTest.java @@ -0,0 +1,140 @@ + +package io.github.wistefan.dcql.query; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.github.wistefan.dcql.QueryResult; +import io.github.wistefan.dcql.model.Credential; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.DcqlQuery; +import io.github.wistefan.dcql.model.credential.JwtCredential; +import io.github.wistefan.dcql.model.credential.MDocCredential; +import io.github.wistefan.dcql.model.credential.MDocHeaders; +import io.github.wistefan.dcql.model.credential.SdJwtCredential; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class DcqlQueryTrustedAuthoritiesTest extends DcqlTest { + + + // --- Test Data --- + + private static final Map ETSI_TL_AUTHORITY = Map.of("type", "etsi_tl", "values", List.of("https://list.com")); + private static final Map OPENID_FEDERATION_AUTHORITY = Map.of("type", "openid_federation", "values", List.of("https://federation.com")); + + + private static final String MDOC_MVRC_QUERY = "{\n" + + " \"credentials\": [\n" + + " {\n" + + " \"id\": \"my_credential\",\n" + + " \"format\": \"mso_mdoc\",\n" + + " \"trusted_authorities\": [\n" + + " {\n" + + " \"type\": \"aki\",\n" + + " \"values\": [\"" + Base64.getUrlEncoder().encodeToString(generateTestAki(TEST_KEY).getKeyIdentifier()) + "\", \"UVVJUkVELiBBIHN0cmluZyB1bmlxdWVseSBpZGVudGlmeWluZyB0aGUgdHlwZSA\"]\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + private static final Credential MDOC_MVRC = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, new MDocHeaders(null, List.of(generateTestCertificate(TEST_KEY))), Map.of( + "credential_format", "mso_mdoc", + "doctype", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.7367.1", Map.of("vehicle_holder", "Martin Auer", "non_disclosed", "secret"), + "org.iso.18013.5.1", Map.of("first_name", "Martin Auer") + ), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_MVRC_ALT_AKI = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, new MDocHeaders(null, List.of(generateTestCertificate(generateTestKeyPair()))), Map.of( + "credential_format", "mso_mdoc", + "doctype", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.7367.1", Map.of("vehicle_holder", "Martin Auer", "non_disclosed", "secret"), + "org.iso.18013.5.1", Map.of("first_name", "Martin Auer") + ), + "cryptographic_holder_binding", true + ))); + + private static final Credential MDOC_MVRC_NO_X5C = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, new MDocHeaders(null, List.of()), Map.of( + "credential_format", "mso_mdoc", + "doctype", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.7367.1", Map.of("vehicle_holder", "Martin Auer", "non_disclosed", "secret"), + "org.iso.18013.5.1", Map.of("first_name", "Martin Auer") + ), + "cryptographic_holder_binding", true + ))); + + private static final String SD_JWT_VC_EXAMPLE_QUERY = "{\n" + + " \"credentials\": [\n" + + " {\n" + + " \"id\": \"my_credential\",\n" + + " \"format\": \"vc+sd-jwt\",\n" + + " \"trusted_authorities\": [\n" + + " {\n" + + " \"type\": \"aki\",\n" + + " \"values\": [\"" + Base64.getUrlEncoder().encodeToString(generateTestAki(TEST_KEY).getKeyIdentifier()) + "\"]\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + private static final Credential SD_JWT_VC = new Credential(CredentialFormat.VC_SD_JWT, new SdJwtCredential( null, new JwtCredential( null, Map.of("x5c", List.of(generateTestCertificate(TEST_KEY))), Map.of( + "credential_format", "vc+sd-jwt", + "vct", "https://credentials.example.com/identity_credential", + "claims", Map.of("first_name", "Arthur", "last_name", "Dent"), + "cryptographic_holder_binding", true + ), null), List.of())); + + + @Test + @DisplayName("mdocMvrc example with trusted_authorities succeeds") + void mdocMvrcExampleWithTrustedAuthoritiesSucceeds() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult credentialsResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC)); + + assertTrue(credentialsResult.success()); + assertEquals(1, credentialsResult.credentials().get("credentials").size()); + } + + @Test + @DisplayName("mdocMvrc example where authority does not match trusted_authorities entry") + void mdocMvrcExampleWhereAuthorityDoesNotMatch() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult credentialsResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC_ALT_AKI)); + + assertFalse(credentialsResult.success()); + } + + @Test + @DisplayName("mdocMvrc example where trusted_authorities is present but no authority") + void mdocMvrcExampleWithNoAuthority() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult credentialsResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_MVRC_NO_X5C)); + + assertFalse(credentialsResult.success()); + } + + @Test + @DisplayName("sdJwtVc example with trusted_authorities succeeds") + void sdJwtVcExampleWithTrustedAuthoritiesSucceeds() throws JsonProcessingException { + + var query = OBJECT_MAPPER.readValue(SD_JWT_VC_EXAMPLE_QUERY, DcqlQuery.class); + QueryResult credentialsResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(SD_JWT_VC)); + + assertTrue(credentialsResult.success()); + assertEquals(1, credentialsResult.credentials().get("credentials").size()); + } + +} diff --git a/src/test/java/io/github/wistefan/dcql/query/DcqlQueryWithJsonTransformTest.java b/src/test/java/io/github/wistefan/dcql/query/DcqlQueryWithJsonTransformTest.java new file mode 100644 index 0000000..c45fa1c --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/query/DcqlQueryWithJsonTransformTest.java @@ -0,0 +1,129 @@ + +package io.github.wistefan.dcql.query; + + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.github.wistefan.dcql.QueryResult; +import io.github.wistefan.dcql.model.Credential; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.DcqlQuery; +import io.github.wistefan.dcql.model.credential.JwtCredential; +import io.github.wistefan.dcql.model.credential.MDocCredential; +import io.github.wistefan.dcql.model.credential.SdJwtCredential; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DcqlQueryWithJsonTransformTest extends DcqlTest { + + /** + * A placeholder for a class that might be used in credential data and needs special handling, + * similar to the ValueClass in the TypeScript test. + */ + static class ValueClass { + private final Object value; + + public ValueClass(Object value) { + this.value = value; + } + + public Object toJson() { + return this.value; + } + + @Override + public boolean equals(Object o) { + return o instanceof ValueClass && ((ValueClass) o).value.equals(this.value); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + } + + // --- Test Data --- + + private static final String MDOC_MVRC_QUERY = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "mso_mdoc", + "meta": { "doctype_value": "org.iso.7367.1.mVRC" }, + "claims": [ + { "namespace": "org.iso.7367.1", "claim_name": "vehicle_holder" }, + { "namespace": "org.iso.18013.5.1", "claim_name": "first_name" } + ] + } + ] + } + """; + + private static final String SD_JWT_VC_EXAMPLE_QUERY = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "dc+sd-jwt", + "meta": { "vct_values": ["https://credentials.example.com/identity_credential"] }, + "claims": [ + { "path": ["last_name"] }, + { "path": ["first_name"] }, + { "path": ["address", "street_address"] }, + { "path": ["org.iso.7367.1", "vehicle_holder"], "values": ["Timo Glastra"] } + ] + } + ] + } + """; + + private static final Credential MDOC_WITH_JT = new Credential(CredentialFormat.MSO_MDOC, new MDocCredential( null, null, Map.of( + "docType", "org.iso.7367.1.mVRC", + "namespaces", Map.of( + "org.iso.7367.1", Map.of("vehicle_holder", "Martin Auer", "non_disclosed", "secret"), + "org.iso.18013.5.1", Map.of("first_name", new ValueClass("Martin Auer")) + ), + "cryptographic_holder_binding", true + ))); + + private static final Credential SD_JWT_VC_WITH_JT = new Credential(CredentialFormat.DC_SD_JWT, + new SdJwtCredential( null, + new JwtCredential( null, null, Map.of( + "vct", "https://credentials.example.com/identity_credential", + "claims", Map.of( + "first_name", "Arthur", + "last_name", "Dent", + "address", Map.of("street_address", new ValueClass("42 Market Street"), "locality", "Milliways", "postal_code", "12345"), + "org.iso.7367.1", Map.of("vehicle_holder", "Timo Glastra") + ), + "cryptographic_holder_binding", true + ), null), List.of())); + + @Test + @DisplayName("mdocMvrc example succeeds") + void mdocMvrcExampleSucceeds() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(MDOC_MVRC_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_WITH_JT)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + } + + @Test + @DisplayName("sdJwtVc example with multiple credentials succeeds") + void sdJwtVcExampleWithMultipleCredentialsSucceeds() throws JsonProcessingException { + var query = OBJECT_MAPPER.readValue(SD_JWT_VC_EXAMPLE_QUERY, DcqlQuery.class); + QueryResult queryResult = dcqlEvaluator.evaluateDCQLQuery(query, List.of(MDOC_WITH_JT, SD_JWT_VC_WITH_JT)); + + assertTrue(queryResult.success()); + assertEquals(1, queryResult.credentials().get("credentials").size()); + assertEquals(CredentialFormat.DC_SD_JWT, queryResult.credentials().get("credentials").get(0).getCredentialFormat()); + } + +} diff --git a/src/test/java/io/github/wistefan/dcql/query/DcqlTest.java b/src/test/java/io/github/wistefan/dcql/query/DcqlTest.java new file mode 100644 index 0000000..d09a70c --- /dev/null +++ b/src/test/java/io/github/wistefan/dcql/query/DcqlTest.java @@ -0,0 +1,143 @@ +package io.github.wistefan.dcql.query; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.github.wistefan.dcql.*; +import io.github.wistefan.dcql.helper.CredentialFormatDeserializer; +import io.github.wistefan.dcql.helper.TrustedAuthorityTypeDeserializer; +import io.github.wistefan.dcql.model.CredentialFormat; +import io.github.wistefan.dcql.model.TrustedAuthorityType; +import io.github.wistefan.dcql.model.credential.Disclosure; +import org.bouncycastle.asn1.x509.*; +import org.bouncycastle.cert.X509ExtensionUtils; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.junit.jupiter.api.BeforeEach; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.Date; +import java.util.List; + +public abstract class DcqlTest { + + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + { + OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + OBJECT_MAPPER.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + SimpleModule deserializerModule = new SimpleModule(); + deserializerModule.addDeserializer(CredentialFormat.class, new CredentialFormatDeserializer()); + deserializerModule.addDeserializer(TrustedAuthorityType.class, new TrustedAuthorityTypeDeserializer()); + OBJECT_MAPPER.registerModule(deserializerModule); + } + + public static final KeyPair TEST_KEY = generateTestKeyPair(); + + protected DCQLEvaluator dcqlEvaluator; + + @BeforeEach + public void setUp() { + dcqlEvaluator = new DCQLEvaluator(List.of( + new JwtCredentialEvaluator(), + new DcSdJwtCredentialEvaluator(), + new VcSdJwtCredentialEvaluator(), + new MDocCredentialEvaluator(), + new LdpCredentialEvaluator())); + } + + public static KeyPair generateTestKeyPair() { + try { + // Generate keypair + KeyPairGenerator keyGen = null; + + keyGen = KeyPairGenerator.getInstance("RSA"); + + keyGen.initialize(2048); + return keyGen.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + public static AuthorityKeyIdentifier generateTestAki(KeyPair testKey) { + + Security.addProvider(new BouncyCastleProvider()); + try { + X509ExtensionUtils extUtils = new X509ExtensionUtils( + new JcaDigestCalculatorProviderBuilder() + .setProvider("BC") + .build() + .get(new AlgorithmIdentifier(X509ObjectIdentifiers.id_SHA1)) + ); + + // Now you can create SKI and AKI + SubjectPublicKeyInfo subjectPublicKeyInfo = + SubjectPublicKeyInfo.getInstance(testKey.getPublic().getEncoded()); + + return extUtils.createAuthorityKeyIdentifier(subjectPublicKeyInfo); + + } catch (Exception e) { + throw new RuntimeException(e); + } + + } + + + public static X509Certificate generateTestCertificate(KeyPair testKey) { + + Security.addProvider(new BouncyCastleProvider()); + try { + Security.addProvider(new BouncyCastleProvider()); + // Certificate details + String issuer = "CN=Test CA"; + String subject = "CN=Test Cert"; + BigInteger serial = BigInteger.valueOf(System.currentTimeMillis()); + Date notBefore = new Date(System.currentTimeMillis() - 1000L * 60 * 60); + Date notAfter = new Date(System.currentTimeMillis() + (1000L * 60 * 60 * 24 * 365)); + + // Builder + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + new javax.security.auth.x500.X500Principal(issuer), + serial, + notBefore, + notAfter, + new javax.security.auth.x500.X500Principal(subject), + testKey.getPublic() + ); + certBuilder.addExtension(Extension.authorityKeyIdentifier, false, generateTestAki(testKey)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption") + .build(testKey.getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter() + .setProvider("BC") + .getCertificate(certBuilder.build(signer)); + return cert; + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static Disclosure getDisclosure(String salt, String claim, Object value) { + try { + byte[] encoded = new ObjectMapper().writeValueAsBytes(List.of(salt, claim, value)); + return new Disclosure(salt, claim, value, Base64.getUrlEncoder().encodeToString(encoded), "sha-256"); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/test/resources/example/legalPerson.sd_jwt b/src/test/resources/example/legalPerson.sd_jwt new file mode 100644 index 0000000..e2b2bb7 --- /dev/null +++ b/src/test/resources/example/legalPerson.sd_jwt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsInR5cCIgOiAiZGMrc2Qtand0Iiwia2lkIiA6ICJkaWQ6a2V5OnpEbmFlYm9MREZXdTRvYlFGQmZUMWl0YUJZUzhmZDJkNVU1OWo1bzRndHZUY0JOcVEifQ.eyJfc2QiOlsiMTlORWo4M0swYWp1UEpTTjZodWtKbWVXbFY3aXZKdVBSS19VaVNUU2QxMCIsIkJIVkZVZ1ROa2gtbU9GdU8yLTB1MkZwSmZFMWs4dWNIWUkzRXcyMHpma2ciLCJDNTRPSWFJRV95OThQQzduZlR2S1lURWNqWE1tV2R5eWlUZXNPMGw3c2RnIiwiRVkzSVpyaWJDcGR1UzBYN0Y4eXJtNkZIaFVUelNucUFjaDhrSHRwcnFBQSIsIkxwOFhzalRqUUxGRXJnWVJtaFVWdnd0ajlwbno0NXVzUnUtcXY4YVNYWFEiLCJQMHZaNE40SDFTUTFmUUU5TndxVkxvME9LanhlY1UyQ0FQZkdRakxsVjh3IiwiU0E0NllaOFJDbVJIV3d6SE94R3FqdzJHWVg0R1VMbUllQTd1OWZXblctUSIsIlhrUi1JZUx2RmpMS3ZjU1BhQThZTGRoekVIbjl0UGpRWnpFOEJWMFdpLVkiLCJhalFEaWk5bGV5RlNFY3RwVDN6X084ZHJXV1dSSUdKUmhIeEwzTkZyZC13IiwiYXcxYzBXYUhzcElNamJBXzVsWWx4MW5NYjhIQXpGWE4yYW9KUTE4bVdoOCIsImJGNE1PSENxUVVTVGpSNy1XVXpYNlR4eFdCblpRVEQ1N1BHMDhFa1NNc28iLCJiVTdGd1U1YVVKZEZGUTFoV3B6dVlWN0tpdFJYYi1kU01CNDR3N19wY1NrIiwiZHVFWVk1dTlOQ1RDM2l1WkROdmIxdkcxaE9sUDgyZklRNm9yS0dsUDc1dyIsImUwai0xUVBQWWlpVzdXYjQybmpZT243a1JLYWtNblh0YUpxQ2gwajZmXzQiLCJxazhFck8xUmgtd0ljWEZrYlRwQkJ1WHNmZUw0U3JtY3ZLM3VaM20xd1dFIiwidUExdExzaU9UZHN6VWZFLVNOLU52R3dQOWthb1IzOC1CNUZoTzM5TjYwOCIsInVxVG1XVkFnR29ncXJlU2k3dWhGR19SMUdGZ0xkTkNmOFNzdTBlQmM5Y2MiLCJ2RVVoMGVVMFFDUkw3eEZCbUszdXk3QWRmQzlHamVjWWRwaXpGbU4wdC04IiwieFR5cWFaZEQ2R3dHSHp2UGFvZ0JFMFc1REdqWW5ZWEdDUDRrUW1zX1V5dyIsInlXODlyQmtNUjRGdjd0aVpDbGhfc3FXMlZSdU5lT2VlbUE1ODhvdEx6MjgiLCJ6TlJJWFFzb0F4a0RnUTFVVmY0WG5ibEJOTmZ4bThfd0tGdlctVXRXY3hrIl0sIl9zZF9hbGciOiJzaGEtMjU2IiwidmN0IjoiTGVnYWxQZXJzb25DcmVkZW50aWFsIiwiaXNzIjoiZGlkOmtleTp6RG5hZWJvTERGV3U0b2JRRkJmVDFpdGFCWVM4ZmQyZDVVNTlqNW80Z3R2VGNCTnFRIn0.XpOwHENO_E36ssmNtv9iK7l4cKsPbRvM6p-LZtG1WwmcfFiyoO-7N7dmuiADvXldgT0EIX7XN_ATEak5JbYpaQ~WyJwMTRoeWp2WFQzUGx6Q1IyaFozeEhBIiwgImNvdW50cnkiLCAiR2VybWFueSJd~WyJqUmxIT2poMXR2a1VjQU5kbGVlNm93IiwgImxhc3ROYW1lIiwgIlVzZXIiXQ~WyJGYlRSOE1fLTlsZ2NtdndHWUdFYU5nIiwgInN0cmVldE51bWJlciIsICIxMCJd~WyJDVDBRdUxmbzQ1eVJyaHhQWXBxZHZ3IiwgImNpdHkiLCAiRHJlc2RlbiJd~WyJOZ1RJYzkxRXJCWFlMenVwUnhCQVpnIiwgInN1YmplY3QiLCAiZGlkOmtleTp6RG5hZWlWcHhDVDdBUndxTG5kYldpQ2VHRzJZWlh2TGZXRnMxY0dQZ0tVZThHUExlIl0~WyJGQmpGNUxmcWdMWTZoclM4NnVFS1pRIiwgInJvbGVzIiwgW3sibmFtZXMiOiBbIlJFUFJFU0VOVEFUSVZFIiwgIlJFQURFUiIsICJjdXN0b21lciJdLCAidGFyZ2V0IjogImRpZDprZXk6ekRuYWVhSmtFUjhFclRBWnNxcEdLZXBnaWRmdFphNEx5dld2b2dRUGZqMllpU1A2YiJ9XV0~WyJ5QVNOQkw1QjhfT20zTHJOeVNfbUl3IiwgInppcGNvZGUiLCAiMDExNjkiXQ~WyJVTEtjckhVTmFKUEptSkhRYllBZGpnIiwgImZpcnN0TmFtZSIsICJUZXN0Il0~WyJ2UGY2OHViblp2RFRPS29WWkJZX2VnIiwgInN0cmVldCIsICJNYWluIFN0cmVldCJd~WyJ2VjVfTVkyYlBsYkV6SkpCZS1jWm13IiwgInJlZ2lvbiIsICJTYXhvbnkiXQ~WyJjd2F4dUFTUkIwUmN3Qjd5Y3NXYXJ3IiwgImVtYWlsIiwgImVtcGxveWVlQGNvbnN1bWVyLm9yZyJd~ \ No newline at end of file diff --git a/src/test/resources/example/userCredential.jwt b/src/test/resources/example/userCredential.jwt new file mode 100644 index 0000000..6f7e6c2 --- /dev/null +++ b/src/test/resources/example/userCredential.jwt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkaWQ6a2V5OnpEbmFlV0dpVkNRS0pmTjVyUGtRNHk3SFJhUVN3NUFWc2VabzNrUWZzSkhmOUNkWGsifQ.eyJuYmYiOjE3NTkyMjM2NzEsImp0aSI6InVybjp1dWlkOjU5NjEwMjZiLWM4ZDktNGZkZC1iNTlmLWJkYWRhMWQ0NjFhZSIsImlzcyI6ImRpZDprZXk6ekRuYWVXR2lWQ1FLSmZONXJQa1E0eTdIUmFRU3c1QVZzZVpvM2tRZnNKSGY5Q2RYayIsInZjIjp7InR5cGUiOlsiVXNlckNyZWRlbnRpYWwiXSwiaXNzdWVyIjoiZGlkOmtleTp6RG5hZVdHaVZDUUtKZk41clBrUTR5N0hSYVFTdzVBVnNlWm8za1Fmc0pIZjlDZFhrIiwiaXNzdWFuY2VEYXRlIjoxNzU5MjIzNjcxLjA5ODAwMDAwMCwiY3JlZGVudGlhbFN1YmplY3QiOnsiemlwY29kZSI6IjAxMTY5IiwibGFzdE5hbWUiOiJVc2VyIiwiY291bnRyeSI6Ikdlcm1hbnkiLCJmaXJzdE5hbWUiOiJUZXN0IiwiY2l0eSI6IkRyZXNkZW4iLCJzdHJlZXROdW1iZXIiOiIxMCIsInN0cmVldCI6Ik1haW4gU3RyZWV0Iiwic3ViamVjdCI6ImRpZDprZXk6ekRuYWVpVnB4Q1Q3QVJ3cUxuZGJXaUNlR0cyWVpYdkxmV0ZzMWNHUGdLVWU4R1BMZSIsInJvbGVzIjpbeyJuYW1lcyI6WyJSRVBSRVNFTlRBVElWRSIsIlJFQURFUiIsImN1c3RvbWVyIl0sInRhcmdldCI6ImRpZDprZXk6ekRuYWVYM2RGWktNUnh0THFROEhSQkFRekJzcEJQZHJubVZoRlNNZzNMeE05ZWZ0MSJ9XSwicmVnaW9uIjoiU2F4b255IiwiZW1haWwiOiJlbXBsb3llZUBjb25zdW1lci5vcmcifSwiQGNvbnRleHQiOlsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjEiXX19.O6j8bOJuK9KXiW-eHjtwJVnCAA7VmUvv9uYCIGwDe2Pxnsm02qyMGri4drdpE_u0_ZMKndUwwerU2QZm5bo0sA \ No newline at end of file