diff --git a/.github/actions/pit-results-badge/action.yml b/.github/actions/pit-results-badge/action.yml new file mode 100644 index 000000000..57adc2288 --- /dev/null +++ b/.github/actions/pit-results-badge/action.yml @@ -0,0 +1,49 @@ +name: Create Shields.io badge from PIT mutation test results +author: Emil Lundberg +description: | + Parses a [PIT][pitest] report file and outputs a [Shields.io][shields] + [endpoint badge][endpoint] definition file. + + [endpoint]: https://shields.io/endpoint + [pitest]: https://pitest.org/ + [shields]: https://shields.io/ + +inputs: + cache-seconds: + default: 3600 + description: Passed through as cacheSeconds to Shields.io. + + label: + default: "mutation coverage" + description: Label for the left side of the badge. + + mutations-file: + default: build/reports/pitest/mutations.xml + description: Path to the PIT report XML file. + + output-file: + required: true + description: Path to write output file to. + +runs: + using: "composite" + + steps: + - name: Install yq (and xq) + shell: bash + run: pip install yq + + - name: Create coverage badge + shell: bash + run: | + cat ${{ inputs.mutations-file }} \ + | xq '.mutations.mutation + | (map(select(.["@detected"] == "true")) | length) / length + | { + schemaVersion: 1, + label: "${{ inputs.label }}", + message: "\(. * 100 | floor | tostring) %", + color: "hsl(\(. * 120 | floor | tostring), 100%, 40%)", + cacheSeconds: ${{ inputs.cache-seconds }}, + }' \ + > ${{ inputs.output-file }} diff --git a/.github/actions/pit-results-comment/action.yml b/.github/actions/pit-results-comment/action.yml new file mode 100644 index 000000000..4f7b25364 --- /dev/null +++ b/.github/actions/pit-results-comment/action.yml @@ -0,0 +1,56 @@ +name: Post PIT mutation test results comment +author: Emil Lundberg +description: | + Parses a [PIT][pitest] report file, compares it to a previous report, + and posts a summary as a commit comment to the commit that triggered the workflow. + + [pitest]: https://pitest.org/ + +inputs: + mutations-file: + default: build/reports/pitest/mutations.xml + description: Path to the PIT report XML file. + + prev-commit: + default: '' + description: | + The full commit SHA of the previous run of this action. + If set, the comment will include a link to the previous commit. + + prev-mutations-file: + required: true + description: Path to the PIT report XML file from the previous run of this action. + + token: + default: ${{ github.token }} + description: GITHUB_TOKEN or a PAT with permission to write commit comments. + +runs: + using: "composite" + + steps: + - name: Install yq (and xq) + shell: bash + run: pip install yq + + - name: Post results comment + shell: bash + run: | + RESULTS_COMMENT_FILE=$(mktemp) + NEW_STATS_FILE=$(mktemp) + PREV_STATS_FILE=$(mktemp) + + ./.github/actions/pit-results-comment/compute-stats.sh "${{ inputs.mutations-file }}" > "${NEW_STATS_FILE}" + + if [[ -f "${{ inputs.prev-mutations-file }}" ]]; then + ./.github/actions/pit-results-comment/compute-stats.sh "${{ inputs.prev-mutations-file }}" > "${PREV_STATS_FILE}" + else + echo 'Previous mutations file not found, using current as placeholder.' + cp "${NEW_STATS_FILE}" "${PREV_STATS_FILE}" + fi + + ./.github/actions/pit-results-comment/stats-to-comment.sh "${PREV_STATS_FILE}" "${NEW_STATS_FILE}" "${{ inputs.prev-commit }}" > "${RESULTS_COMMENT_FILE}" + + curl -X POST \ + -H "Authorization: Bearer ${{ inputs.token }}" \ + ${{ github.api_url }}/repos/${{ github.repository }}/commits/${{ github.sha }}/comments -d @"${RESULTS_COMMENT_FILE}" diff --git a/.github/actions/pit-results-comment/compute-stats.sh b/.github/actions/pit-results-comment/compute-stats.sh new file mode 100755 index 000000000..0497ea802 --- /dev/null +++ b/.github/actions/pit-results-comment/compute-stats.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +xq '.mutations.mutation + | group_by(.mutatedClass | split(".") | .[:-1]) + | INDEX(.[0].mutatedClass | split(".") | .[:-1] | join(".")) + | map_values({ + detected: (map(select(.["@detected"] == "true")) | length), + mutations: length, + }) +' "${1}" diff --git a/.github/actions/pit-results-comment/stats-to-comment.sh b/.github/actions/pit-results-comment/stats-to-comment.sh new file mode 100755 index 000000000..d96ab0a33 --- /dev/null +++ b/.github/actions/pit-results-comment/stats-to-comment.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +make-contents() { + cat << EOF +## Mutation test results + +Package | Coverage | Stats | Prev | Prev | +------- | --------:|:-----:| ----:|:----:| +EOF + + jq -s '.[0] as $old | .[1] as $new + | { + packages: ( + $old | keys + | map({ + ("`\(.)`"): { + before: { + detected: $old[.].detected, + mutations: $old[.].mutations, + }, + after: { + detected: $new[.].detected, + mutations: $new[.].mutations, + }, + percentage_diff: (($new[.].detected / $new[.].mutations - $old[.].detected / $old[.].mutations) * 100 | round), + }, + }) + | add + ), + overall: { + before: { + detected: [($old[] | .detected)] | add, + mutations: [($old[] | .mutations)] | add, + }, + after: { + detected: [($new[] | .detected)] | add, + mutations: [($new[] | .mutations)] | add, + }, + percentage_diff: ( + ( + ([($new[] | .detected)] | add) / ([($new[] | .mutations)] | add) + - ([($old[] | .detected)] | add) / ([($old[] | .mutations)] | add) + ) * 100 | round + ), + }, + } + | { ("**Overall**"): .overall } + .packages + | to_entries + | .[] + | def difficon: + if .after.detected == .after.mutations then ":trophy:" + elif .percentage_diff > 0 then ":green_circle:" + elif .percentage_diff < 0 then ":small_red_triangle_down:" + else ":small_blue_diamond:" + end; + def triangles: + if . > 0 then ":small_red_triangle:" + elif . < 0 then ":small_red_triangle_down:" + else ":small_blue_diamond:" + end; + "\(.key) | **\(.value.after.detected / .value.after.mutations * 100 | floor) %** \(.value | difficon) | \(.value.after.detected) \(.value.after.detected - .value.before.detected | triangles) / \(.value.after.mutations) \(.value.after.mutations - .value.before.mutations | triangles)| \(.value.before.detected / .value.before.mutations * 100 | floor) % | \(.value.before.detected) / \(.value.before.mutations)" + ' \ + "${1}" "${2}" --raw-output + + if [[ -n "${3}" ]]; then + cat << EOF + +Previous run: ${3} +EOF + + cat << EOF + +Detailed reports: [workflow run #${GITHUB_RUN_NUMBER}](/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) +EOF + fi + +} + +make-contents "$@" | python -c 'import json; import sys; print(json.dumps({"body": sys.stdin.read()}))' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ce159c58f..031f7e8a5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,13 @@ updates: directory: "/" schedule: interval: "daily" + + ignore: + # Spotless patch updates are too noisy + - dependency-name: "spotless-plugin-gradle" + update-types: ["version-update:semver-patch"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d81b970af..ae491df61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,41 +1,58 @@ # This name is shown in the status badge in the README name: build -on: [push, pull_request] +on: + push: + branches-ignore: + - 'tmp**' + pull_request: + branches-ignore: + - 'tmp**' jobs: test: - name: JDK ${{matrix.java}} + name: JDK ${{ matrix.java }} ${{ matrix.distribution }} runs-on: ubuntu-latest strategy: matrix: - java: [8, 11, 16] + java: [8, 11, 17, 18] + distribution: [temurin] + include: + - java: 17 + distribution: zulu + - java: 17 + distribution: microsoft + + outputs: + report-java: 17 + report-dist: temurin steps: - name: Check out code - uses: actions/checkout@v1 + uses: actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} - name: Run tests run: ./gradlew cleanTest test - name: Archive HTML test report if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: test-reports-java${{ matrix.java }}-html + name: test-reports-java${{ matrix.java }}-${{ matrix.distribution }}-html path: "*/build/reports/**" - name: Archive JUnit test report if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: test-reports-java${{ matrix.java }}-xml + name: test-reports-java${{ matrix.java }}-${{ matrix.distribution }}-xml path: "*/build/test-results/**/*.xml" - name: Build JavaDoc @@ -47,11 +64,17 @@ jobs: runs-on: ubuntu-latest if: ${{ always() && github.event_name == 'pull_request' }} + permissions: + checks: write + pull-requests: write + steps: - name: Download artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 + with: + name: test-reports-java${{ needs.test.outputs.report-java }}-${{ needs.test.outputs.report-dist }}-xml - name: Publish test results - uses: EnricoMi/publish-unit-test-result-action@v1 + uses: EnricoMi/publish-unit-test-result-action@v2 with: files: "**/*.xml" diff --git a/.github/workflows/code-formatting.yml b/.github/workflows/code-formatting.yml index 9ab0f3888..749569623 100644 --- a/.github/workflows/code-formatting.yml +++ b/.github/workflows/code-formatting.yml @@ -1,7 +1,13 @@ # This name is shown in the status badge in the README name: code-formatting -on: [push, pull_request] +on: + push: + branches-ignore: + - 'tmp**' + pull_request: + branches-ignore: + - 'tmp**' jobs: test: @@ -10,16 +16,18 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - java: [11] + java: [17] + distribution: [temurin] steps: - name: Check out code - uses: actions/checkout@v1 + uses: actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} - name: Check code formatting run: ./gradlew spotlessCheck diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ced88f0f7..a48696100 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,8 +2,12 @@ name: "Code scanning - action" on: push: - branches-ignore: 'dependabot/**' + branches-ignore: + - 'dependabot/**' + - 'tmp**' pull_request: + branches-ignore: + - 'tmp**' schedule: - cron: '0 12 * * 2' @@ -12,17 +16,21 @@ jobs: runs-on: ubuntu-latest + permissions: + security-events: write + steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - - uses: actions/setup-java@v1 + - uses: actions/setup-java@v3 with: - java-version: '11' + java-version: 17 + distribution: temurin # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: java @@ -31,4 +39,4 @@ jobs: ./gradlew jar - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3dc41f85a..e2bbe639e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -3,7 +3,7 @@ name: Test coverage on: push: - branches: [master] + branches: [main] jobs: test: @@ -11,19 +11,71 @@ jobs: runs-on: ubuntu-latest + permissions: + contents: write # For push to GitHub Pages + steps: - name: Check out code - uses: actions/checkout@v1 + uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v1 + - name: Set up JDK + uses: actions/setup-java@v3 with: - java-version: 11 + java-version: 17 + distribution: temurin - name: Run mutation test - run: ./gradlew pitest + run: ./gradlew pitestMerge + + - name: Archive test reports + uses: actions/upload-artifact@v3 + with: + name: pitest-reports-${{ github.sha }} + path: "*/build/reports/pitest/**" + + - name: Create output directory + run: mkdir -p build/gh-pages + + - name: Collect HTML reports + run: | + mkdir -p build/gh-pages/mutation-coverage-reports + for sp in webauthn-server-attestation webauthn-server-core yubico-util; do + cp -a "${sp}"/build/reports/pitest build/gh-pages/mutation-coverage-reports/"${sp}" + done + sed "s/{shortcommit}/${GITHUB_SHA:0:8}/g;s/{commit}/${GITHUB_SHA}/g;s#{repo}#${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}#g" .github/workflows/coverage/index.html.template > build/gh-pages/index.html + + - name: Create coverage badge + # This creates a file that defines a [Shields.io endpoint badge](https://shields.io/endpoint) + # which we can then include in the project README. + uses: ./.github/actions/pit-results-badge + with: + output-file: build/gh-pages/coverage-badge.json + + - name: Check out GitHub Pages branch + uses: actions/checkout@v3 + with: + ref: gh-pages + clean: false + + - name: Prepare metadata for pit-results-comment action + run: | + git checkout "${GITHUB_SHA}" -- .github/workflows/coverage .github/actions + echo PREV_COMMIT=$(cat prev-commit.txt) >> "${GITHUB_ENV}" + + - name: Post mutation test results as commit comment + uses: ./.github/actions/pit-results-comment + with: + prev-commit: ${{ env.PREV_COMMIT }} + prev-mutations-file: prev-mutations.xml - - name: Report to Coveralls - env: - COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} - run: ./gradlew coveralls + - name: Push to GitHub Pages + run: | + git config user.name github-actions + git config user.email github-actions@github.com + git rm -rf -- . + mv build/gh-pages/* . + cp build/reports/pitest/mutations.xml prev-mutations.xml + echo "${GITHUB_SHA}" > prev-commit.txt + git add coverage-badge.json index.html mutation-coverage-reports prev-mutations.xml prev-commit.txt + git commit --amend --reset-author -m "Generate GitHub Pages content" + git push -f diff --git a/.github/workflows/coverage/index.html.template b/.github/workflows/coverage/index.html.template new file mode 100644 index 000000000..14908c1af --- /dev/null +++ b/.github/workflows/coverage/index.html.template @@ -0,0 +1,15 @@ + + + + + Mutation test report index - java-webauthn-server {shortcommit} + + +

Mutation test reports for java-webauthn-server {shortcommit}:

+ + + diff --git a/.github/workflows/release-verify-signatures.yml b/.github/workflows/release-verify-signatures.yml index fe85b844f..23b7dac62 100644 --- a/.github/workflows/release-verify-signatures.yml +++ b/.github/workflows/release-verify-signatures.yml @@ -1,51 +1,92 @@ -name: Verify release signatures +name: Reproducible binary + +# This workflow waits for release signatures to appear on Maven Central, +# then rebuilds the artifacts and verifies them against those signatures, +# and finally uploads the signatures to the GitHub release. on: release: - types: [published, created, edited, prereleased] + types: [published, edited] jobs: - verify: - name: Verify signatures (JDK ${{matrix.java}}) + download: + name: Download keys and signatures + runs-on: ubuntu-latest + + steps: + - name: Fetch keys + run: gpg --no-default-keyring --keyring ./yubico.keyring --keyserver hkps://keys.openpgp.org --recv-keys 57A9DEED4C6D962A923BB691816F3ED99921835E + - name: Download signatures from Maven Central + timeout-minutes: 60 + run: | + until wget https://repo1.maven.org/maven2/com/yubico/webauthn-server-attestation/${{ github.ref_name }}/webauthn-server-attestation-${{ github.ref_name }}.jar.asc; do sleep 180; done + until wget https://repo1.maven.org/maven2/com/yubico/webauthn-server-core/${{ github.ref_name }}/webauthn-server-core-${{ github.ref_name }}.jar.asc; do sleep 180; done + + - name: Store keyring and signatures as artifact + uses: actions/upload-artifact@v3 + with: + name: keyring-and-signatures + retention-days: 1 + path: | + yubico.keyring + *.jar.asc + + verify: + name: Verify signatures (JDK ${{ matrix.java }} ${{ matrix.distribution }}) + needs: download runs-on: ubuntu-latest + strategy: matrix: - java: [11] + java: [17] + distribution: [temurin, zulu, microsoft] steps: - name: check out code - uses: actions/checkout@v1 + uses: actions/checkout@v3 + with: + ref: ${{ github.ref_name }} - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} - name: Build jars run: | java --version ./gradlew jar - - name: Fetch keys - run: gpg --no-default-keyring --keyring yubico --keyserver hkps://keys.openpgp.org --recv-keys 57A9DEED4C6D962A923BB691816F3ED99921835E + - name: Retrieve keyring and signatures + uses: actions/download-artifact@v3 + with: + name: keyring-and-signatures - - name: Verify signatures from GitHub release + - name: Verify signatures from Maven Central run: | - export TAGNAME=${GITHUB_REF#refs/tags/} + gpg --no-default-keyring --keyring ./yubico.keyring --verify webauthn-server-attestation-${{ github.ref_name }}.jar.asc webauthn-server-attestation/build/libs/webauthn-server-attestation-${{ github.ref_name }}.jar + gpg --no-default-keyring --keyring ./yubico.keyring --verify webauthn-server-core-${{ github.ref_name }}.jar.asc webauthn-server-core/build/libs/webauthn-server-core-${{ github.ref_name }}.jar - wget https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAGNAME}/webauthn-server-attestation-${TAGNAME}.jar.asc - wget https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAGNAME}/webauthn-server-core-${TAGNAME}.jar.asc + upload: + name: Upload signatures to GitHub + needs: verify + runs-on: ubuntu-latest - gpg --no-default-keyring --keyring yubico --verify webauthn-server-attestation-${TAGNAME}.jar.asc webauthn-server-attestation/build/libs/webauthn-server-attestation-${TAGNAME}.jar - gpg --no-default-keyring --keyring yubico --verify webauthn-server-core-${TAGNAME}.jar.asc webauthn-server-core/build/libs/webauthn-server-core-${TAGNAME}.jar + permissions: + contents: write # Allow uploading release artifacts - - name: Verify signatures from Maven Central - run: | - export TAGNAME=${GITHUB_REF#refs/tags/} + steps: + - name: Retrieve signatures + uses: actions/download-artifact@v3 + with: + name: keyring-and-signatures - wget -O webauthn-server-core-${TAGNAME}.jar.mavencentral.asc https://repo1.maven.org/maven2/com/yubico/webauthn-server-core/${TAGNAME}/webauthn-server-core-${TAGNAME}.jar.asc - wget -O webauthn-server-attestation-${TAGNAME}.jar.mavencentral.asc https://repo1.maven.org/maven2/com/yubico/webauthn-server-attestation/${TAGNAME}/webauthn-server-attestation-${TAGNAME}.jar.asc + - name: Upload signatures to GitHub + run: | + RELEASE_DATA=$(curl -H "Authorization: Bearer ${{ github.token }}" ${{ github.api_url }}/repos/${{ github.repository }}/releases/tags/${{ github.ref_name }}) + UPLOAD_URL=$(jq -r .upload_url <<<"${RELEASE_DATA}" | sed 's/{?name,label}//') - gpg --no-default-keyring --keyring yubico --verify webauthn-server-attestation-${TAGNAME}.jar.mavencentral.asc webauthn-server-attestation/build/libs/webauthn-server-attestation-${TAGNAME}.jar - gpg --no-default-keyring --keyring yubico --verify webauthn-server-core-${TAGNAME}.jar.mavencentral.asc webauthn-server-core/build/libs/webauthn-server-core-${TAGNAME}.jar + curl -X POST -H "Authorization: Bearer ${{ github.token }}" -H 'Content-Type: text/plain' --data-binary @webauthn-server-attestation-${{ github.ref_name }}.jar.asc "${UPLOAD_URL}?name=webauthn-server-attestation-${{ github.ref_name }}.jar.asc" + curl -X POST -H "Authorization: Bearer ${{ github.token }}" -H 'Content-Type: text/plain' --data-binary @webauthn-server-core-${{ github.ref_name }}.jar.asc "${UPLOAD_URL}?name=webauthn-server-core-${{ github.ref_name }}.jar.asc" diff --git a/NEWS b/NEWS index 57dea19b5..530d4727a 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,65 @@ +== Version 2.1.0 == + +`webauthn-server-core`: + +Changes: + +* Log messages on attestation certificate path validation failure now include + the attestation object. + +Deprecations: + +* Deprecated method `AssertionResult.getCredentialId(): ByteArray`. Use + `.getCredential().getCredentialId()` instead. +* Deprecated method `AssertionResult.getUserHandle(): ByteArray`. Use + `.getCredential().getUserHandle()` instead. + +New features: + +* Added method `FidoMetadataDownloader.refreshBlob()`. +* Added function `COSEAlgorithmIdentifier.fromPublicKey(ByteArray)`. +* Added method `AssertionResult.getCredential(): RegisteredCredential`. +* Added support for the `"tpm"` attestation statement format. +* Added support for ES384 and ES512 signature algorithms. +* Added property `policyTreeValidator` to `TrustRootsResult`. If set, the given + predicate function will be used to validate the certificate policy tree after + successful attestation certificate path validation. This may be required for + some JCA providers to accept attestation certificates with critical + certificate policy extensions. See the JavaDoc for + `TrustRootsResultBuilder.policyTreeValidator(Predicate)` for more information. +* Added enum value `AttestationConveyancePreference.ENTERPRISE`. +* (Experimental) Added constant `AuthenticatorTransport.HYBRID`. + +Fixes: + +* Fixed various typos and mistakes in JavaDocs. +* Moved version constraints for test dependencies from meta-module + `webauthn-server-parent` to unpublished test meta-module. +* `yubico-util` dependency removed from downstream compile scope. +* Fixed missing JavaDoc on `TrustRootsResult` getters and builder setters. + + +`webauthn-server-attestation`: + +Changes: + +* The `AuthenticatorToBeFiltered` argument of the `FidoMetadataService` runtime + filter now omits zero AAGUIDs. +* Promoted log messages in `FidoMetadataDownloader` about BLOB signature failure + and cache corruption from DEBUG level to WARN level. + +Fixes: + +* Fixed various typos and mistakes in JavaDocs. +* `FidoMetadataDownloader` now verifies the SHA-256 hash of the cached trust + root certificate, as promised in the JavaDoc of `useTrustRootCacheFile` and + `useTrustRootCache`. +* BouncyCastle dependency dropped. +* Guava dependency dropped (but still remains in core module). +* If BLOB download fails, `FidoMetadataDownloader` now correctly falls back to + cache if available. + + == Version 2.0.0 == This release removes deprecated APIs and changes some defaults to better align diff --git a/README b/README index ccb5cdb7c..308e23953 100644 --- a/README +++ b/README @@ -5,7 +5,8 @@ java-webauthn-server :toc-title: image:https://github.com/Yubico/java-webauthn-server/workflows/build/badge.svg["Build Status", link="https://github.com/Yubico/java-webauthn-server/actions"] -image:https://coveralls.io/repos/github/Yubico/java-webauthn-server/badge.svg["Coverage Status", link="https://coveralls.io/github/Yubico/java-webauthn-server"] +image:https://img.shields.io/endpoint?url=https%3A%2F%2FYubico.github.io%2Fjava-webauthn-server%2Fcoverage-badge.json["Mutation test coverage", link="https://Yubico.github.io/java-webauthn-server/"] +image:https://github.com/Yubico/java-webauthn-server/actions/workflows/release-verify-signatures.yml/badge.svg["Binary reproducibility", link="https://github.com/Yubico/java-webauthn-server/actions/workflows/release-verify-signatures.yml"] Server-side https://www.w3.org/TR/webauthn/[Web Authentication] library for Java. Provides implementations of the @@ -38,7 +39,7 @@ Maven: com.yubico webauthn-server-core - 2.0.0 + 2.1.0 compile ---------- @@ -46,7 +47,7 @@ Maven: Gradle: ---------- -compile 'com.yubico:webauthn-server-core:2.0.0' +compile 'com.yubico:webauthn-server-core:2.1.0' ---------- NOTE: You may need additional dependencies with JCA providers to support some signature algorithms. @@ -59,7 +60,8 @@ The library will log warnings if you try to configure it for algorithms with no This library uses link:https://semver.org/[semantic versioning]. The public API consists of all public classes, methods and fields in the `com.yubico.webauthn` package and its subpackages, i.e., everything covered by the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/package-summary.html[Javadoc]. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/package-summary.html[Javadoc], +*with the exception* of things annotated with `@Deprecated`. Package-private classes and methods are NOT part of the public API. The `com.yubico:yubico-util` module is NOT part of the public API. @@ -70,7 +72,7 @@ Breaking changes to these will NOT be reflected in version numbers. In addition to the main `webauthn-server-core` module, there is also: -- `webauthn-server-attestation`: Integration with the https://fidoalliance.org/metadata/[FIDO Metadata Service] +- link:webauthn-server-attestation[`webauthn-server-attestation`]: Integration with the https://fidoalliance.org/metadata/[FIDO Metadata Service] for retrieving and selecting trust roots to use for verifying https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-attestation[attestation statements]. @@ -101,7 +103,7 @@ but the authentication mechanism alone does not make a security system. == Documentation See the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/package-summary.html[Javadoc] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/package-summary.html[Javadoc] for in-depth API documentation. @@ -115,10 +117,22 @@ See link:doc/Migrating_from_v1.adoc[the migration guide]. Using this library comes in two parts: the server side and the client side. The server side involves: - 1. Implement the `CredentialRepository` interface with your database access logic. - 2. Instantiate the `RelyingParty` class. - 3. Use the `RelyingParty.startRegistration(...)` and `RelyingParty.fininshRegistration(...)` methods to perform registration ceremonies. - 4. Use the `RelyingParty.startAssertion(...)` and `RelyingParty.fininshAssertion(...)` methods to perform authentication ceremonies. + 1. Implement the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] + interface with your database access logic. + 2. Instantiate the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + class. + 3. Use the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#startRegistration(com.yubico.webauthn.StartRegistrationOptions)[`RelyingParty.startRegistration(...)`] + and + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.fininshRegistration(...)`] + methods to perform registration ceremonies. + 4. Use the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#startAssertion(com.yubico.webauthn.StartAssertionOptions)[`RelyingParty.startAssertion(...)`] + and + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.fininshAssertion(...)`] + methods to perform authentication ceremonies. 5. Use the outputs of `finishRegistration` and `finishAssertion` to update your database, initiate sessions, etc. The client side involves: @@ -136,18 +150,22 @@ link:webauthn-server-demo[`webauthn-server-demo`] for a complete demo server. === 1. Implement a `CredentialRepository` -The link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] interface -abstracts your database in a database-agnostic way. +The +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] +interface abstracts your database in a database-agnostic way. The concrete implementation will be different for every project, but you can use -link:https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[`InMemoryRegistrationStorage`] +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[`InMemoryRegistrationStorage`] as a simple example. === 2. Instantiate a `RelyingParty` -The link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] class -is the main entry point to the library. +The +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] +class is the main entry point to the library. You can instantiate it using its builder methods, -passing in your `CredentialRepository` implementation (called `MyCredentialRepository` here) as an argument: +passing in your +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] +implementation (called `MyCredentialRepository` here) as an argument: [source,java] ---------- @@ -167,14 +185,16 @@ RelyingParty rp = RelyingParty.builder() A registration ceremony consists of 5 main steps: - 1. Generate registration parameters using `RelyingParty.startRegistration(...)`. + 1. Generate registration parameters using + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#startRegistration(com.yubico.webauthn.StartRegistrationOptions)[`RelyingParty.startRegistration(...)`]. 2. Send registration parameters to the client and call https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create[`navigator.credentials.create()`]. 3. With `cred` as the result of the successfully resolved promise, call https://www.w3.org/TR/webauthn-2/#ref-for-dom-publickeycredential-getclientextensionresults[`cred.getClientExtensionResults()`] and https://www.w3.org/TR/webauthn-2/#ref-for-dom-authenticatorattestationresponse-gettransports[`cred.response.getTransports()`] and return their results along with `cred` to the server. - 4. Validate the response using `RelyingParty.finishRegistration(...)`. + 4. Validate the response using + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.fininshRegistration(...)`]. 5. Update your database using the `finishRegistration` output. This example uses GitHub's link:https://github.com/github/webauthn-json[webauthn-json] library to do both (2) and (3) in one function call. @@ -205,8 +225,12 @@ String credentialCreateJson = request.toCredentialsCreateJson(); return credentialCreateJson; // Send to client ---------- -You will need to keep this `PublicKeyCredentialCreationOptions` object in temporary storage -so you can also pass it into `finishRegistration(...)` later. +You will need to keep this +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.html[`PublicKeyCredentialCreationOptions`] +object in temporary storage +so you can also pass it into +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.fininshRegistration(...)`] +later. Now call the WebAuthn API on the client side: @@ -262,14 +286,16 @@ storeCredential( // Some database access method of your own design Like registration ceremonies, an authentication ceremony consists of 5 main steps: - 1. Generate authentication parameters using `RelyingParty.startAssertion(...)`. + 1. Generate authentication parameters using + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#startAssertion(com.yubico.webauthn.StartAssertionOptions)[`RelyingParty.startAssertion(...)`]. 2. Send authentication parameters to the client, call https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get[`navigator.credentials.get()`] and return the response. 3. With `cred` as the result of the successfully resolved promise, call https://www.w3.org/TR/webauthn-2/#ref-for-dom-publickeycredential-getclientextensionresults[`cred.getClientExtensionResults()`] and return the result along with `cred` to the server. - 4. Validate the response using `RelyingParty.finishAssertion(...)`. + 4. Validate the response using + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.fininshAssertion(...)`]. 5. Update your database using the `finishAssertion` output, and act upon the result (for example, grant login access). This example uses GitHub's link:https://github.com/github/webauthn-json[webauthn-json] library to do both (2) and (3) in one function call. @@ -285,8 +311,12 @@ String credentialGetJson = request.toCredentialsGetJson(); return credentialGetJson; // Send to client ---------- -Again, you will need to keep this `PublicKeyCredentialRequestOptions` object in temporary storage -so you can also pass it into `finishAssertion(...)` later. +Again, you will need to keep this +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/AssertionRequest.html[`AssertionRequest`] +object in temporary storage +so you can also pass it into +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.fininshAssertion(...)`] +later. Now call the WebAuthn API on the client side: @@ -325,7 +355,8 @@ try { throw new RuntimeException("Authentication failed"); ---------- -Finally, if the previous step was successful, update your database using the `AssertionResult`. +Finally, if the previous step was successful, update your database using the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/AssertionResult.html[`AssertionResult`]. Most importantly, you should update the signature counter. That might look something like this: [source,java] @@ -388,9 +419,11 @@ AssertionRequest request = rp.startAssertion(StartAssertionOptions.builder() .build()); ---------- -Then `RelyingParty.finishAssertion(...)` will enforce that user verification was -performed. However, there is no guarantee that the user's authenticator will -support this unless the user has some credential created with the +Then +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.fininshAssertion(...)`] +will enforce that user verification was performed. +However, there is no guarantee that the user's authenticator will support this +unless the user has some credential created with the link:https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialcreationoptions-authenticatorselection[`authenticatorSelection`].link:https://www.w3.org/TR/webauthn-2/#dom-authenticatorselectioncriteria-userverification[`userVerification`] option set: @@ -419,13 +452,15 @@ To migrate to using the WebAuthn API, you need to do the following: 1. Follow the link:#getting-started[Getting started] guide above to set up WebAuthn support in general. + -Note that unlike a U2F AppID, the WebAuthn link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/data/RelyingPartyIdentity.RelyingPartyIdentityBuilder.html#id(java.lang.String)[RP ID] +Note that unlike a U2F AppID, the WebAuthn link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/RelyingPartyIdentity.RelyingPartyIdentityBuilder.html#id(java.lang.String)[RP ID] consists of only the domain name of the AppID. WebAuthn does not support link:https://fidoalliance.org/specs/fido-u2f-v1.2-ps-20170411/fido-appid-and-facets-v1.2-ps-20170411.html[U2F Trusted Facet Lists]. 2. Set the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#appId(com.yubico.webauthn.extension.appid.AppId)[`appId()`] - setting on your `RelyingParty` instance. + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#appId(com.yubico.webauthn.extension.appid.AppId)[`appId()`] + setting on your + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + instance. The argument to the `appid()` setting should be the same as you used for the `appId` argument to the link:https://fidoalliance.org/specs/fido-u2f-v1.2-ps-20170411/fido-u2f-javascript-api-v1.2-ps-20170411.html#high-level-javascript-api[U2F `register` and `sign` functions]. + @@ -441,19 +476,23 @@ extensions and configure the `RelyingParty` to accept the given AppId when verif for more on this, see the link:https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-user-handle-privacy[User Handle Contents] privacy consideration. - 4. When your `CredentialRepository` creates a `RegisteredCredential` for a U2F credential, + 4. When your + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] + creates a + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegisteredCredential.html[`RegisteredCredential`] + for a U2F credential, use the U2F key handle as the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RegisteredCredential.RegisteredCredentialBuilder.html#credentialId(com.yubico.webauthn.data.ByteArray)[credential ID]. + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegisteredCredential.RegisteredCredentialBuilder.html#credentialId(com.yubico.webauthn.data.ByteArray)[credential ID]. If you store key handles base64 encoded, you should decode them using - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/data/ByteArray.html#fromBase64(java.lang.String)[`ByteArray.fromBase64`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/ByteArray.html#fromBase64(java.lang.String)[`ByteArray.fromBase64`] or - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/data/ByteArray.html#fromBase64Url(java.lang.String)[`ByteArray.fromBase64Url`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/ByteArray.html#fromBase64Url(java.lang.String)[`ByteArray.fromBase64Url`] as appropriate before passing them to the `RegisteredCredential`. 5. When your `CredentialRepository` creates a `RegisteredCredential` for a U2F credential, use the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RegisteredCredential.RegisteredCredentialBuilder.html#publicKeyEs256Raw(com.yubico.webauthn.data.ByteArray)[`publicKeyEs256Raw()`] - method instead of link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RegisteredCredential.RegisteredCredentialBuilder.html#publicKeyCose(com.yubico.webauthn.data.ByteArray)[`publicKeyCose()`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegisteredCredential.RegisteredCredentialBuilder.html#publicKeyEs256Raw(com.yubico.webauthn.data.ByteArray)[`publicKeyEs256Raw()`] + method instead of link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegisteredCredential.RegisteredCredentialBuilder.html#publicKeyCose(com.yubico.webauthn.data.ByteArray)[`publicKeyCose()`] to set the credential public key. 6. Replace calls to the U2F @@ -478,14 +517,14 @@ effects, and does not directly interact with any database. This means it is database agnostic and thread safe. The following diagram illustrates an example architecture for an application using the library. -image::https://raw.githubusercontent.com/Yubico/java-webauthn-server/master/docs/img/demo-architecture.svg?sanitize=true["Example application architecture",align="center"] +image::https://raw.githubusercontent.com/Yubico/java-webauthn-server/main/docs/img/demo-architecture.svg?sanitize=true["Example application architecture",align="center"] The application manages all state and database access, and communicates with the library via POJO representations of requests and responses. The following diagram illustrates the data flow during a WebAuthn registration or authentication ceremony. -image::https://raw.githubusercontent.com/Yubico/java-webauthn-server/master/docs/img/demo-sequence-diagram.svg?sanitize=true["WebAuthn ceremony sequence diagram",align="center"] +image::https://raw.githubusercontent.com/Yubico/java-webauthn-server/main/docs/img/demo-sequence-diagram.svg?sanitize=true["WebAuthn ceremony sequence diagram",align="center"] In this diagram, the *Client* is the user's browser and the application's client-side scripts. The *Server* is the application and its business logic, the @@ -586,6 +625,21 @@ The link:webauthn-server-attestation[`webauthn-server-attestation` module] provides optional additional features for working with attestation. See the module documentation for more details. +Alternatively, you can use the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/attestation/AttestationTrustSource.html[`AttestationTrustSource`] +interface to implement your own source of attestation root certificates +and set it as the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#attestationTrustSource(com.yubico.webauthn.attestation.AttestationTrustSource)[`attestationTrustSource`] +for your +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] +instance. +Note that depending on your JCA provider configuration, you may need to set the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/attestation/AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder.html#enableRevocationChecking(boolean)[`enableRevocationChecking`] +and/or +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/attestation/AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder.html#policyTreeValidator(java.util.function.Predicate)[`policyTreeValidator`] +settings for compatibility with some authenticators' attestation certificates. +See the JavaDoc for these settings for more information. + == Building diff --git a/build.gradle b/build.gradle index fd0009c32..ff23a4ccb 100644 --- a/build.gradle +++ b/build.gradle @@ -4,19 +4,21 @@ buildscript { } dependencies { classpath 'com.cinnober.gradle:semver-git:2.5.0' - classpath 'com.diffplug.spotless:spotless-plugin-gradle:6.5.1' + classpath 'com.diffplug.spotless:spotless-plugin-gradle:6.11.0' classpath 'io.github.cosmicsilence:gradle-scalafix:0.1.13' } } plugins { id 'java-platform' - id 'com.github.kt3k.coveralls' version '2.12.0' + id 'maven-publish' + id 'signing' id 'io.github.gradle-nexus.publish-plugin' version '1.1.0' id 'io.franzbecker.gradle-lombok' version '5.0.0' } import io.franzbecker.gradle.lombok.LombokPlugin import io.franzbecker.gradle.lombok.task.DelombokTask +import com.yubico.gradle.GitUtils rootProject.description = "Metadata root for the com.yubico:webauthn-server-* module family" @@ -40,7 +42,7 @@ if (publishEnabled) { } wrapper { - gradleVersion = '7.3' + gradleVersion = '7.5.1' } dependencies { @@ -50,30 +52,10 @@ dependencies { api('com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:[2.13.2,3)') api('com.fasterxml.jackson.datatype:jackson-datatype-jdk8:[2.13.2,3)') api('com.fasterxml.jackson.datatype:jackson-datatype-jsr310:[2.13.2,3)') - api('com.fasterxml.jackson:jackson-bom') { - version { - strictly '[2.13.2.1,3)' - reject '2.13.2.1' - } - because 'jackson-databind 2.13.2.1 references nonexistent BOM' - } - api('com.google.guava:guava:[24.1.1,31)') + api('com.google.guava:guava:[24.1.1,32)') api('com.upokecenter:cbor:[4.5.1,5)') - api('javax.ws.rs:javax.ws.rs-api:[2.1,3)') - api('javax.xml.bind:jaxb-api:[2.3.0,3)') - api('junit:junit:[4.12,5)') - api('org.apache.httpcomponents:httpclient:[4.5.2,5)') - api('org.bouncycastle:bcpkix-jdk15on:[1.62,2)') - api('org.bouncycastle:bcprov-jdk15on:[1.62,2)') - api('org.eclipse.jetty:jetty-servlet:[9.4.9.v20180320,10)') - api('org.glassfish.jersey.containers:jersey-container-servlet-core:[2.33,3)') - api('org.glassfish.jersey.containers:jersey-container-servlet:[2.33,3)') - api('org.glassfish.jersey.inject:jersey-hk2:[2.33,3)') - api('org.mockito:mockito-core:[2.27.0,3)') - api('org.scalacheck:scalacheck_2.13:[1.14.0,2)') - api('org.scalatest:scalatest_2.13:[3.0.8,3.1)') - api('org.slf4j:slf4j-api:[1.7.25,2)') - api('uk.org.lidalia:slf4j-test:[1.1.0,2)') + api('org.apache.httpcomponents.client5:httpclient5:[5.0.0,6)') + api('org.slf4j:slf4j-api:[1.7.25,3)') } } @@ -82,7 +64,6 @@ allprojects { ext.dirtyMarker = "-DIRTY" apply plugin: 'com.cinnober.gradle.semver-git' - apply plugin: 'com.diffplug.spotless' apply plugin: 'idea' group = 'com.yubico' @@ -97,8 +78,8 @@ subprojects { apply plugin: LombokPlugin lombok { - version '1.18.20' - sha256 = 'ce947be6c2fbe759fbbe8ef3b42b6825f814c98c8853f1013f2d9630cedf74b0' + version '1.18.24' + sha256 = 'd3584bc2db03f059f984fb0a9c119aac1fa0da578a448e69fc3f68b36584c749' } tasks.withType(AbstractCompile) { if (tasks.findByName('verifyLombok')) { @@ -111,12 +92,15 @@ subprojects { mavenCentral() } - spotless { - java { - googleJavaFormat() - } - scala { - scalafmt('2.6.3').configFile(rootProject.file('scalafmt.conf')) + if (project !== project(':test-platform')) { + apply plugin: 'com.diffplug.spotless' + spotless { + java { + googleJavaFormat() + } + scala { + scalafmt('2.6.3').configFile(rootProject.file('scalafmt.conf')) + } } } } @@ -132,17 +116,12 @@ task assembleJavadoc(type: Sync) { destinationDir = file("${rootProject.buildDir}/javadoc") } -String getGitCommit() { - def proc = "git rev-parse HEAD".execute(null, projectDir) - proc.waitFor() - if (proc.exitValue() != 0) { - return null +task checkJavaVersionBeforeRelease { + doFirst { + if (JavaVersion.current() != JavaVersion.VERSION_17) { + throw new RuntimeException('Release must be built using JDK 17. Current JDK version: ' + JavaVersion.current()) + } } - return proc.text.trim() -} - -String getGitCommitOrUnknown() { - return getGitCommit() ?: 'UNKNOWN' } subprojects { project -> @@ -177,16 +156,19 @@ subprojects { project -> reproducibleFileOrder = true } - tasks.withType(Sign) { - it.dependsOn check - } - tasks.withType(AbstractTestTask) { testLogging { showStandardStreams = isCiBuild } } + tasks.withType(AbstractCompile) { shouldRunAfter checkJavaVersionBeforeRelease } + tasks.withType(AbstractTestTask) { shouldRunAfter checkJavaVersionBeforeRelease } + tasks.withType(Sign) { + it.dependsOn check + dependsOn checkJavaVersionBeforeRelease + } + if (project.hasProperty('publishMe') && project.publishMe) { task sourcesJar(type: Jar) { archiveClassifier = 'sources' @@ -223,8 +205,8 @@ subprojects { project -> if (project.hasProperty('publishMe') && project.publishMe) { - if (getGitCommit() == null) { - throw new RuntimeException("Failed to get git commit ID"); + if (GitUtils.getGitCommit(projectDir) == null) { + throw new RuntimeException("Failed to get git commit ID") } publishing { @@ -278,9 +260,6 @@ subprojects { project -> // The root project has no sources, but the dependency platform also needs to be published as an artifact // See https://docs.gradle.org/current/userguide/java_platform_plugin.html // See https://github.com/Yubico/java-webauthn-server/issues/93#issuecomment-822806951 -apply plugin: 'maven-publish' -apply plugin: 'signing' - publishing { publications { jars(MavenPublication) { @@ -325,10 +304,3 @@ if (publishEnabled) { } task pitestMerge(type: com.yubico.gradle.pitest.tasks.PitestMergeTask) - -coveralls { - sourceDirs = subprojects.findAll({ project.hasProperty('sourceSets') }).sourceSets.main.allSource.srcDirs.flatten() -} -tasks.coveralls { - inputs.files pitestMerge.outputs.files -} diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle deleted file mode 100644 index 24de977c2..000000000 --- a/buildSrc/build.gradle +++ /dev/null @@ -1,12 +0,0 @@ -apply plugin: 'groovy' - -repositories { - mavenCentral() -} - -dependencies { - implementation( - 'commons-io:commons-io:2.5', - 'info.solidsoft.gradle.pitest:gradle-pitest-plugin:1.5.1', - ) -} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 000000000..00b97c10d --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + groovy +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("commons-io:commons-io:2.5") + implementation("info.solidsoft.gradle.pitest:gradle-pitest-plugin:1.5.1") +} diff --git a/buildSrc/src/main/groovy/com/yubico/gradle/GitUtils.groovy b/buildSrc/src/main/groovy/com/yubico/gradle/GitUtils.groovy new file mode 100644 index 000000000..c3c143e31 --- /dev/null +++ b/buildSrc/src/main/groovy/com/yubico/gradle/GitUtils.groovy @@ -0,0 +1,18 @@ +package com.yubico.gradle + +public class GitUtils { + + public static String getGitCommit(File projectDir) { + def proc = "git rev-parse HEAD".execute(null, projectDir) + proc.waitFor() + if (proc.exitValue() != 0) { + return null + } + return proc.text.trim() + } + + public static String getGitCommitOrUnknown(projectDir) { + return getGitCommit(projectDir) ?: 'UNKNOWN' + } + +} diff --git a/buildSrc/src/main/groovy/com/yubico/gradle/pitest/tasks/PitestMergeTask.groovy b/buildSrc/src/main/groovy/com/yubico/gradle/pitest/tasks/PitestMergeTask.groovy index 268a79cc1..d1ecd235c 100644 --- a/buildSrc/src/main/groovy/com/yubico/gradle/pitest/tasks/PitestMergeTask.groovy +++ b/buildSrc/src/main/groovy/com/yubico/gradle/pitest/tasks/PitestMergeTask.groovy @@ -14,7 +14,7 @@ import org.gradle.api.tasks.TaskAction class PitestMergeTask extends DefaultTask { @OutputFile - def File destinationFile = project.file("${project.buildDir}/reports/pitest/mutations.xml") + File destinationFile = project.file("${project.buildDir}/reports/pitest/mutations.xml") PitestMergeTask() { project.subprojects.each { subproject -> @@ -24,7 +24,7 @@ class PitestMergeTask extends DefaultTask { } } - def Set findMutationsXmlFiles(File f, Set found) { + Set findMutationsXmlFiles(File f, Set found) { if (f.isDirectory()) { Set result = found for (File child : f.listFiles()) { diff --git a/doc/Migrating_from_v1.adoc b/doc/Migrating_from_v1.adoc index d4cb5e6dc..d2e7af752 100644 --- a/doc/Migrating_from_v1.adoc +++ b/doc/Migrating_from_v1.adoc @@ -21,7 +21,7 @@ Here is a high-level outline of what needs to be updated: - Remove uses of removed features. - Update uses of renamed and replaced features. - Replace any implementations of `MetadataService` with - `AttestationTrustSource`. + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/attestation/AttestationTrustSource.html[`AttestationTrustSource`]. - Rename imports of classes in `com.yubico.fido.metadata`. - Update `getUserVerification()` and `getResidentKey()` calls to expect `Optional` values. @@ -41,7 +41,7 @@ Maven example: - webauthn-server-core-minimal - 1.12.2 + webauthn-server-core -+ 2.0.0 ++ 2.1.0 compile ---------- @@ -51,7 +51,7 @@ Gradle: [source,diff] ---------- -compile 'com.yubico:webauthn-server-core-minimal:1.12.2' -+compile 'com.yubico:webauthn-server-core:2.0.0' ++compile 'com.yubico:webauthn-server-core:2.1.0' ---------- @@ -84,7 +84,8 @@ Gradle: implementation 'org.bouncycastle:bcprov-jdk15on:1.70' ---------- -Then set up the provider. This should be done before instantiating `RelyingParty`. +Then set up the provider. This should be done before instantiating +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`]. Example: @@ -100,7 +101,10 @@ Security.addProvider(new BouncyCastleProvider()); Several fields, methods and settings have been removed: -- The `icon` field in `RelyingPartyIdentity` and `UserIdentity`, +- The `icon` field in + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/RelyingPartyIdentity.html[`RelyingPartyIdentity`] + and + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/UserIdentity.html[`UserIdentity`], and its associated methods. They were removed in WebAuthn Level 2 and have no replacement. + @@ -122,7 +126,8 @@ Example: .build(); ---------- -- The setting `allowUnrequestedExtensions(boolean)` in `RelyingParty`. +- The setting `allowUnrequestedExtensions(boolean)` in + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`]. + WebAuthn Level 2 now recommends that unrequested extensions should be allowed, so this setting has been removed and is now always enabled. @@ -201,9 +206,13 @@ Example: == Update uses of renamed and replaced features -- Methods `requireResidentKey(boolean)` and `isRequireResidentKey()` - in `AuthenticatorSelectionCriteria` have been replaced - by `residentKey(ResidentKeyRequirement)` and `getResidentKey()`, respectively. +- Methods `requireResidentKey(boolean)` and `isRequireResidentKey()` in + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.html[`AuthenticatorSelectionCriteria`] + have been replaced by + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder.html#residentKey(com.yubico.webauthn.data.ResidentKeyRequirement)[`residentKey(ResidentKeyRequirement)`] + and + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.html#getResidentKey()[`getResidentKey()`], + respectively. + Replace `requireResidentKey(false)` with `residentKey(ResidentKeyRequirement.DISCOURAGED)`. @@ -252,16 +261,18 @@ Example: == Replace implementations of `MetadataService` -The `MetadataService` interface has been replaced with `AttestationTrustSource`. +The `MetadataService` interface has been replaced with +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/attestation/AttestationTrustSource.html[`AttestationTrustSource`]. The new interface has some key differences: - `MetadataService` implementations were expected to validate the attestation certificate path. `AttestationTrustSource` implementations are not; instead they only need to retrieve the trust root certificates. - The `RelyingParty.finishRegistration` method will perform - certificate path validation internally - and report the result via `RegistrationResult.isAttestationTrusted()`. + The + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration`] + method will perform certificate path validation internally and report the result via + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegistrationResult.html#isAttestationTrusted()[`RegistrationResult.isAttestationTrusted()`]. The `AttestationTrustSource` may also return a `CertStore` of untrusted certificates and CRLs that may be needed for certificate path validation, @@ -274,8 +285,12 @@ The new interface has some key differences: for accessing attestation metadata, but `RelyingParty` will not integrate them in the core result types. -See the JavaDoc for `AttestationTrustSource` for details on how to implement it, -and see the `FidoMetadataService` class in the +See the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/attestation/AttestationTrustSource.html[JavaDoc +for `AttestationTrustSource`] for details on how to implement it, +and see the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +class in the link:../webauthn-server-attestation[`webauthn-server-attestation` module] for a reference implementation. @@ -308,15 +323,17 @@ link:https://github.com/w3c/webauthn/issues/1253[turned out to cause confusion]. Therefore, browsers have started issuing console warnings when `userVerification` is not set explicitly. This library has mirrored the defaults for -`PublicKeyCredentialRequestOptions.userVerification` and -`AuthenticatorSelectionCriteria.userVerification`, +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.PublicKeyCredentialRequestOptionsBuilder.html#userVerification(com.yubico.webauthn.data.UserVerificationRequirement)[`PublicKeyCredentialRequestOptions.userVerification`] +and +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder.html#userVerification(com.yubico.webauthn.data.UserVerificationRequirement)[`AuthenticatorSelectionCriteria.userVerification`], but this inadvertently suppresses any browser console warnings since the library emits parameter objects with an explicit value set, even if the value was not explicitly set at the library level. The defaults have therefore been removed, and the corresponding getters now return `Optional` values. For consistency, the same change applies to -`AuthenticatorSelectionCriteria.residentKey` as well. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder.html#residentKey(com.yubico.webauthn.data.ResidentKeyRequirement)[`AuthenticatorSelectionCriteria.residentKey`] +as well. The setters for these settings remain unchanged, but if you use the getters you need to expect `Optional` values instead. diff --git a/doc/releasing.md b/doc/releasing.md index 0394948ba..4d44edf84 100644 --- a/doc/releasing.md +++ b/doc/releasing.md @@ -6,15 +6,13 @@ Release candidate versions 1. Make sure release notes in `NEWS` are up to date. - 2. Make sure you're running Gradle in JDK 11. - - 3. Run the tests one more time: + 2. Run the tests one more time: ``` $ ./gradlew clean check ``` - 4. Tag the head commit with an `X.Y.Z-RCN` tag: + 3. Tag the head commit with an `X.Y.Z-RCN` tag: ``` $ git tag -a -s 1.4.0-RC1 -m "Pre-release 1.4.0-RC1" @@ -22,19 +20,13 @@ Release candidate versions No tag body needed. - 5. Publish to Sonatype Nexus: + 4. Publish to Sonatype Nexus: ``` $ ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository ``` - 6. Wait for the artifacts to become downloadable at - https://repo1.maven.org/maven2/com/yubico/webauthn-server-core/ . This is - needed for one of the GitHub Actions release workflows and usually takes - less than 30 minutes (long before the artifacts become searchable on the - main Maven Central website). - - 7. Push to GitHub. + 5. Push to GitHub. If the pre-release makes significant changes to the project README, such that the README does not accurately reflect the latest non-pre-release @@ -46,40 +38,38 @@ Release candidate versions ``` If the README still accurately reflects the latest non-pre-release version, - you can simply push to master instead: + you can simply push to main instead: ``` - $ git push origin master 1.4.0-RC1 + $ git push origin main 1.4.0-RC1 ``` - 8. Make GitHub release. + 6. Make GitHub release. - Use the new tag as the release tag - Check the pre-release checkbox - Copy the release notes from `NEWS` into the GitHub release notes; reformat from ASCIIdoc to Markdown and remove line wraps. Include only changes/additions since the previous release or pre-release. - - Attach the signature files from - `webauthn-server-attestation/build/libs/webauthn-server-attestation-X.Y.Z-RCN.jar.asc` - and - `webauthn-server-core/build/libs/webauthn-server-core-X.Y.Z-RCN.jar.asc`. - Note which JDK version was used to build the artifacts. + 7. Check that the ["Reproducible binary" + workflow](/Yubico/java-webauthn-server/actions/workflows/release-verify-signatures.yml) + runs and succeeds. + Release versions --- 1. Make sure release notes in `NEWS` are up to date. - 2. Make sure you're running Gradle in JDK 11. - - 3. Make a no-fast-forward merge from the last (non release candidate) release + 2. Make a no-fast-forward merge from the last (non release candidate) release to the commit to be released: ``` $ git checkout 1.3.0 $ git checkout -b release-1.4.0 - $ git merge --no-ff master + $ git merge --no-ff main ``` Copy the release notes for this version from `NEWS` into the merge commit @@ -90,14 +80,16 @@ Release versions commits for examples. ``` - $ git checkout master + $ git checkout main $ git merge --ff-only release-1.4.0 $ git branch -d release-1.4.0 ``` - 4. Remove the "(unreleased)" tag from `NEWS`. + 3. Remove the "(unreleased)" tag from `NEWS`. - 5. Update the version in the dependency snippets in the README. + 4. Update the version in the dependency snippets in the README. + + 5. Update the version in JavaDoc links in the READMEs. 6. Amend these changes into the merge commit: @@ -126,27 +118,20 @@ Release versions $ ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository ``` -10. Wait for the artifacts to become downloadable at - https://repo1.maven.org/maven2/com/yubico/webauthn-server-core/ . This is - needed for one of the GitHub Actions release workflows and usually takes - less than 30 minutes (long before the artifacts become searchable on the - main Maven Central website). - -11. Push to GitHub: +10. Push to GitHub: ``` - $ git push origin master 1.4.0 + $ git push origin main 1.4.0 ``` -12. Make GitHub release. +11. Make GitHub release. - Use the new tag as the release tag - Copy the release notes from `NEWS` into the GitHub release notes; reformat from ASCIIdoc to Markdown and remove line wraps. Include all changes since the previous release (not just changes since the previous pre-release). - - Attach the signature files from - `webauthn-server-attestation/build/libs/webauthn-server-attestation-X.Y.Z.jar.asc` - and - `webauthn-server-core/build/libs/webauthn-server-core-X.Y.Z.jar.asc`. - - Note which JDK version was used to build the artifacts. + +12. Check that the ["Reproducible binary" + workflow](/Yubico/java-webauthn-server/actions/workflows/release-verify-signatures.yml) + runs and succeeds. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180f2..249e5832f 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e750102e0..ae04661ee 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c78733..a69d9cb6c 100755 --- a/gradlew +++ b/gradlew @@ -205,6 +205,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index 107acd32c..f127cfd49 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index efdcc3775..000000000 --- a/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -rootProject.name = 'webauthn-server-parent' -include ':webauthn-server-attestation' -include ':webauthn-server-core' -include ':webauthn-server-demo' -include ':yubico-util' -include ':yubico-util-scala' - -include ':test-dependent-projects:java-dep-webauthn-server-attestation' -include ':test-dependent-projects:java-dep-webauthn-server-core' -include ':test-dependent-projects:java-dep-webauthn-server-core-and-bouncycastle' -include ':test-dependent-projects:java-dep-yubico-util' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..9c076aa98 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,12 @@ +rootProject.name = "webauthn-server-parent" +include(":webauthn-server-attestation") +include(":webauthn-server-core") +include(":webauthn-server-demo") +include(":yubico-util") +include(":yubico-util-scala") + +include(":test-dependent-projects:java-dep-webauthn-server-attestation") +include(":test-dependent-projects:java-dep-webauthn-server-core") +include(":test-dependent-projects:java-dep-webauthn-server-core-and-bouncycastle") +include(":test-dependent-projects:java-dep-yubico-util") +include(":test-platform") diff --git a/test-dependent-projects/java-dep-webauthn-server-core-and-bouncycastle/build.gradle.kts b/test-dependent-projects/java-dep-webauthn-server-core-and-bouncycastle/build.gradle.kts index f558ba389..801446db1 100644 --- a/test-dependent-projects/java-dep-webauthn-server-core-and-bouncycastle/build.gradle.kts +++ b/test-dependent-projects/java-dep-webauthn-server-core-and-bouncycastle/build.gradle.kts @@ -6,7 +6,7 @@ val coreTestsOutput = project(":webauthn-server-core").extensions.getByType(Sour dependencies { implementation(project(":webauthn-server-core")) - implementation("org.bouncycastle:bcprov-jdk15on:[1.62,2)") + implementation("org.bouncycastle:bcprov-jdk18on:[1.62,2)") testImplementation(coreTestsOutput) testImplementation("junit:junit:4.12") @@ -15,8 +15,6 @@ dependencies { // Runtime-only internal dependency of webauthn-server-core testImplementation("com.augustcellars.cose:cose-java:[1.0.0,2)") - testRuntimeOnly("ch.qos.logback:logback-classic:[1.2.3,2)") - // Transitive dependencies from coreTestOutput testImplementation("org.scala-lang:scala-library:[2.13.1,3)") } diff --git a/test-platform/build.gradle.kts b/test-platform/build.gradle.kts new file mode 100644 index 000000000..d440d8d92 --- /dev/null +++ b/test-platform/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + `java-platform` +} + +description = "Dependency constraints for tests" + +dependencies { + constraints { + api("junit:junit:4.13.2") + api("org.mockito:mockito-core:4.7.0") + api("org.scalacheck:scalacheck_2.13:1.16.0") + api("org.scalatest:scalatest_2.13:3.2.13") + api("org.scalatestplus:junit-4-13_2.13:3.2.13.0") + api("org.scalatestplus:scalacheck-1-16_2.13:3.2.13.0") + api("uk.org.lidalia:slf4j-test:1.2.0") + api("org.bouncycastle:bcpkix-jdk18on:[1.62,2)") + api("org.bouncycastle:bcprov-jdk18on:[1.62,2)") + } +} diff --git a/webauthn-server-attestation/README.adoc b/webauthn-server-attestation/README.adoc index 5eee6ddc0..8a4d04d6c 100644 --- a/webauthn-server-attestation/README.adoc +++ b/webauthn-server-attestation/README.adoc @@ -19,7 +19,7 @@ This module does four things: - Re-download the metadata BLOB when out of date or invalid. - Provide utilities for selecting trusted metadata entries and authenticators. - Integrate with the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] class in the base library, to provide trust root certificates for verifying attestation statements during credential registrations. @@ -27,13 +27,23 @@ Notable *non-features* include: - *Scheduled BLOB downloads.* + -The `FidoMetadataDownloader` -class will attempt to download a new BLOB only when its `loadCachedBlob()` is executed, -and then only if the cache is empty or if the cached BLOB is invalid or out of date. -`FidoMetadataService` +The +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] +class will attempt to download a new BLOB only when its +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] +or +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#refreshBlob()[`refreshBlob()`] +method is executed. +As the names suggest, +`loadCachedBlob()` downloads a new BLOB only if the cache is empty +or the cached BLOB is invalid or out of date, +while `refreshBlob()` always downloads a new BLOB and falls back +to the cached BLOB only when the new BLOB is invalid in some way. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] will never re-download a new BLOB once instantiated. + -You should use some external scheduling mechanism to re-run `loadCachedBlob()` periodically +You should use some external scheduling mechanism to re-run `loadCachedBlob()` +and/or `refreshBlob()` periodically and rebuild new `FidoMetadataService` instances with the updated metadata contents. You can do this with minimal disruption since the `FidoMetadataService` and `RelyingParty` classes keep no internal mutable state. @@ -41,8 +51,13 @@ classes keep no internal mutable state. - *Revocation of already-registered credentials* + The FIDO Metadata Service may from time to time report security issues with particular authenticator models. -The `FidoMetadataService` class can be configured with a filter for which authenticators to trust, -and untrusted authenticators can be rejected during registration by setting `.allowUntrustedAttestation(false)` on `RelyingParty`, +The +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +class can be configured with a filter for which authenticators to trust, +and untrusted authenticators can be rejected during registration by setting +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`] +on +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`], but this will not affect any credentials already registered. @@ -69,25 +84,65 @@ but we recommend that you do not _require_ a trusted attestation unless you have See link:doc/Migrating_from_v1.adoc[the migration guide]. +== Dependency configuration + +Maven: + +---------- + + com.yubico + webauthn-server-attestation + 2.1.0 + compile + +---------- + +Gradle: + +---------- +compile 'com.yubico:webauthn-server-attestation:2.1.0' +---------- + + +=== Semantic versioning + +This library uses link:https://semver.org/[semantic versioning]. +The public API consists of all public classes, methods and fields in the `com.yubico.fido.metadata` package and its subpackages, +i.e., everything covered by the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/package-summary.html[Javadoc], +*with the exception* of things annotated with `@Deprecated`. + +Package-private classes and methods are NOT part of the public API. +The `com.yubico:yubico-util` module is NOT part of the public API. +Breaking changes to these will NOT be reflected in version numbers. + + == Getting started Using this module consists of 4 major steps: 1. Create a - `FidoMetadataDownloader` + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] instance to download and cache metadata BLOBs, and a - `FidoMetadataService` + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] instance to make use of the downloaded BLOB. See the JavaDoc for these classes for details on how to construct them. + [WARNING] ===== Unlike other classes in this module and the core library, -`FidoMetadataDownloader` is NOT THREAD SAFE since its `loadCachedBlob()` method reads and writes caches. -`FidoMetadataService`, on the other hand, is thread safe, -and `FidoMetadataDownloader` instances can be reused for subsequent `loadCachedBlob()` calls -as long as only one `loadCachedBlob()` call executes at a time. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] +is NOT THREAD SAFE since its +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] +and +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#refreshBlob()[`refreshBlob()`] +methods read and write caches. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`], +on the other hand, is thread safe, +and `FidoMetadataDownloader` instances can be reused +for subsequent `loadCachedBlob()` and `refreshBlob()` calls +as long as only one call executes at a time. ===== + [source,java] @@ -105,12 +160,20 @@ FidoMetadataService mds = FidoMetadataService.builder() .build(); ---------- - 2. Set the `FidoMetadataService` as the `attestationTrustSource` on your - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + 2. Set the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + as the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#attestationTrustSource(com.yubico.webauthn.attestation.AttestationTrustSource)[`attestationTrustSource`] + on your + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] instance, - and set `attestationConveyancePreference(AttestationConveyancePreference.DIRECT)` on `RelyingParty` + and set + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#attestationConveyancePreference(com.yubico.webauthn.data.AttestationConveyancePreference)[`attestationConveyancePreference(AttestationConveyancePreference.DIRECT)`] + on `RelyingParty` to request an attestation statement for new registrations. - Optionally also set `.allowUntrustedAttestation(false)` on `RelyingParty` to require trusted attestation for new registrations. + Optionally also set + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`] + on `RelyingParty` to require trusted attestation for new registrations. + [source,java] ---------- @@ -123,8 +186,10 @@ RelyingParty rp = RelyingParty.builder() .build(); ---------- - 3. After performing registrations, inspect the `isAttestationTrusted()` result in - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + 3. After performing registrations, inspect the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegistrationResult.html#isAttestationTrusted()[`isAttestationTrusted()`] + result in + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`] to determine whether the authenticator presented an attestation statement that could be verified by any of the trusted attestation certificates in the FIDO Metadata Service. + @@ -140,7 +205,9 @@ if (result.isAttestationTrusted()) { } ---------- - 4. If needed, use the `findEntries` methods of `FidoMetadataService` to retrieve additional authenticator metadata for new registrations. + 4. If needed, use the `findEntries` methods of + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + to retrieve additional authenticator metadata for new registrations. + [source,java] ---------- @@ -150,7 +217,9 @@ RegistrationResult result = rp.finishRegistration(/* ... */); Set metadata = mds.findEntries(result); ---------- -By default, `FidoMetadataDownloader` will probably use the SUN provider for the `PKIX` certificate path validation algorithm. +By default, +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] +will probably use the SUN provider for the `PKIX` certificate path validation algorithm. This requires the `com.sun.security.enableCRLDP` system property set to `true` in order to verify the BLOB signature. For example, this can be done on the JVM command line using a `-Dcom.sun.security.enableCRLDP=true` option. See the https://docs.oracle.com/javase/9/security/java-pki-programmers-guide.htm#JSSEC-GUID-EB250086-0AC1-4D60-AE2A-FC7461374746[Java PKI Programmers Guide] @@ -160,12 +229,20 @@ for details. == Selecting trusted authenticators The -`FidoMetadataService` +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] class can be configured with filters for which authenticators to trust. -When the `FidoMetadataService` is used as the `.attestationTrustSource()` in `RelyingParty`, -this will be reflected in the `.isAttestationTrusted()` result in -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core-minimal/latest/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`]. -Any authenticators not trusted will also be rejected for new registrations if you set `.allowUntrustedAttestation(false)` on `RelyingParty`. +When the `FidoMetadataService` is used as the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#attestationTrustSource(com.yubico.webauthn.attestation.AttestationTrustSource)[`attestationTrustSource`] +in +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`], +this will be reflected in the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegistrationResult.html#isAttestationTrusted()[`.isAttestationTrusted()`] +result in +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`]. +Any authenticators not trusted will also be rejected for new registrations +if you set +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`] +on `RelyingParty`. The filter has two stages: a "prefilter" which selects metadata entries to include in the data source, and a registration-time filter which decides whether to associate a metadata entry @@ -226,13 +303,19 @@ link:https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.h entry, and the default registration-time filter excludes any authenticator with a matching `ATTESTATION_KEY_COMPROMISE` status report entry. -To customize the filters, configure the `.prefilter(Predicate)` and `.filter(Predicate)` settings -in the `FidoMetadataService` builder. +To customize the filters, configure the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.FidoMetadataServiceBuilder.html#prefilter(java.util.function.Predicate)[`.prefilter(Predicate)`] +and +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.FidoMetadataServiceBuilder.html#filter(java.util.function.Predicate)[`.filter(Predicate)`] +settings in +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`]. The filters are predicate functions; each metadata entry will be trusted if and only if the prefilter predicate returns `true` for that entry. Similarly during registration or metadata lookup, the authenticator will be matched with each metadata entry only if the registration-time filter returns `true` for that pair of authenticator and metadata entry. -You can also use the `FidoMetadataService.Filters.allOf()` combinator to merge several predicates into one. +You can also use the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.Filters.html#allOf(java.util.function.Predicate\...)[`FidoMetadataService.Filters.allOf()`] +combinator to merge several predicates into one. [NOTE] ===== @@ -240,8 +323,11 @@ Setting a custom filter will replace the default filter. This is true for both the prefilter and the registration-time filter. If you want to maintain the default filter in addition to the new behaviour, you must include the default condition in the new filter. -For example, you can use `FidoMetadataService.Filters.allOf()` to combine a predefined filter with a custom one. -The default filters are available via static functions in `FidoMetadataService.Filters`. +For example, you can use +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.Filters.html#allOf(java.util.function.Predicate\...)[`FidoMetadataService.Filters.allOf()`] +to combine a predefined filter with a custom one. +The default filters are available via static functions in +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.Filters.html[`FidoMetadataService.Filters`]. ===== @@ -261,7 +347,10 @@ Since it will have an unknown trust root, it would then be implicitly trusted. This is why any enforceable attestation policy must disallow unknown trust roots. Note that unknown and untrusted attestation is allowed by default, -but can be disallowed by explicitly configuring `RelyingParty` with `.allowUntrustedAttestation(false)`. +but can be disallowed by explicitly configuring +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] +with +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`]. == Alignment with FIDO MDS spec @@ -270,21 +359,30 @@ The FIDO Metadata Service specification defines link:https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#metadata-blob-object-processing-rules[processing rules for servers]. The library implements these as closely as possible, but with some slight departures from the spec: -* Processing rules steps 1-7 are implemented as specified, by the `FidoMetadataDownloader` class. +* Processing rules steps 1-7 are implemented as specified, by the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] + class. All "SHOULD" clauses are also respected, with some caveats: ** Step 3 states "The `nextUpdate` field of the Metadata BLOB specifies a date when the download SHOULD occur at latest". `FidoMetadataDownloader` does not automatically re-download the BLOB. - Instead, each time its `.loadCachedBlob()` method is executed it checks whether a new BLOB should be downloaded. + Instead, each time the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] + method is executed it checks whether a new BLOB should be downloaded. + The + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#refreshBlob()[`refreshBlob()`] + method always attempts to download a new BLOB when executed, + but also does not trigger re-downloads automatically. + -If no BLOB exists in cache, or the cached BLOB is invalid, or if the current date is greater than or equal to `nextUpdate`, -then a new BLOB is downloaded. -If the new BLOB is valid, has a correct signature, and has a `no` field greater than the cached BLOB, +Whenever a newly downloaded BLOB is valid, has a correct signature, +and has a `no` field greater than the cached BLOB (if any), then the new BLOB replaces the cached one; -otherwise, the new BLOB is discarded and the cached one is kept until the next execution of `.loadCachedBlob()`. +otherwise, the new BLOB is discarded and the cached one is kept +until the next execution of `.loadCachedBlob()` or `.refreshBlob()`. * Metadata entries are not stored or cached individually, instead the BLOB is cached as a whole. - In processing rules step 8, neither `FidoMetadataDownloader` nor `FidoMetadataService` + In processing rules step 8, neither `FidoMetadataDownloader` nor + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] performs any comparison between versions of a metadata entry. Policy for ignoring metadata entries can be configured via the filter settings in `FidoMetadataService`. See above for details. @@ -295,7 +393,9 @@ There are also some other requirements throughout the spec, which may not be obv link:https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#info-statuses[AuthenticatorStatus section] states that "The Relying party MUST reject the Metadata Statement if the `authenticatorVersion` has not increased" in an `UPDATE_AVAILABLE` status report. - Thus, `FidoMetadataService` silently ignores any `MetadataBLOBPayloadEntry` + Thus, + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + silently ignores any `MetadataBLOBPayloadEntry` whose `metadataStatement.authenticatorVersion` is present and not greater than or equal to the `authenticatorVersion` in the respective status report. Again, no comparison is made between metadata entries from different BLOB versions. @@ -303,12 +403,17 @@ There are also some other requirements throughout the spec, which may not be obv * The link:https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html#info-statuses[AuthenticatorStatus section] states that "FIDO Servers MUST silently ignore all unknown AuthenticatorStatus values". - Thus any unknown status valus will be parsed as `AuthenticatorStatus.UNKNOWN`, - and `MetadataBLOBPayloadEntry` will silently ignore any status report with that status. + Thus any unknown status values will be parsed as + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/AuthenticatorStatus.html#UNKNOWN[`AuthenticatorStatus.UNKNOWN`], + and + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/MetadataBLOBPayloadEntry.html[`MetadataBLOBPayloadEntry`] + will silently ignore any status report with that status. == Overriding certificate path validation -The `FidoMetadataDownloader` class uses `CertPathValidator.getInstance("PKIX")` to retrieve a `CertPathValidator` instance. +The +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] +class uses `CertPathValidator.getInstance("PKIX")` to retrieve a `CertPathValidator` instance. If you need to override any aspect of certificate path validation, such as CRL retrieval or OCSP, you may provide a custom `CertPathValidator` provider for the `"PKIX"` algorithm. diff --git a/webauthn-server-attestation/build.gradle b/webauthn-server-attestation/build.gradle deleted file mode 100644 index 7fe728b33..000000000 --- a/webauthn-server-attestation/build.gradle +++ /dev/null @@ -1,105 +0,0 @@ -plugins { - id 'java-library' - id 'scala' - id 'maven-publish' - id 'signing' - id 'info.solidsoft.pitest' - id 'io.github.cosmicsilence.scalafix' -} - -description = 'Yubico WebAuthn attestation subsystem' - -project.ext.publishMe = true - -sourceCompatibility = 1.8 -targetCompatibility = 1.8 - -evaluationDependsOn(':webauthn-server-core') - -sourceSets { - integrationTest { - compileClasspath += sourceSets.main.output - runtimeClasspath += sourceSets.main.output - } -} - -configurations { - integrationTestImplementation.extendsFrom testImplementation - integrationTestRuntimeOnly.extendsFrom testRuntimeOnly -} - -dependencies { - api(platform(rootProject)) - - api( - project(':webauthn-server-core'), - ) - - implementation( - project(':yubico-util'), - 'com.google.guava:guava', - 'com.fasterxml.jackson.core:jackson-databind', - 'org.bouncycastle:bcprov-jdk15on', - 'org.slf4j:slf4j-api', - ) - - testImplementation( - project(':webauthn-server-core').sourceSets.test.output, - project(':yubico-util-scala'), - 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8', - 'junit:junit', - 'org.bouncycastle:bcpkix-jdk15on', - 'org.eclipse.jetty:jetty-server:[9.4.9.v20180320,10)', - 'org.mockito:mockito-core', - 'org.scala-lang:scala-library', - 'org.scalacheck:scalacheck_2.13', - 'org.scalatest:scalatest_2.13', - 'uk.org.lidalia:slf4j-test', - ) - - testImplementation('org.slf4j:slf4j-api') { - version { - strictly '[1.7.25,1.8-a)' // Pre-1.8 version required by slf4j-test - } - } -} - -tasks.register('integrationTest', Test) { - description = 'Runs integration tests.' - group = 'verification' - - testClassesDirs = sourceSets.integrationTest.output.classesDirs - classpath = sourceSets.integrationTest.runtimeClasspath - shouldRunAfter test - check.dependsOn it - - // Required for processing CRL distribution points extension - systemProperty 'com.sun.security.enableCRLDP', 'true' -} - -jar { - manifest { - attributes([ - 'Implementation-Id': 'java-webauthn-server-attestation', - 'Implementation-Title': project.description, - 'Implementation-Version': project.version, - 'Implementation-Vendor': 'Yubico', - 'Git-Commit': getGitCommitOrUnknown(), - ]) - } -} - -pitest { - pitestVersion = '1.4.11' - - timestampedReports = false - outputFormats = ['XML', 'HTML'] - - avoidCallsTo = [ - 'java.util.logging', - 'org.apache.log4j', - 'org.slf4j', - 'org.apache.commons.logging', - 'com.google.common.io.Closeables', - ] -} diff --git a/webauthn-server-attestation/build.gradle.kts b/webauthn-server-attestation/build.gradle.kts new file mode 100644 index 000000000..fd4af93dc --- /dev/null +++ b/webauthn-server-attestation/build.gradle.kts @@ -0,0 +1,104 @@ +import com.yubico.gradle.GitUtils + +plugins { + `java-library` + scala + `maven-publish` + signing + id("info.solidsoft.pitest") + id("io.github.cosmicsilence.scalafix") +} + +description = "Yubico WebAuthn attestation subsystem" + +val publishMe by extra(true) + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +sourceSets { + create("integrationTest") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + } +} + +configurations["integrationTestImplementation"].extendsFrom(configurations.testImplementation.get()) +configurations["integrationTestRuntimeOnly"].extendsFrom(configurations.testRuntimeOnly.get()) + +// Can't use test fixtures because they interfere with pitest: https://github.com/gradle/gradle/issues/12168 +evaluationDependsOn(":webauthn-server-core") +val coreTestsOutput = project(":webauthn-server-core").extensions.getByType(SourceSetContainer::class).test.get().output + +dependencies { + api(platform(rootProject)) + + api(project(":webauthn-server-core")) + + implementation(project(":yubico-util")) + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("org.slf4j:slf4j-api") + + testImplementation(platform(project(":test-platform"))) + testImplementation(coreTestsOutput) + testImplementation(project(":yubico-util-scala")) + testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8") + testImplementation("junit:junit") + testImplementation("org.bouncycastle:bcpkix-jdk18on") + testImplementation("org.eclipse.jetty:jetty-server:[9.4.9.v20180320,10)") + testImplementation("org.mockito:mockito-core") + testImplementation("org.scala-lang:scala-library") + testImplementation("org.scalacheck:scalacheck_2.13") + testImplementation("org.scalatest:scalatest_2.13") + testImplementation("org.scalatestplus:junit-4-13_2.13") + testImplementation("org.scalatestplus:scalacheck-1-16_2.13") + testImplementation("uk.org.lidalia:slf4j-test") + + testImplementation("org.slf4j:slf4j-api") { + version { + strictly("[1.7.25,1.8-a)") // Pre-1.8 version required by slf4j-test + } + } +} + +val integrationTest = task("integrationTest") { + description = "Runs integration tests." + group = "verification" + + testClassesDirs = sourceSets["integrationTest"].output.classesDirs + classpath = sourceSets["integrationTest"].runtimeClasspath + shouldRunAfter(tasks.test) + + // Required for processing CRL distribution points extension + systemProperty("com.sun.security.enableCRLDP", "true") +} +tasks["check"].dependsOn(integrationTest) + +tasks.jar { + manifest { + attributes(mapOf( + "Implementation-Id" to "java-webauthn-server-attestation", + "Implementation-Title" to project.description, + "Implementation-Version" to project.version, + "Implementation-Vendor" to "Yubico", + "Git-Commit" to GitUtils.getGitCommitOrUnknown(projectDir), + )) + } +} + +pitest { + pitestVersion.set("1.9.5") + timestampedReports.set(false) + + outputFormats.set(listOf("XML", "HTML")) + + avoidCallsTo.set(listOf( + "java.util.logging", + "org.apache.log4j", + "org.slf4j", + "org.apache.commons.logging", + "com.google.common.io.Closeables", + )) +} diff --git a/webauthn-server-attestation/doc/Migrating_from_v1.adoc b/webauthn-server-attestation/doc/Migrating_from_v1.adoc index cf0f035b7..330ebcf1e 100644 --- a/webauthn-server-attestation/doc/Migrating_from_v1.adoc +++ b/webauthn-server-attestation/doc/Migrating_from_v1.adoc @@ -11,18 +11,28 @@ link:https://github.com/Yubico/java-webauthn-server/issues/new[let us know!] Here is a high-level outline of what needs to be updated: - Replace uses of `StandardMetadataService` and its related classes - with `FidoMetadataService` and `FidoMetadataDownloader`. -- Update the name of the `RelyingParty` integration point - from `metadataService` to `attestationTrustSource`. -- `RegistrationResult` no longer includes attestation metadata, + with + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + and + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`]. +- Update the name of the + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + integration point from `metadataService` to + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#attestationTrustSource(com.yubico.webauthn.attestation.AttestationTrustSource)[`attestationTrustSource`]. +- link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`] + no longer includes attestation metadata, instead you'll need to retrieve it separately after a successful registration. -- Replace uses of the `Attestation` result type with `MetadataBLOBPayloadEntry`. +- Replace uses of the `Attestation` result type with + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/MetadataBLOBPayloadEntry.html[`MetadataBLOBPayloadEntry`]. == Replace `StandardMetadataService` `StandardMetadataService` and its constituent classes have been removed -in favour of `FidoMetadataService` and `FidoMetadataDownloader`. +in favour of +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +and +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`]. See the link:../#getting-started[Getting started] documentation for details on how to configure and construct them. @@ -49,19 +59,25 @@ FidoMetadataService metadataService = FidoMetadataService.builder() .useDefaultBlob() .useBlobCacheFile(new File("fido-mds-blob-cache.bin")) .build() - .loadBlob() + .loadCachedBlob() ) .build(); ---------- -You may also need to add external logic to occasionally re-run `loadBlob()` +You may also need to add external logic to occasionally re-run +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] +and/or +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#refreshBlob()[`refreshBlob()`] and reconstruct the `FidoMetadataService`, as `FidoMetadataService` will not automatically update the BLOB on its own. == Update `RelyingParty` integration point -`FidoMetadataService` integrates with `RelyingParty` in much the same way as `StandardMetadataService`, +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +integrates with +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] +in much the same way as `StandardMetadataService`, although the name of the setting has changed. Example `1.x` code: @@ -93,11 +109,17 @@ Example `2.0` code: == Retrieve attestation metadata separately -In `1.x`, `RegistrationResult` could include an `Attestation` object with attestation metadata, +In `1.x`, +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`] +could include an `Attestation` object with attestation metadata, if a metadata service was configured and the authenticator matched anything in the metadata service. -In order to keep `RelyingParty` and the new `AttestationTrustSource` interface -decoupled from any particular format of attestation metadata, this result field has been removed. -Instead, use the `findEntries` methods of `FidoMetadataService` +In order to keep +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] +and the new +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/attestation/AttestationTrustSource.html[`AttestationTrustSource`] +interface decoupled from any particular format of attestation metadata, this result field has been removed. +Instead, use the `findEntries` methods of +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] to retrieve attestation metadata after a successful registration, if needed. Example `1.x` code: @@ -128,7 +150,9 @@ Optional authenticatorName = mds.findEntries(result) This ties in with the previous step, and much of it will likely be done already. However if your front-end accesses and/or displays contents of an `Attestation` object, -it will need to be updated to work with `MetadataBLOBPayloadEntry` or similar types instead. +it will need to be updated to work with +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.1.0/com/yubico/fido/metadata/MetadataBLOBPayloadEntry.html[`MetadataBLOBPayloadEntry`] +or similar types instead. Example `1.x` code: diff --git a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala index 26100a559..937a0db8c 100644 --- a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala +++ b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala @@ -2,8 +2,8 @@ package com.yubico.fido.metadata import org.junit.runner.RunWith import org.scalatest.BeforeAndAfter -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatest.tags.Network import org.scalatest.tags.Slow import org.scalatestplus.junit.JUnitRunner @@ -16,7 +16,7 @@ import scala.util.Try @Network @RunWith(classOf[JUnitRunner]) class FidoMetadataDownloaderIntegrationTest - extends FunSpec + extends AnyFunSpec with Matchers with BeforeAndAfter { diff --git a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataServiceIntegrationTest.scala b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataServiceIntegrationTest.scala index 399c8ceb8..6a2782ded 100644 --- a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataServiceIntegrationTest.scala +++ b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataServiceIntegrationTest.scala @@ -2,27 +2,35 @@ package com.yubico.fido.metadata import com.fasterxml.jackson.databind.JsonNode import com.yubico.fido.metadata.AttachmentHint.ATTACHMENT_HINT_EXTERNAL +import com.yubico.fido.metadata.AttachmentHint.ATTACHMENT_HINT_INTERNAL import com.yubico.fido.metadata.AttachmentHint.ATTACHMENT_HINT_NFC import com.yubico.fido.metadata.AttachmentHint.ATTACHMENT_HINT_WIRED import com.yubico.fido.metadata.AttachmentHint.ATTACHMENT_HINT_WIRELESS import com.yubico.internal.util.CertificateParser +import com.yubico.webauthn.FinishRegistrationOptions +import com.yubico.webauthn.RelyingParty +import com.yubico.webauthn.TestWithEachProvider import com.yubico.webauthn.data.AttestationObject +import com.yubico.webauthn.test.Helpers import com.yubico.webauthn.test.RealExamples +import org.bouncycastle.jce.provider.BouncyCastleProvider import org.junit.runner.RunWith import org.scalatest.BeforeAndAfter -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatest.tags.Network import org.scalatest.tags.Slow import org.scalatestplus.junit.JUnitRunner import java.io.IOException import java.security.cert.X509Certificate +import java.time.Clock +import java.time.ZoneOffset import java.util import java.util.Optional import scala.jdk.CollectionConverters.IteratorHasAsScala +import scala.jdk.CollectionConverters.SetHasAsJava import scala.jdk.CollectionConverters.SetHasAsScala -import scala.jdk.OptionConverters.RichOption import scala.jdk.OptionConverters.RichOptional import scala.util.Try @@ -30,9 +38,10 @@ import scala.util.Try @Network @RunWith(classOf[JUnitRunner]) class FidoMetadataServiceIntegrationTest - extends FunSpec + extends AnyFunSpec with Matchers - with BeforeAndAfter { + with BeforeAndAfter + with TestWithEachProvider { describe("FidoMetadataService") { @@ -60,11 +69,7 @@ class FidoMetadataServiceIntegrationTest val attachmentHintsNfc = attachmentHintsUsb ++ Set(ATTACHMENT_HINT_WIRELESS, ATTACHMENT_HINT_NFC) - describe("by AAGUID") { - describe("correctly identifies") {} - } - - describe("correctly identifies") { + describe("correctly identifies and trusts") { def check( expectedDescriptionRegex: String, testData: RealExamples.Example, @@ -105,17 +110,38 @@ class FidoMetadataServiceIntegrationTest def getX5cArray(attestationObject: AttestationObject): JsonNode = attestationObject.getAttestationStatement.get("x5c") - val entries = fidoMds.get - .findEntries( - getAttestationTrustPath( - testData.attestation.attestationObject - ).get, - Some( - new AAGUID( - testData.attestation.attestationObject.getAuthenticatorData.getAttestedCredentialData.get.getAaguid - ) - ).toJava, + val rp = RelyingParty + .builder() + .identity(testData.rp) + .credentialRepository(Helpers.CredentialRepository.empty) + .origins( + Set(testData.attestation.collectedClientData.getOrigin).asJava + ) + .allowUntrustedAttestation(false) + .attestationTrustSource(fidoMds.get) + .clock( + Clock.fixed( + CertificateParser + .parseDer(testData.attestationCert.getBytes) + .getNotBefore + .toInstant, + ZoneOffset.UTC, + ) ) + .build() + + val registrationResult = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(testData.asRegistrationTestData.request) + .response(testData.attestation.credential) + .build() + ) + + registrationResult.isAttestationTrusted should be(true) + + val entries = fidoMds.get + .findEntries(registrationResult) .asScala entries should not be empty val metadataStatements = @@ -186,7 +212,11 @@ class FidoMetadataServiceIntegrationTest } it("a YubiKey 5Ci.") { - check("YubiKey 5Ci", RealExamples.YubiKey5Ci, attachmentHintsUsb) + check( + "YubiKey 5 .*Lightning", + RealExamples.YubiKey5Ci, + attachmentHintsUsb, + ) } ignore("a Security Key by Yubico.") { // TODO: Investigate why this fails @@ -214,16 +244,18 @@ class FidoMetadataServiceIntegrationTest } it("a YubiKey 5.4 NFC FIPS.") { - check( - "YubiKey 5 FIPS Series with NFC", - RealExamples.YubikeyFips5Nfc, - attachmentHintsNfc, - ) + withProviderContext(List(new BouncyCastleProvider)) { // Needed for JDK<14 because this example uses EdDSA + check( + "YubiKey 5 FIPS Series with NFC", + RealExamples.YubikeyFips5Nfc, + attachmentHintsNfc, + ) + } } it("a YubiKey 5.4 Ci FIPS.") { check( - "YubiKey 5Ci FIPS", + "YubiKey 5 .*FIPS .*Lightning", RealExamples.Yubikey5ciFips, attachmentHintsUsb, ) @@ -236,6 +268,14 @@ class FidoMetadataServiceIntegrationTest attachmentHintsUsb, ) } + + it("a Windows Hello attestation.") { + check( + "Windows Hello.*", + RealExamples.WindowsHelloTpm, + Set(ATTACHMENT_HINT_INTERNAL), + ) + } } } } diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AuthenticatorGetInfo.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AuthenticatorGetInfo.java index 78cefbd64..1d878b93a 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AuthenticatorGetInfo.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AuthenticatorGetInfo.java @@ -1,7 +1,6 @@ package com.yubico.fido.metadata; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; @@ -360,7 +359,7 @@ private static class SetFromIntJsonDeserializer extends JsonDeserializer> { @Override public Set deserialize(JsonParser p, DeserializationContext ctxt) - throws IOException, JacksonException { + throws IOException { final int bitset = p.getNumberValue().intValue(); return Arrays.stream(UserVerificationMethod.values()) .filter(uvm -> (uvm.getValue() & bitset) != 0) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/CtapCertificationId.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/CtapCertificationId.java index 0357fcb81..a43815bc6 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/CtapCertificationId.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/CtapCertificationId.java @@ -57,7 +57,7 @@ public enum CtapCertificationId { */ FIDO("FIDO"); - @JsonValue private String id; + @JsonValue private final String id; CtapCertificationId(String id) { this.id = id; diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/CtapPinUvAuthProtocolVersion.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/CtapPinUvAuthProtocolVersion.java index 254c2a823..99410961e 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/CtapPinUvAuthProtocolVersion.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/CtapPinUvAuthProtocolVersion.java @@ -29,7 +29,7 @@ public enum CtapPinUvAuthProtocolVersion { */ TWO(2); - @JsonValue private int value; + @JsonValue private final int value; CtapPinUvAuthProtocolVersion(int value) { this.value = value; diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java index d9261251c..fd79fb6ec 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java @@ -95,10 +95,10 @@ * *

This class is NOT THREAD SAFE since it reads and writes caches. However, it has no internal * mutable state, so instances MAY be reused in single-threaded or externally synchronized contexts. - * See also the {@link #loadCachedBlob()} method. + * See also the {@link #loadCachedBlob()} and {@link #refreshBlob()} methods. * *

Use the {@link #builder() builder} to configure settings, then use the {@link - * #loadCachedBlob()} method to load the metadata BLOB. + * #loadCachedBlob()} and {@link #refreshBlob()} methods to load the metadata BLOB. */ @Slf4j @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -411,7 +411,7 @@ public Step5 useDefaultBlob() { * nextUpdate property of the cached BLOB is the current date or earlier. * *

If the BLOB is downloaded, it is also written to the cache {@link File} or {@link - * Consumer} configured in the previous step. + * Consumer} configured in the next step. * * @param url the HTTP URL to download. It MUST use the https: scheme. */ @@ -642,25 +642,25 @@ public FidoMetadataDownloaderBuilder trustHttpsCerts(@NonNull X509Certificate... * Consumer} (see {@link FidoMetadataDownloaderBuilder.Step5}). * * - * No internal mutable state is maintained between invocations of loadBlob(); each - * invocation will reload/rewrite caches, perform downloads and check the "legalHeader" + * No internal mutable state is maintained between invocations of this method; each invocation + * will reload/rewrite caches, perform downloads and check the "legalHeader" * as necessary. You may therefore reuse a {@link FidoMetadataDownloader} instance and, - * for example, call loadBlob() periodically to refresh the BLOB when appropriate. - * Each call will return a new {@link MetadataBLOB} instance; ones already returned will not be - * updated by subsequent loadBlob() calls. + * for example, call this method periodically to refresh the BLOB when appropriate. Each call will + * return a new {@link MetadataBLOB} instance; ones already returned will not be updated by + * subsequent calls. * * @return the successfully retrieved and validated metadata BLOB. - * @throws Base64UrlException if the metadata BLOB is not a well-formed JWT in compact - * serialization. - * @throws CertPathValidatorException if the downloaded or explicitly configured BLOB fails + * @throws Base64UrlException if the explicitly configured or newly downloaded BLOB is not a + * well-formed JWT in compact serialization. + * @throws CertPathValidatorException if the explicitly configured or newly downloaded BLOB fails * certificate path validation. * @throws CertificateException if the trust root certificate was downloaded and passed the * SHA-256 integrity check, but does not contain a currently valid X.509 DER certificate; or * if the BLOB signing certificate chain fails to parse. * @throws DigestException if the trust root certificate was downloaded but failed the SHA-256 * integrity check. - * @throws FidoMetadataDownloaderException if the explicitly configured BLOB (if any) has a bad - * signature. + * @throws FidoMetadataDownloaderException if the explicitly configured or newly downloaded BLOB + * (if any) has a bad signature and there is no cached BLOB to fall back to. * @throws IOException if any of the following fails: downloading the trust root certificate, * downloading the BLOB, reading or writing any cache file (if any), or parsing the BLOB * contents. @@ -680,8 +680,173 @@ public MetadataBLOB loadCachedBlob() CertificateException, IOException, NoSuchAlgorithmException, SignatureException, InvalidKeyException, UnexpectedLegalHeader, DigestException, FidoMetadataDownloaderException { - X509Certificate trustRoot = retrieveTrustRootCert(); - return retrieveBlob(trustRoot); + final X509Certificate trustRoot = retrieveTrustRootCert(); + + final Optional explicit = loadExplicitBlobOnly(trustRoot); + if (explicit.isPresent()) { + log.debug("Explicit BLOB is set - disregarding cache and download."); + return explicit.get(); + } + + final Optional cached = loadCachedBlobOnly(trustRoot); + if (cached.isPresent()) { + log.debug("Cached BLOB exists, checking expiry date..."); + if (cached + .get() + .getPayload() + .getNextUpdate() + .atStartOfDay() + .atZone(clock.getZone()) + .isAfter(clock.instant().atZone(clock.getZone()))) { + log.debug("Cached BLOB has not yet expired - using cached BLOB."); + return cached.get(); + } else { + log.debug("Cached BLOB has expired."); + } + + } else { + log.debug("Cached BLOB does not exist or is invalid."); + } + + return refreshBlobInternal(trustRoot, cached).get(); + } + + /** + * Download and cache a fresh metadata BLOB, or read it from cache if the downloaded BLOB is not + * up to date. + * + *

This method is NOT THREAD SAFE since it reads and writes caches. + * + *

On each execution this will, in order: + * + *

    + *
  1. Download the trust root certificate, if necessary: if the cache is empty, the cache fails + * to load, or the cached cert is not valid at the current time (as determined by the {@link + * FidoMetadataDownloaderBuilder#clock(Clock) clock} setting). + *
  2. If downloaded, cache the trust root certificate using the configured {@link File} or + * {@link Consumer} (see {@link FidoMetadataDownloaderBuilder.Step3}) + *
  3. Download the metadata BLOB. + *
  4. Check the "no" property of the downloaded BLOB and compare it with the + * "no" of the cached BLOB, if any. The one with a greater "no" + * overrides the other, even if its "nextUpdate" is in the past. + *
  5. If the downloaded BLOB has a newer "no", or if no BLOB was cached, verify + * that the value of the downloaded BLOB's "legalHeader" appears in the + * configured {@link FidoMetadataDownloaderBuilder.Step1#expectLegalHeader(String...) + * expectLegalHeader} setting. If not, throw an {@link UnexpectedLegalHeader} exception + * containing the cached BLOB, if any, and the downloaded BLOB. + *
  6. If the downloaded BLOB has an expected + * "legalHeader", cache it using the configured {@link File} or {@link Consumer} (see + * {@link FidoMetadataDownloaderBuilder.Step5}). + *
+ * + * No internal mutable state is maintained between invocations of this method; each invocation + * will reload/rewrite caches, perform downloads and check the "legalHeader" + * as necessary. You may therefore reuse a {@link FidoMetadataDownloader} instance and, + * for example, call this method periodically to refresh the BLOB. Each call will return a new + * {@link MetadataBLOB} instance; ones already returned will not be updated by subsequent calls. + * + * @return the successfully retrieved and validated metadata BLOB. + * @throws Base64UrlException if the explicitly configured or newly downloaded BLOB is not a + * well-formed JWT in compact serialization. + * @throws CertPathValidatorException if the explicitly configured or newly downloaded BLOB fails + * certificate path validation. + * @throws CertificateException if the trust root certificate was downloaded and passed the + * SHA-256 integrity check, but does not contain a currently valid X.509 DER certificate; or + * if the BLOB signing certificate chain fails to parse. + * @throws DigestException if the trust root certificate was downloaded but failed the SHA-256 + * integrity check. + * @throws FidoMetadataDownloaderException if the explicitly configured or newly downloaded BLOB + * (if any) has a bad signature and there is no cached BLOB to fall back to. + * @throws IOException if any of the following fails: downloading the trust root certificate, + * downloading the BLOB, reading or writing any cache file (if any), or parsing the BLOB + * contents. + * @throws InvalidAlgorithmParameterException if certificate path validation fails. + * @throws InvalidKeyException if signature verification fails. + * @throws NoSuchAlgorithmException if signature verification fails, or if the SHA-256 algorithm + * is not available. + * @throws SignatureException if signature verification fails. + * @throws UnexpectedLegalHeader if the downloaded BLOB (if any) contains a "legalHeader" + * value not configured in {@link + * FidoMetadataDownloaderBuilder.Step1#expectLegalHeader(String...) + * expectLegalHeader(String...)} but is otherwise valid. The downloaded BLOB will not be + * written to cache in this case. + */ + public MetadataBLOB refreshBlob() + throws CertPathValidatorException, InvalidAlgorithmParameterException, Base64UrlException, + CertificateException, IOException, NoSuchAlgorithmException, SignatureException, + InvalidKeyException, UnexpectedLegalHeader, DigestException, + FidoMetadataDownloaderException { + final X509Certificate trustRoot = retrieveTrustRootCert(); + + final Optional explicit = loadExplicitBlobOnly(trustRoot); + if (explicit.isPresent()) { + log.debug("Explicit BLOB is set - disregarding cache and download."); + return explicit.get(); + } + + final Optional cached = loadCachedBlobOnly(trustRoot); + if (cached.isPresent()) { + log.debug("Cached BLOB exists, proceeding to compare against fresh BLOB..."); + } else { + log.debug("Cached BLOB does not exist or is invalid."); + } + + return refreshBlobInternal(trustRoot, cached).get(); + } + + private Optional refreshBlobInternal( + @NonNull X509Certificate trustRoot, @NonNull Optional cached) + throws CertPathValidatorException, InvalidAlgorithmParameterException, Base64UrlException, + CertificateException, IOException, NoSuchAlgorithmException, SignatureException, + InvalidKeyException, UnexpectedLegalHeader, FidoMetadataDownloaderException { + + try { + log.debug("Attempting to download new BLOB..."); + final ByteArray downloadedBytes = download(blobUrl); + final MetadataBLOB downloadedBlob = parseAndVerifyBlob(downloadedBytes, trustRoot); + log.debug("New BLOB downloaded."); + + if (cached.isPresent()) { + log.debug("Cached BLOB exists - checking if new BLOB has a higher \"no\"..."); + if (downloadedBlob.getPayload().getNo() <= cached.get().getPayload().getNo()) { + log.debug("New BLOB does not have a higher \"no\" - using cached BLOB instead."); + return cached; + } + log.debug("New BLOB has a higher \"no\" - proceeding with new BLOB."); + } + + log.debug("Checking legalHeader in new BLOB..."); + if (!expectedLegalHeaders.contains(downloadedBlob.getPayload().getLegalHeader())) { + throw new UnexpectedLegalHeader(cached.orElse(null), downloadedBlob); + } + + log.debug("Writing new BLOB to cache..."); + if (blobCacheFile != null) { + try (FileOutputStream f = new FileOutputStream(blobCacheFile)) { + f.write(downloadedBytes.getBytes()); + } + } + + if (blobCacheConsumer != null) { + blobCacheConsumer.accept(downloadedBytes); + } + + return Optional.of(downloadedBlob); + } catch (FidoMetadataDownloaderException e) { + if (e.getReason() == Reason.BAD_SIGNATURE && cached.isPresent()) { + log.warn("New BLOB has bad signature - falling back to cached BLOB."); + return cached; + } else { + throw e; + } + } catch (Exception e) { + if (cached.isPresent()) { + log.warn("Failed to download new BLOB - falling back to cached BLOB.", e); + return cached; + } else { + throw e; + } + } } /** @@ -709,13 +874,16 @@ private X509Certificate retrieveTrustRootCert() X509Certificate cert = null; if (cachedContents.isPresent()) { - try { - final X509Certificate cachedCert = - CertificateParser.parseDer(cachedContents.get().getBytes()); - cachedCert.checkValidity(Date.from(clock.instant())); - cert = cachedCert; - } catch (CertificateException e) { - // Fall through + final ByteArray verifiedCachedContents = verifyHash(cachedContents.get(), trustRootSha256); + if (verifiedCachedContents != null) { + try { + final X509Certificate cachedCert = + CertificateParser.parseDer(verifiedCachedContents.getBytes()); + cachedCert.checkValidity(Date.from(clock.instant())); + cert = cachedCert; + } catch (CertificateException e) { + // Fall through + } } } @@ -730,7 +898,9 @@ private X509Certificate retrieveTrustRootCert() cert.checkValidity(Date.from(clock.instant())); if (trustRootCacheFile != null) { - new FileOutputStream(trustRootCacheFile).write(downloaded.getBytes()); + try (FileOutputStream f = new FileOutputStream(trustRootCacheFile)) { + f.write(downloaded.getBytes()); + } } if (trustRootCacheConsumer != null) { @@ -745,101 +915,62 @@ private X509Certificate retrieveTrustRootCert() /** * @throws Base64UrlException if the metadata BLOB is not a well-formed JWT in compact * serialization. - * @throws CertPathValidatorException if the downloaded or explicitly configured BLOB fails - * certificate path validation. + * @throws CertPathValidatorException if the explicitly configured BLOB fails certificate path + * validation. * @throws CertificateException if the BLOB signing certificate chain fails to parse. - * @throws IOException if any of the following fails: downloading the BLOB, reading or writing the - * cache file (if any), or parsing the BLOB contents. + * @throws IOException on failure to parse the BLOB contents. * @throws InvalidAlgorithmParameterException if certificate path validation fails. * @throws InvalidKeyException if signature verification fails. - * @throws UnexpectedLegalHeader if the downloaded BLOB (if any) contains a "legalHeader" - * value not configured in {@link - * FidoMetadataDownloaderBuilder.Step1#expectLegalHeader(String...) - * expectLegalHeader(String...)} but is otherwise valid. The downloaded BLOB will not be - * written to cache in this case. * @throws NoSuchAlgorithmException if signature verification fails. * @throws SignatureException if signature verification fails. * @throws FidoMetadataDownloaderException if the explicitly configured BLOB (if any) has a bad * signature. */ - private MetadataBLOB retrieveBlob(X509Certificate trustRootCertificate) + private Optional loadExplicitBlobOnly(X509Certificate trustRootCertificate) throws Base64UrlException, CertPathValidatorException, CertificateException, IOException, - InvalidAlgorithmParameterException, InvalidKeyException, UnexpectedLegalHeader, - NoSuchAlgorithmException, SignatureException, FidoMetadataDownloaderException { + InvalidAlgorithmParameterException, InvalidKeyException, NoSuchAlgorithmException, + SignatureException, FidoMetadataDownloaderException { if (blobJwt != null) { - return parseAndVerifyBlob( - new ByteArray(blobJwt.getBytes(StandardCharsets.UTF_8)), trustRootCertificate); + return Optional.of( + parseAndVerifyBlob( + new ByteArray(blobJwt.getBytes(StandardCharsets.UTF_8)), trustRootCertificate)); } else { + return Optional.empty(); + } + } - final Optional cachedContents; - if (blobCacheFile != null) { - cachedContents = readCacheFile(blobCacheFile); - } else { - cachedContents = blobCacheSupplier.get(); - } + private Optional loadCachedBlobOnly(X509Certificate trustRootCertificate) { - final MetadataBLOB cachedBlob = - cachedContents - .map( - cached -> { - try { - return parseAndVerifyBlob(cached, trustRootCertificate); - } catch (Exception e) { - return null; - } - }) - .orElse(null); - - if (cachedBlob != null - && cachedBlob - .getPayload() - .getNextUpdate() - .atStartOfDay() - .atZone(clock.getZone()) - .isAfter(clock.instant().atZone(clock.getZone()))) { - return cachedBlob; + final Optional cachedContents; + if (blobCacheFile != null) { + log.debug("Attempting to read BLOB from cache file..."); - } else { - final ByteArray downloaded = download(blobUrl); - try { - final MetadataBLOB downloadedBlob = parseAndVerifyBlob(downloaded, trustRootCertificate); - - if (cachedBlob == null - || downloadedBlob.getPayload().getNo() > cachedBlob.getPayload().getNo()) { - if (expectedLegalHeaders.contains(downloadedBlob.getPayload().getLegalHeader())) { - if (blobCacheFile != null) { - new FileOutputStream(blobCacheFile).write(downloaded.getBytes()); - } - - if (blobCacheConsumer != null) { - blobCacheConsumer.accept(downloaded); - } - - return downloadedBlob; - } else { - throw new UnexpectedLegalHeader(cachedBlob, downloadedBlob); - } - - } else { - return cachedBlob; - } - } catch (FidoMetadataDownloaderException e) { - if (e.getReason() == FidoMetadataDownloaderException.Reason.BAD_SIGNATURE - && cachedBlob != null) { - return cachedBlob; - } else { - throw e; - } - } + try { + cachedContents = readCacheFile(blobCacheFile); + } catch (IOException e) { + return Optional.empty(); } + } else { + log.debug("Attempting to read BLOB from cache Supplier..."); + cachedContents = blobCacheSupplier.get(); } + + return cachedContents.map( + cached -> { + try { + return parseAndVerifyBlob(cached, trustRootCertificate); + } catch (Exception e) { + log.warn("Failed to read or parse cached BLOB.", e); + return null; + } + }); } private Optional readCacheFile(File cacheFile) throws IOException { if (cacheFile.exists() && cacheFile.canRead() && cacheFile.isFile()) { - try { - return Optional.of(readAll(new FileInputStream(cacheFile))); + try (FileInputStream f = new FileInputStream(cacheFile)) { + return Optional.of(readAll(f)); } catch (FileNotFoundException e) { throw new RuntimeException( "This exception should be impossible, please file a bug report.", e); diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataService.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataService.java index 388c515d7..9176bf504 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataService.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataService.java @@ -69,9 +69,15 @@ * *

This class implements {@link AttestationTrustSource}, so it can be configured as the {@link * RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource) attestationTrustSource} - * setting in {@link RelyingParty}. + * setting in {@link RelyingParty}. This implementation always sets {@link + * com.yubico.webauthn.attestation.AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder#enableRevocationChecking(boolean) + * enableRevocationChecking(false)}, because the FIDO MDS has its own revocation procedures and not + * all attestation certificates provide CRLs; and always sets {@link + * com.yubico.webauthn.attestation.AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder#policyTreeValidator(Predicate) + * policyTreeValidator} to accept any policy tree, because a Windows Hello attestation certificate + * is known to include a critical certificate policies extension. * - *

The metadata service may be configured with a two stages of filters to select trusted + *

The metadata service may be configured with two stages of filters to select trusted * authenticators. The first stage is the {@link FidoMetadataServiceBuilder#prefilter(Predicate) * prefilter} setting, which is executed once when the {@link FidoMetadataService} instance is * constructed. The second stage is the {@link FidoMetadataServiceBuilder#filter(Predicate) filter} @@ -261,7 +267,7 @@ public FidoMetadataServiceBuilder useBlob(@NonNull MetadataBLOBPayload blobPaylo * * @param prefilter a {@link Predicate} which returns true for metadata entries to * include in the data source. - * @see #filter + * @see #filter(Predicate) * @see Filters#allOf(Predicate[]) */ public FidoMetadataServiceBuilder prefilter( @@ -328,9 +334,11 @@ public FidoMetadataService build() /** * Preconfigured filters and utilities for combining filters. See the {@link - * FidoMetadataServiceBuilder#prefilter(Predicate) filter} setting. + * FidoMetadataServiceBuilder#prefilter(Predicate) prefilter} and {@link + * FidoMetadataServiceBuilder#filter(Predicate) filter} settings. * * @see FidoMetadataServiceBuilder#prefilter(Predicate) + * @see FidoMetadataServiceBuilder#filter(Predicate) */ public static class Filters { @@ -424,6 +432,9 @@ public static class AuthenticatorToBeFiltered { * The AAGUID from the attested * credential data of a credential about ot be registered. + * + *

This will not be present if the attested credential data contained an AAGUID of all + * zeroes. */ public Optional getAaguid() { return Optional.ofNullable(aaguid); @@ -491,7 +502,7 @@ public Set findEntries( certSubjectKeyIdentifiers, aaguid); - if (!nonzeroAaguid.isPresent()) { + if (aaguid.isPresent() && !nonzeroAaguid.isPresent()) { log.debug("findEntries: ignoring zero AAGUID"); } @@ -514,7 +525,7 @@ public Set findEntries( new AuthenticatorToBeFiltered( attestationCertificateChain, metadataBLOBPayloadEntry, - aaguid.orElse(null)))) + nonzeroAaguid.orElse(null)))) .collect(Collectors.toSet()); log.debug( @@ -614,6 +625,7 @@ public TrustRootsResult findTrustRoots( .collect(Collectors.toSet())) .certStore(certStore) .enableRevocationChecking(false) + .policyTreeValidator(policyNode -> true) .build(); } } diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMds3Spec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMds3Spec.scala index 9b8417814..ffa8ee50b 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMds3Spec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMds3Spec.scala @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.databind.node.TextNode +import com.yubico.fido.metadata.FidoMetadataService.Filters.AuthenticatorToBeFiltered import com.yubico.internal.util.CertificateParser import com.yubico.webauthn.FinishRegistrationOptions import com.yubico.webauthn.RegistrationResult @@ -22,8 +23,8 @@ import com.yubico.webauthn.test.Helpers import org.bouncycastle.asn1.x500.X500Name import org.bouncycastle.cert.jcajce.JcaX500NameUtil import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatest.tags.Network import org.scalatest.tags.Slow import org.scalatestplus.junit.JUnitRunner @@ -49,7 +50,7 @@ import scala.jdk.OptionConverters.RichOptional @Slow @Network @RunWith(classOf[JUnitRunner]) -class FidoMds3Spec extends FunSpec with Matchers { +class FidoMds3Spec extends AnyFunSpec with Matchers { private val CertValidFrom = Instant.parse("2022-02-15T17:00:00Z") private val CertValidTo = Instant.parse("2022-03-15T17:00:00Z") @@ -204,18 +205,23 @@ class FidoMds3Spec extends FunSpec with Matchers { def makeMds( blobTuple: (String, X509Certificate, java.util.Set[CRL]), attestationCrls: Set[CRL] = Set.empty, - )(filter: MetadataBLOBPayloadEntry => Boolean): FidoMetadataService = - FidoMetadataService + )( + prefilter: MetadataBLOBPayloadEntry => Boolean, + filter: Option[AuthenticatorToBeFiltered => Boolean] = None, + ): FidoMetadataService = { + val builder = FidoMetadataService .builder() .useBlob(makeDownloader(blobTuple).loadCachedBlob()) - .prefilter(filter.asJava) + .prefilter(prefilter.asJava) .certStore( CertStore.getInstance( "Collection", new CollectionCertStoreParameters(attestationCrls.asJava), ) ) - .build() + filter.foreach(f => builder.filter(f.asJava)) + builder.build() + } val blobTuple = makeBlob(s"""{ "legalHeader" : "Kom ihåg att du aldrig får snyta dig i mattan!", @@ -239,8 +245,8 @@ class FidoMds3Spec extends FunSpec with Matchers { }""") it("Filtering in getFilteredEntries works as expected.") { - def count(filter: MetadataBLOBPayloadEntry => Boolean): Long = - makeMds(blobTuple)(filter).findEntries(_ => true).size + def count(prefilter: MetadataBLOBPayloadEntry => Boolean): Long = + makeMds(blobTuple)(prefilter).findEntries(_ => true).size implicit class MetadataBLOBPayloadEntryWithAbbreviatedAttestationCertificateKeyIdentifiers( entry: MetadataBLOBPayloadEntry @@ -312,7 +318,7 @@ class FidoMds3Spec extends FunSpec with Matchers { attestationMaker = AttestationMaker.packed( AttestationSigner.ca( COSEAlgorithmIdentifier.ES256, - aaguid = aaguidA.asBytes, + aaguid = Some(aaguidA.asBytes), validFrom = CertValidFrom, validTo = CertValidTo, ) @@ -384,10 +390,10 @@ class FidoMds3Spec extends FunSpec with Matchers { .build() def finishRegistration( - filter: MetadataBLOBPayloadEntry => Boolean + prefilter: MetadataBLOBPayloadEntry => Boolean ): RegistrationResult = { val mds = - makeMds(blobTuple, attestationCrls = attestationCrls)(filter) + makeMds(blobTuple, attestationCrls = attestationCrls)(prefilter) RelyingParty .builder() .identity(rpIdentity) @@ -405,6 +411,66 @@ class FidoMds3Spec extends FunSpec with Matchers { _.getAaguid.toScala.contains(aaguidB) ).isAttestationTrusted should be(false) } + + describe("Zero AAGUIDs") { + val zeroAaguid = + new AAGUID(ByteArray.fromHex("00000000000000000000000000000000")) + + it("are not used to find metadata entries.") { + aaguidA should not equal zeroAaguid + + val blobTuple = makeBlob(s"""{ + "legalHeader" : "Kom ihåg att du aldrig får snyta dig i mattan!", + "nextUpdate" : "2022-12-01", + "no" : 0, + "entries": [ + ${makeEntry(aaguid = Some(aaguidA))}, + ${makeEntry(aaguid = Some(zeroAaguid))} + ] + }""") + var filterRan = false + val mds = makeMds(blobTuple)( + _ => true, + filter = Some({ _ => + filterRan = true + true + }), + ) + + mds.findEntries(zeroAaguid) shouldBe empty + filterRan should be(false) + } + + it("are omitted in the argument to the runtime filter.") { + aaguidA should not equal zeroAaguid + + val (cert, _) = TestAuthenticator.generateAttestationCertificate() + val acki: String = new ByteArray( + CertificateParser.computeSubjectKeyIdentifier(cert) + ).getHex + val blobTuple = makeBlob(s"""{ + "legalHeader" : "Kom ihåg att du aldrig får snyta dig i mattan!", + "nextUpdate" : "2022-12-01", + "no" : 0, + "entries": [ + ${makeEntry(acki = Some(Set(acki)), aaguid = Some(aaguidA))} + ] + }""") + var filterRan = false + val mds = makeMds(blobTuple)( + _ => true, + filter = Some({ authenticatorToBeFiltered => + filterRan = true + authenticatorToBeFiltered.getAaguid.toScala should be(None) + true + }), + ) + + mds.findEntries(List(cert).asJava, zeroAaguid).size should be(1) + filterRan should be(true) + } + } + } describe("2.1. Check whether the status report of the authenticator model has changed compared to the cached entry by looking at the fields timeOfLastStatusChange and statusReport.") { diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala index e23c2b126..eb60e5b52 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala @@ -9,6 +9,7 @@ import com.yubico.webauthn.TestAuthenticator import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.COSEAlgorithmIdentifier import org.bouncycastle.asn1.x500.X500Name +import org.eclipse.jetty.http.HttpStatus import org.eclipse.jetty.server.HttpConfiguration import org.eclipse.jetty.server.HttpConnectionFactory import org.eclipse.jetty.server.Request @@ -21,8 +22,8 @@ import org.eclipse.jetty.util.ssl.SslContextFactory import org.eclipse.jetty.util.thread.QueuedThreadPool import org.junit.runner.RunWith import org.scalatest.BeforeAndAfter -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatest.tags.Network import org.scalatestplus.junit.JUnitRunner @@ -55,7 +56,7 @@ import scala.util.Try @Network @RunWith(classOf[JUnitRunner]) class FidoMetadataDownloaderSpec - extends FunSpec + extends AnyFunSpec with Matchers with BeforeAndAfter { @@ -198,14 +199,16 @@ class FidoMetadataDownloaderSpec path: String, response: String, ): (Server, String, X509Certificate) = - makeHttpServer(Map(path -> response.getBytes(StandardCharsets.UTF_8))) + makeHttpServer( + Map(path -> (200, response.getBytes(StandardCharsets.UTF_8))) + ) private def makeHttpServer( path: String, response: Array[Byte], ): (Server, String, X509Certificate) = - makeHttpServer(Map(path -> response)) + makeHttpServer(Map(path -> (200, response))) private def makeHttpServer( - responses: Map[String, Array[Byte]] + responses: Map[String, (Int, Array[Byte])] ): (Server, String, X509Certificate) = { val tlsKey = TestAuthenticator.generateEcKeypair() val tlsCert = TestAuthenticator.buildCertificate( @@ -248,9 +251,9 @@ class FidoMetadataDownloaderSpec response: HttpServletResponse, ): Unit = { responses.get(target) match { - case Some(responseBody) => { + case Some((status, responseBody)) => { response.getOutputStream.write(responseBody) - response.setStatus(200) + response.setStatus(status) } case None => response.setStatus(404) } @@ -262,768 +265,359 @@ class FidoMetadataDownloaderSpec (server, s"https://localhost:${port}", tlsCert) } - describe("§3.2. Metadata BLOB object processing rules") { - describe("1. Download and cache the root signing trust anchor from the respective MDS root location e.g. More information can be found at https://fidoalliance.org/metadata/") { - it( - "The trust root is downloaded and cached if there isn't a supplier-cached one." - ) { - val random = new SecureRandom() - val trustRootDistinguishedName = - s"CN=Test trust root ${random.nextInt(10000)}" - val (trustRootCert, caKeypair, caName) = - makeTrustRootCert(distinguishedName = trustRootDistinguishedName) - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.now()) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - ) - - var writtenCache: Option[ByteArray] = None - - val (server, serverUrl, httpsCert) = - makeHttpServer("/trust-root.der", trustRootCert.getEncoded) - startServer(server) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), - Set( - TestAuthenticator.sha256(new ByteArray(trustRootCert.getEncoded)) - ).asJava, - ) - .useTrustRootCache( - () => Optional.empty(), - newCache => { writtenCache = Some(newCache) }, - ) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob - blob should not be null - blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( - trustRootDistinguishedName - ) - writtenCache should equal(Some(new ByteArray(trustRootCert.getEncoded))) - } - - it("The trust root is downloaded and cached if there's an expired one in supplier-cache.") { - val random = new SecureRandom() - - val oldTrustRootDistinguishedName = - s"CN=Test trust root ${random.nextInt(10000)}" - val newTrustRootDistinguishedName = - s"CN=Test trust root ${random.nextInt(10000) + 10000}" - val (oldTrustRootCert, _, _) = - makeTrustRootCert( - distinguishedName = oldTrustRootDistinguishedName, - validFrom = CertValidFrom.minusSeconds(600), - validTo = CertValidFrom.minusSeconds(1), - ) - val (newTrustRootCert, caKeypair, caName) = - makeTrustRootCert(distinguishedName = newTrustRootDistinguishedName) - - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.now()) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - ) - - var writtenCache: Option[ByteArray] = None - - val (server, serverUrl, httpsCert) = - makeHttpServer("/trust-root.der", newTrustRootCert.getEncoded) - startServer(server) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), - Set( - TestAuthenticator.sha256( - new ByteArray(newTrustRootCert.getEncoded) - ) - ).asJava, - ) - .useTrustRootCache( - () => Optional.of(new ByteArray(oldTrustRootCert.getEncoded)), - newCache => { writtenCache = Some(newCache) }, - ) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob - blob should not be null - blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( - newTrustRootDistinguishedName - ) - writtenCache should equal( - Some(new ByteArray(newTrustRootCert.getEncoded)) - ) - } + private def withEachLoadMethod( + body: (FidoMetadataDownloader => MetadataBLOB) => Unit + ): Unit = { + describe("[using loadCachedBlob()]") { + body(_.loadCachedBlob()) + } + describe("[using refreshBlob()]") { + body(_.refreshBlob()) + } + } - it( - "The trust root is not downloaded if there's a valid one in file cache." - ) { - val random = new SecureRandom() - val trustRootDistinguishedName = - s"CN=Test trust root ${random.nextInt(10000)}" - val (trustRootCert, caKeypair, caName) = - makeTrustRootCert(distinguishedName = trustRootDistinguishedName) - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.now()) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + describe("§3.2. Metadata BLOB object processing rules") { + withEachLoadMethod { load => + describe("1. Download and cache the root signing trust anchor from the respective MDS root location e.g. More information can be found at https://fidoalliance.org/metadata/") { + it( + "The trust root is downloaded and cached if there isn't a supplier-cached one." + ) { + val random = new SecureRandom() + val trustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000)}" + val (trustRootCert, caKeypair, caName) = + makeTrustRootCert(distinguishedName = trustRootDistinguishedName) + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.now()) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - ) - - val cacheFile = File.createTempFile( - s"${getClass.getCanonicalName}_test_cache_", - ".tmp", - ) - val f = new FileOutputStream(cacheFile) - f.write(trustRootCert.getEncoded) - f.close() - cacheFile.deleteOnExit() - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useDefaultTrustRoot() - .useTrustRootCacheFile(cacheFile) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .build() - .loadCachedBlob - blob should not be null - blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( - trustRootDistinguishedName - ) - } - it( - "The trust root is downloaded and cached if there isn't a file-cached one." - ) { - val random = new SecureRandom() - val trustRootDistinguishedName = - s"CN=Test trust root ${random.nextInt(10000)}" - val (trustRootCert, caKeypair, caName) = - makeTrustRootCert(distinguishedName = trustRootDistinguishedName) - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.now()) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/trust-root.der", trustRootCert.getEncoded) - startServer(server) - - val cacheFile = File.createTempFile( - s"${getClass.getCanonicalName}_test_cache_", - ".tmp", - ) - cacheFile.delete() - cacheFile.deleteOnExit() - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), - Set( - TestAuthenticator.sha256(new ByteArray(trustRootCert.getEncoded)) - ).asJava, - ) - .useTrustRootCacheFile(cacheFile) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob - blob should not be null - blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( - trustRootDistinguishedName - ) - cacheFile.exists() should be(true) - BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( - trustRootCert.getEncoded - ) - } + var writtenCache: Option[ByteArray] = None - it("The trust root is downloaded and cached if there's an expired one in file cache.") { - val random = new SecureRandom() - - val oldTrustRootDistinguishedName = - s"CN=Test trust root ${random.nextInt(10000)}" - val newTrustRootDistinguishedName = - s"CN=Test trust root ${random.nextInt(10000) + 10000}" - val (oldTrustRootCert, _, _) = - makeTrustRootCert( - distinguishedName = oldTrustRootDistinguishedName, - validFrom = CertValidFrom.minusSeconds(600), - validTo = CertValidFrom.minusSeconds(1), - ) - val (newTrustRootCert, caKeypair, caName) = - makeTrustRootCert(distinguishedName = newTrustRootDistinguishedName) + val (server, serverUrl, httpsCert) = + makeHttpServer("/trust-root.der", trustRootCert.getEncoded) + startServer(server) - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.now()) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/trust-root.der", newTrustRootCert.getEncoded) - startServer(server) - - val cacheFile = File.createTempFile( - s"${getClass.getCanonicalName}_test_cache_", - ".tmp", - ) - val f = new FileOutputStream(cacheFile) - f.write(oldTrustRootCert.getEncoded) - f.close() - cacheFile.deleteOnExit() - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), - Set( - TestAuthenticator.sha256( - new ByteArray(newTrustRootCert.getEncoded) + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" ) - ).asJava, - ) - .useTrustRootCacheFile(cacheFile) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob - blob should not be null - blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( - newTrustRootDistinguishedName - ) - cacheFile.exists() should be(true) - BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( - newTrustRootCert.getEncoded - ) - } - - it("The trust root is not downloaded if there's a valid one in supplier-cache.") { - val random = new SecureRandom() - val trustRootDistinguishedName = - s"CN=Test trust root ${random.nextInt(10000)}" - val (trustRootCert, caKeypair, caName) = - makeTrustRootCert(distinguishedName = trustRootDistinguishedName) - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.now()) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + .downloadTrustRoot( + new URL(s"${serverUrl}/trust-root.der"), + Set( + TestAuthenticator.sha256( + new ByteArray(trustRootCert.getEncoded) + ) + ).asJava, + ) + .useTrustRootCache( + () => Optional.empty(), + newCache => { + writtenCache = Some(newCache) + }, + ) + .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() ) - ) - var writtenCache: Option[ByteArray] = None - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useDefaultTrustRoot() - .useTrustRootCache( - () => Optional.of(new ByteArray(trustRootCert.getEncoded)), - newCache => { writtenCache = Some(newCache) }, + blob should not be null + blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( + trustRootDistinguishedName ) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .build() - .loadCachedBlob - blob should not be null - blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( - trustRootDistinguishedName - ) - writtenCache should equal(None) - } - - it("The downloaded trust root cert must match one of the expected SHA256 hashes.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = makeBlob(List(blobCert), blobKeypair, LocalDate.now()) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + writtenCache should equal( + Some(new ByteArray(trustRootCert.getEncoded)) ) - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/trust-root.der", trustRootCert.getEncoded) - startServer(server) - - def testWithHashes(hashes: Set[ByteArray]): MetadataBLOB = { - FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), - hashes.asJava, - ) - .useTrustRootCache(() => Optional.empty(), _ => {}) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob } - val goodHash = - TestAuthenticator.sha256(new ByteArray(trustRootCert.getEncoded)) - val badHash = TestAuthenticator.sha256(goodHash) - - a[DigestException] should be thrownBy { testWithHashes(Set(badHash)) } - testWithHashes(Set(goodHash)) should not be null - testWithHashes(Set(badHash, goodHash)) should not be null - } - } - - describe("2. To validate the digital certificates used in the digital signature, the certificate revocation information MUST be available in the form of CRLs at the respective MDS CRL location e.g. More information can be found at https://fidoalliance.org/metadata/") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.parse("2022-01-19")) - - it( - "Verification fails if the certs don't declare CRL distribution points." - ) { - val thrown = the[CertPathValidatorException] thrownBy { - FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob() - } - thrown.getReason should equal( - BasicReason.UNDETERMINED_REVOCATION_STATUS - ) - } + it("The trust root is downloaded and cached if there's an expired one in supplier-cache.") { + val random = new SecureRandom() + + val oldTrustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000)}" + val newTrustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000) + 10000}" + val (oldTrustRootCert, _, _) = + makeTrustRootCert( + distinguishedName = oldTrustRootDistinguishedName, + validFrom = CertValidFrom.minusSeconds(600), + validTo = CertValidFrom.minusSeconds(1), + ) + val (newTrustRootCert, caKeypair, caName) = + makeTrustRootCert(distinguishedName = newTrustRootDistinguishedName) - it("Verification succeeds if explicitly given appropriate CRLs.") { - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.now()) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - ) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob() - blob should not be null - } - describe("Intermediate certificates") { + var writtenCache: Option[ByteArray] = None - val (intermediateCert, intermediateKeypair, intermediateName) = - makeCert( - caKeypair, - caName, - isCa = true, - name = "CN=Yubico java-webauthn-server unit tests intermediate CA, O=Yubico", - ) - val (blobCert, blobKeypair, _) = - makeCert(intermediateKeypair, intermediateName) - val blobJwt = makeBlob( - List(blobCert, intermediateCert), - blobKeypair, - LocalDate.parse("2022-01-19"), - ) - - it("each require their own CRL.") { - val thrown = the[CertPathValidatorException] thrownBy { + val (server, serverUrl, httpsCert) = + makeHttpServer("/trust-root.der", newTrustRootCert.getEncoded) + startServer(server) + + val blob = load( FidoMetadataDownloader .builder() .expectLegalHeader( "Kom ihåg att du aldrig får snyta dig i mattan!" ) - .useTrustRoot(trustRootCert) + .downloadTrustRoot( + new URL(s"${serverUrl}/trust-root.der"), + Set( + TestAuthenticator.sha256( + new ByteArray(newTrustRootCert.getEncoded) + ) + ).asJava, + ) + .useTrustRootCache( + () => Optional.of(new ByteArray(oldTrustRootCert.getEncoded)), + newCache => { + writtenCache = Some(newCache) + }, + ) .useBlob(blobJwt) .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) .build() - .loadCachedBlob() - } - thrown.getReason should equal( - BasicReason.UNDETERMINED_REVOCATION_STATUS ) - - val rootCrl = TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + blob should not be null + blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( + newTrustRootDistinguishedName + ) + writtenCache should equal( + Some(new ByteArray(newTrustRootCert.getEncoded)) ) + } - val thrown2 = the[CertPathValidatorException] thrownBy { - FidoMetadataDownloader - .builder() - .expectLegalHeader( - "Kom ihåg att du aldrig får snyta dig i mattan!" - ) - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(List[CRL](rootCrl).asJava) - .build() - .loadCachedBlob() - } - thrown2.getReason should equal( - BasicReason.UNDETERMINED_REVOCATION_STATUS + it( + "The trust root is not downloaded and not written to cache if there's a valid one in file cache." + ) { + val random = new SecureRandom() + val trustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000)}" + val (trustRootCert, caKeypair, caName) = + makeTrustRootCert(distinguishedName = trustRootDistinguishedName) + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.now()) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - val intermediateCrl = TestAuthenticator.buildCrl( - intermediateName, - intermediateKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + val cacheFile = File.createTempFile( + s"${getClass.getCanonicalName}_test_cache_", + ".tmp", ) + val f = new FileOutputStream(cacheFile) + f.write(trustRootCert.getEncoded) + f.close() + cacheFile.deleteOnExit() + cacheFile.setLastModified( + cacheFile.lastModified() - 1000 + ) // Set mtime in the past to ensure any write will change it + val initialModTime = cacheFile.lastModified - val thrown3 = the[CertPathValidatorException] thrownBy { + val blob = load( FidoMetadataDownloader .builder() .expectLegalHeader( "Kom ihåg att du aldrig får snyta dig i mattan!" ) - .useTrustRoot(trustRootCert) + .downloadTrustRoot( + new URL("https://localhost:12345/nonexistent.dev.null"), + Set( + TestAuthenticator.sha256( + new ByteArray(trustRootCert.getEncoded) + ) + ).asJava, + ) + .useTrustRootCacheFile(cacheFile) .useBlob(blobJwt) .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(List[CRL](intermediateCrl).asJava) + .useCrls(crls.asJava) .build() - .loadCachedBlob() - } - thrown3.getReason should equal( - BasicReason.UNDETERMINED_REVOCATION_STATUS ) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(List[CRL](rootCrl, intermediateCrl).asJava) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob() blob should not be null + blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( + trustRootDistinguishedName + ) + cacheFile.lastModified should equal(initialModTime) } - it("can revoke downstream certificates too.") { - val rootCrl = TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + it( + "The trust root is downloaded and cached if there isn't a file-cached one." + ) { + val random = new SecureRandom() + val trustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000)}" + val (trustRootCert, caKeypair, caName) = + makeTrustRootCert(distinguishedName = trustRootDistinguishedName) + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.now()) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - val intermediateCrl = TestAuthenticator.buildCrl( - intermediateName, - intermediateKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - revoked = Set(blobCert), + + val (server, serverUrl, httpsCert) = + makeHttpServer("/trust-root.der", trustRootCert.getEncoded) + startServer(server) + + val cacheFile = File.createTempFile( + s"${getClass.getCanonicalName}_test_cache_", + ".tmp", ) - val crls = List(rootCrl, intermediateCrl) + cacheFile.delete() + cacheFile.deleteOnExit() - val thrown = the[CertPathValidatorException] thrownBy { + val blob = load( FidoMetadataDownloader .builder() .expectLegalHeader( "Kom ihåg att du aldrig får snyta dig i mattan!" ) - .useTrustRoot(trustRootCert) + .downloadTrustRoot( + new URL(s"${serverUrl}/trust-root.der"), + Set( + TestAuthenticator.sha256( + new ByteArray(trustRootCert.getEncoded) + ) + ).asJava, + ) + .useTrustRootCacheFile(cacheFile) .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) .useCrls(crls.asJava) - .clock(Clock.fixed(CertValidFrom.plusSeconds(1), ZoneOffset.UTC)) + .trustHttpsCerts(httpsCert) .build() - .loadCachedBlob() - } - thrown.getReason should equal( - BasicReason.REVOKED ) - } - } - } - - describe("3. The FIDO Server MUST be able to download the latest metadata BLOB object from the well-known URL when appropriate, e.g. https://mds.fidoalliance.org/. The nextUpdate field of the Metadata BLOB specifies a date when the download SHOULD occur at latest.") { - it("The BLOB is downloaded if there isn't a cached one.") { - val random = new SecureRandom() - val blobLegalHeader = - s"Kom ihåg att du aldrig får snyta dig i mattan! ${random.nextInt(10000)}" - val blobNo = random.nextInt(10000); - - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob( - List(blobCert), - blobKeypair, - LocalDate.parse("2022-01-19"), - no = blobNo, - legalHeader = blobLegalHeader, + blob should not be null + blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( + trustRootDistinguishedName ) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + cacheFile.exists() should be(true) + BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( + trustRootCert.getEncoded ) - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/blob.jwt", blobJwt) - startServer(server) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader(blobLegalHeader) - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCache(() => Optional.empty(), _ => {}) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob() - .getPayload - blob should not be null - blob.getLegalHeader should equal(blobLegalHeader) - blob.getNo should equal(blobNo) - } + } - it("The BLOB is downloaded if the cached one is out of date.") { - val oldBlobNo = 1 - val newBlobNo = 2 + it("The trust root is downloaded and cached if there's an expired one in file cache.") { + val random = new SecureRandom() + + val oldTrustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000)}" + val newTrustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000) + 10000}" + val (oldTrustRootCert, _, _) = + makeTrustRootCert( + distinguishedName = oldTrustRootDistinguishedName, + validFrom = CertValidFrom.minusSeconds(600), + validTo = CertValidFrom.minusSeconds(1), + ) + val (newTrustRootCert, caKeypair, caName) = + makeTrustRootCert(distinguishedName = newTrustRootDistinguishedName) - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val oldBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - CertValidFrom.atOffset(ZoneOffset.UTC).toLocalDate, - no = oldBlobNo, - ) - val newBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, - no = newBlobNo, - ) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/blob.jwt", newBlobJwt) - startServer(server) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCache( - () => - Optional.of( - new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) - ), - _ => {}, + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.now()) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob() - .getPayload - blob should not be null - blob.getNo should equal(newBlobNo) - } - it( - "The BLOB is not downloaded if the cached one is not yet out of date." - ) { - val oldBlobNo = 1 - val newBlobNo = 2 + val (server, serverUrl, httpsCert) = + makeHttpServer("/trust-root.der", newTrustRootCert.getEncoded) + startServer(server) - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val oldBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, - no = oldBlobNo, + val cacheFile = File.createTempFile( + s"${getClass.getCanonicalName}_test_cache_", + ".tmp", ) - val newBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, - no = newBlobNo, + val f = new FileOutputStream(cacheFile) + f.write(oldTrustRootCert.getEncoded) + f.close() + cacheFile.deleteOnExit() + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .downloadTrustRoot( + new URL(s"${serverUrl}/trust-root.der"), + Set( + TestAuthenticator.sha256( + new ByteArray(newTrustRootCert.getEncoded) + ) + ).asJava, + ) + .useTrustRootCacheFile(cacheFile) + .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() ) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + blob should not be null + blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( + newTrustRootDistinguishedName ) - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/blob.jwt", newBlobJwt) - startServer(server) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCache( - () => - Optional.of( - new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) - ), - _ => {}, + cacheFile.exists() should be(true) + BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( + newTrustRootCert.getEncoded ) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob() - .getPayload - blob should not be null - blob.getNo should equal(oldBlobNo) - } - - } - - describe("4. If the x5u attribute is present in the JWT Header, then:") { + } - describe("1. The FIDO Server MUST verify that the URL specified by the x5u attribute has the same web-origin as the URL used to download the metadata BLOB from. The FIDO Server SHOULD ignore the file if the web-origin differs (in order to prevent loading objects from arbitrary sites).") { - it("x5u on a different host is rejected.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + it("The trust root is not downloaded if there's a valid one in supplier-cache.") { + val random = new SecureRandom() + val trustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000)}" + val (trustRootCert, caKeypair, caName) = + makeTrustRootCert(distinguishedName = trustRootDistinguishedName) val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - - val certChain = List(blobCert) - val certChainPem = certChain - .map(cert => new ByteArray(cert.getEncoded).getBase64) - .mkString( - "-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----", - ) - val blobJwt = - makeBlob( - blobKeypair, - s"""{"alg":"ES256","x5u": "https://localhost:8444/chain.pem"}""", - s"""{ - "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", - "no": 1, - "nextUpdate": "2022-01-19", - "entries": [] - }""", - ) - - val (server, _, httpsCert) = - makeHttpServer( - Map( - "/chain.pem" -> certChainPem.getBytes(StandardCharsets.UTF_8), - "/blob.jwt" -> blobJwt.getBytes(StandardCharsets.UTF_8), - ) - ) - startServer(server) - + makeBlob(List(blobCert), blobKeypair, LocalDate.now()) val crls = List[CRL]( TestAuthenticator.buildCrl( caName, @@ -1034,53 +628,44 @@ class FidoMetadataDownloaderSpec ) ) - val thrown = the[IllegalArgumentException] thrownBy { + var writtenCache: Option[ByteArray] = None + + val blob = load( FidoMetadataDownloader .builder() .expectLegalHeader( "Kom ihåg att du aldrig får snyta dig i mattan!" ) - .useTrustRoot(trustRootCert) - .downloadBlob(new URL("https://localhost:8443/blob.jwt")) - .useBlobCache(() => Optional.empty(), _ => {}) + .downloadTrustRoot( + new URL("https://localhost:12345/nonexistent.dev.null"), + Set( + TestAuthenticator.sha256( + new ByteArray(trustRootCert.getEncoded) + ) + ).asJava, + ) + .useTrustRootCache( + () => Optional.of(new ByteArray(trustRootCert.getEncoded)), + newCache => { + writtenCache = Some(newCache) + }, + ) + .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) .build() - .loadCachedBlob - } - thrown should not be null + ) + blob should not be null + blob.getHeader.getX5c.get.asScala.last.getIssuerDN.getName should equal( + trustRootDistinguishedName + ) + writtenCache should equal(None) } - } - describe("2. The FIDO Server MUST download the certificate (chain) from the URL specified by the x5u attribute [JWS]. The certificate chain MUST be verified to properly chain to the metadata BLOB signing trust anchor according to [RFC5280]. All certificates in the chain MUST be checked for revocation according to [RFC5280].") { - it("x5u with one cert is accepted.") { + it("The downloaded trust root cert must match one of the expected SHA256 hashes.") { val (trustRootCert, caKeypair, caName) = makeTrustRootCert() val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - - val certChain = List(blobCert) - val certChainPem = certChain - .map(cert => new ByteArray(cert.getEncoded).getBase64) - .mkString( - "-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----", - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/chain.pem", certChainPem) - startServer(server) - - val blobJwt = - makeBlob( - blobKeypair, - s"""{"alg":"ES256","x5u": "${serverUrl}/chain.pem"}""", - s"""{ - "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", - "no": 1, - "nextUpdate": "2022-01-19", - "entries": [] - }""", - ) + val blobJwt = makeBlob(List(blobCert), blobKeypair, LocalDate.now()) val crls = List[CRL]( TestAuthenticator.buildCrl( caName, @@ -1091,229 +676,162 @@ class FidoMetadataDownloaderSpec ) ) - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob - blob should not be null - } - - it("x5u with an unknown trust anchor is rejected.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (_, untrustedCaKeypair, untrustedCaName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = - makeCert(untrustedCaKeypair, untrustedCaName) - - val certChain = List(blobCert) - val certChainPem = certChain - .map(cert => new ByteArray(cert.getEncoded).getBase64) - .mkString( - "-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----", - ) - val (server, serverUrl, httpsCert) = - makeHttpServer("/chain.pem", certChainPem) + makeHttpServer("/trust-root.der", trustRootCert.getEncoded) startServer(server) - val blobJwt = - makeBlob( - blobKeypair, - s"""{"alg":"ES256","x5u": "${serverUrl}/chain.pem"}""", - s"""{ - "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", - "no": 1, - "nextUpdate": "2022-01-19", - "entries": [] - }""", + def testWithHashes(hashes: Set[ByteArray]): MetadataBLOB = { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .downloadTrustRoot( + new URL(s"${serverUrl}/trust-root.der"), + hashes.asJava, + ) + .useTrustRootCache(() => Optional.empty(), _ => {}) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() ) - val crls = List[CRL]( + } + + val goodHash = + TestAuthenticator.sha256(new ByteArray(trustRootCert.getEncoded)) + val badHash = TestAuthenticator.sha256(goodHash) + + a[DigestException] should be thrownBy { + testWithHashes(Set(badHash)) + } + testWithHashes(Set(goodHash)) should not be null + testWithHashes(Set(badHash, goodHash)) should not be null + } + + it("The cached trust root cert must match one of the expected SHA256 hashes.") { + val (cachedTrustRootCert, cachedCaKeypair, cachedCaName) = + makeTrustRootCert() + val (cachedRootBlobCert, cachedRootBlobKeypair, _) = + makeCert(cachedCaKeypair, cachedCaName) + val cachedRootBlobJwt = makeBlob( + List(cachedRootBlobCert), + cachedRootBlobKeypair, + LocalDate.now(), + ) + val cachedRootCrls = List[CRL]( TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, + cachedCaName, + cachedCaKeypair.getPrivate, "SHA256withECDSA", CertValidFrom, CertValidTo, ) ) - val thrown = the[CertPathValidatorException] thrownBy { - FidoMetadataDownloader - .builder() - .expectLegalHeader( - "Kom ihåg att du aldrig får snyta dig i mattan!" - ) - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob - } - thrown should not be null - thrown.getReason should be( - CertPathValidatorException.BasicReason.INVALID_SIGNATURE + val (downloadedTrustRootCert, downloadedCaKeypair, downloadedCaName) = + makeTrustRootCert() + val (downloadedRootBlobCert, downloadedRootBlobKeypair, _) = + makeCert(downloadedCaKeypair, downloadedCaName) + val downloadedRootBlobJwt = makeBlob( + List(downloadedRootBlobCert), + downloadedRootBlobKeypair, + LocalDate.now(), ) - } - - it("x5u with three certs requires a CRL for each CA certificate.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val certChain = makeCertChain(caKeypair, caName, 3) - certChain.length should be(3) - val certChainPem = certChain - .map({ - case (cert, _, _) => new ByteArray(cert.getEncoded).getBase64 - }) - .mkString( - "-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----", + val downloadedRootCrls = List[CRL]( + TestAuthenticator.buildCrl( + downloadedCaName, + downloadedCaKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, ) - - val crls = - (certChain.tail :+ (trustRootCert, caKeypair, caName)).map({ - case (_, keypair, name) => - TestAuthenticator.buildCrl( - name, - keypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - }) + ) val (server, serverUrl, httpsCert) = - makeHttpServer("/chain.pem", certChainPem) - startServer(server) - - val blobJwt = - makeBlob( - certChain.head._2, - s"""{"alg":"ES256","x5u": "${serverUrl}/chain.pem"}""", - s"""{ - "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", - "no": 1, - "nextUpdate": "2022-01-19", - "entries": [] - }""", + makeHttpServer( + "/trust-root.der", + downloadedTrustRootCert.getEncoded, ) + startServer(server) - val clock = Clock.fixed(CertValidFrom, ZoneOffset.UTC) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .clock(clock) - .build() - .loadCachedBlob - blob should not be null + def testWithHashes( + hashes: Set[ByteArray], + blobJwt: String, + crls: List[CRL], + ): (MetadataBLOB, Option[ByteArray]) = { + var writtenCache: Option[ByteArray] = None - for { i <- certChain.indices } { - val splicedCrls = crls.take(i) ++ crls.drop(i + 1) - splicedCrls.length should be(crls.length - 1) - val thrown = the[CertPathValidatorException] thrownBy { + val blob = load( FidoMetadataDownloader .builder() .expectLegalHeader( "Kom ihåg att du aldrig får snyta dig i mattan!" ) - .useTrustRoot(trustRootCert) + .downloadTrustRoot( + new URL(s"${serverUrl}/trust-root.der"), + hashes.asJava, + ) + .useTrustRootCache( + () => + Optional.of(new ByteArray(cachedTrustRootCert.getEncoded)), + downloaded => { writtenCache = Some(downloaded) }, + ) .useBlob(blobJwt) - .useCrls(splicedCrls.asJava) + .useCrls(crls.asJava) .trustHttpsCerts(httpsCert) - .clock(clock) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) .build() - .loadCachedBlob - } - thrown should not be null - thrown.getReason should be( - CertPathValidatorException.BasicReason.UNDETERMINED_REVOCATION_STATUS ) + + (blob, writtenCache) } - } - } - describe("3. The FIDO Server SHOULD ignore the file if the chain cannot be verified or if one of the chain certificates is revoked.") { - it("Verification fails if explicitly given CRLs where a cert in the chain is revoked.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val certChain = makeCertChain(caKeypair, caName, 3) - certChain.length should be(3) - val certChainPem = certChain - .map({ - case (cert, _, _) => new ByteArray(cert.getEncoded).getBase64 - }) - .mkString( - "-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", - "\n-----END CERTIFICATE-----", + { + val (blob, writtenCache) = testWithHashes( + Set( + TestAuthenticator.sha256( + new ByteArray(cachedTrustRootCert.getEncoded) + ) + ), + cachedRootBlobJwt, + cachedRootCrls, ) + blob should not be null + writtenCache should be(None) + } - val crls = - (certChain.tail :+ (trustRootCert, caKeypair, caName)).map({ - case (_, keypair, name) => - TestAuthenticator.buildCrl( - name, - keypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + { + val (blob, writtenCache) = testWithHashes( + Set( + TestAuthenticator.sha256( + new ByteArray(downloadedTrustRootCert.getEncoded) ) - }) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/chain.pem", certChainPem) - startServer(server) - - val blobJwt = - makeBlob( - certChain.head._2, - s"""{"alg":"ES256","x5u": "${serverUrl}/chain.pem"}""", - s"""{ - "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", - "no": 1, - "nextUpdate": "2022-01-19", - "entries": [] - }""", + ), + downloadedRootBlobJwt, + downloadedRootCrls, ) + blob should not be null + writtenCache should be( + Some(new ByteArray(downloadedTrustRootCert.getEncoded)) + ) + } + } + } - val clock = Clock.fixed(CertValidFrom.plusSeconds(1), ZoneOffset.UTC) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .clock(clock) - .build() - .loadCachedBlob - blob should not be null + describe("2. To validate the digital certificates used in the digital signature, the certificate revocation information MUST be available in the form of CRLs at the respective MDS CRL location e.g. More information can be found at https://fidoalliance.org/metadata/") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.parse("2022-01-19")) - for { i <- certChain.indices } { - val crlsWithRevocation = - crls.take(i) ++ crls.drop(i + 1) :+ TestAuthenticator.buildCrl( - certChain.lift(i + 1).map(_._3).getOrElse(caName), - certChain.lift(i + 1).map(_._2).getOrElse(caKeypair).getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - revoked = Set(certChain(i)._1), - ) - crlsWithRevocation.length should equal(crls.length) - val thrown = the[CertPathValidatorException] thrownBy { + it( + "Verification fails if the certs don't declare CRL distribution points." + ) { + val thrown = the[CertPathValidatorException] thrownBy { + load( FidoMetadataDownloader .builder() .expectLegalHeader( @@ -1321,113 +839,27 @@ class FidoMetadataDownloaderSpec ) .useTrustRoot(trustRootCert) .useBlob(blobJwt) - .useCrls(crlsWithRevocation.asJava) - .trustHttpsCerts(httpsCert) - .clock(clock) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) .build() - .loadCachedBlob - } - thrown should not be null - thrown.getReason should be(BasicReason.REVOKED) - thrown.getIndex should equal(i) + ) } - } - } - } - - describe("5. If the x5u attribute is missing, the chain should be retrieved from the x5c attribute. If that attribute is missing as well, Metadata BLOB signing trust anchor is considered the BLOB signing certificate chain.") { - it("x5c with one cert is accepted.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val certChain = List(blobCert) - val certChainJson = certChain - .map(cert => new ByteArray(cert.getEncoded).getBase64) - .mkString("[\"", "\",\"", "\"]") - val blobJwt = - makeBlob( - blobKeypair, - s"""{"alg":"ES256","x5c": ${certChainJson}}""", - s"""{ - "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", - "no": 1, - "nextUpdate": "2022-01-19", - "entries": [] - }""", - ) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + thrown.getReason should equal( + BasicReason.UNDETERMINED_REVOCATION_STATUS ) - ) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob - blob should not be null - } - - it("x5c with three certs requires a CRL for each CA certificate.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val certChain = makeCertChain(caKeypair, caName, 3) - certChain.length should be(3) - val certChainJson = certChain - .map({ - case (cert, _, _) => new ByteArray(cert.getEncoded).getBase64 - }) - .mkString("[\"", "\",\"", "\"]") + } - val blobJwt = - makeBlob( - certChain.head._2, - s"""{"alg":"ES256","x5c": ${certChainJson}}""", - s"""{ - "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", - "no": 1, - "nextUpdate": "2022-01-19", - "entries": [] - }""", - ) - val crls = (certChain.tail :+ (trustRootCert, caKeypair, caName)).map({ - case (_, keypair, name) => + it("Verification succeeds if explicitly given appropriate CRLs.") { + val crls = List[CRL]( TestAuthenticator.buildCrl( - name, - keypair.getPrivate, + caName, + caKeypair.getPrivate, "SHA256withECDSA", CertValidFrom, CertValidTo, ) - }) - - val clock = Clock.fixed(CertValidFrom, ZoneOffset.UTC) - - val blob = Try( - FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .clock(clock) - .build() - .loadCachedBlob - ) - blob should not be null - blob shouldBe a[Success[_]] - - for { i <- certChain.indices } { - val splicedCrls = crls.take(i) ++ crls.drop(i + 1) - splicedCrls.length should be(crls.length - 1) - val thrown = the[CertPathValidatorException] thrownBy { + ) + + val blob = load( FidoMetadataDownloader .builder() .expectLegalHeader( @@ -1435,415 +867,1254 @@ class FidoMetadataDownloaderSpec ) .useTrustRoot(trustRootCert) .useBlob(blobJwt) - .useCrls(splicedCrls.asJava) - .clock(clock) + .useCrls(crls.asJava) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) .build() - .loadCachedBlob - } - thrown should not be null - thrown.getReason should be( - CertPathValidatorException.BasicReason.UNDETERMINED_REVOCATION_STATUS ) + blob should not be null } - } - - it("Missing x5c means the trust root cert is used as the signer.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val blobJwt = - makeBlob( - caKeypair, - s"""{"alg":"ES256"}""", - s"""{ - "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", - "no": 1, - "nextUpdate": "2022-01-19", - "entries": [] - }""", - ) - - val crls = List( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - ) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(blobJwt) - .useCrls(crls.asJava) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob - blob should not be null - } - } - describe("6. Verify the signature of the Metadata BLOB object using the BLOB signing certificate chain (as determined by the steps above). The FIDO Server SHOULD ignore the file if the signature is invalid. It SHOULD also ignore the file if its number (no) is less or equal to the number of the last Metadata BLOB object cached locally.") { - it("Invalid signatures are detected.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + describe("Intermediate certificates") { - val validBlobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.parse("2022-01-19")) - val crls = List( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - ) - val badBlobJwt = validBlobJwt - .split(raw"\.") - .updated( - 1, { - val json = JacksonCodecs.json() - val badBlobBody = json - .readTree( - ByteArray - .fromBase64Url(validBlobJwt.split(raw"\.")(1)) - .getBytes - ) - .asInstanceOf[ObjectNode] - badBlobBody.set("no", new IntNode(7)) - new ByteArray( - json - .writeValueAsString(badBlobBody) - .getBytes(StandardCharsets.UTF_8) - ).getBase64 - }, + val (intermediateCert, intermediateKeypair, intermediateName) = + makeCert( + caKeypair, + caName, + isCa = true, + name = "CN=Yubico java-webauthn-server unit tests intermediate CA, O=Yubico", + ) + val (blobCert, blobKeypair, _) = + makeCert(intermediateKeypair, intermediateName) + val blobJwt = makeBlob( + List(blobCert, intermediateCert), + blobKeypair, + LocalDate.parse("2022-01-19"), ) - .mkString(".") - - val thrown = the[FidoMetadataDownloaderException] thrownBy { - FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .useBlob(badBlobJwt) - .useCrls(crls.asJava) - .build() - .loadCachedBlob - } - thrown.getReason should be(Reason.BAD_SIGNATURE) - } - it("""A newly downloaded BLOB is disregarded if the cached one has a greater "no".""") { - val oldBlobNo = 2 - val newBlobNo = 1 + it("each require their own CRL.") { + val thrown = the[CertPathValidatorException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() + ) + } + thrown.getReason should equal( + BasicReason.UNDETERMINED_REVOCATION_STATUS + ) - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val oldBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - CertValidFrom.atOffset(ZoneOffset.UTC).toLocalDate, - no = oldBlobNo, + val rootCrl = TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + + val thrown2 = the[CertPathValidatorException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(List[CRL](rootCrl).asJava) + .build() + ) + } + thrown2.getReason should equal( + BasicReason.UNDETERMINED_REVOCATION_STATUS + ) + + val intermediateCrl = TestAuthenticator.buildCrl( + intermediateName, + intermediateKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + + val thrown3 = the[CertPathValidatorException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(List[CRL](intermediateCrl).asJava) + .build() + ) + } + thrown3.getReason should equal( + BasicReason.UNDETERMINED_REVOCATION_STATUS + ) + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(List[CRL](rootCrl, intermediateCrl).asJava) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() + ) + blob should not be null + } + + it("can revoke downstream certificates too.") { + val rootCrl = TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + val intermediateCrl = TestAuthenticator.buildCrl( + intermediateName, + intermediateKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + revoked = Set(blobCert), + ) + val crls = List(rootCrl, intermediateCrl) + + val thrown = the[CertPathValidatorException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .clock( + Clock.fixed(CertValidFrom.plusSeconds(1), ZoneOffset.UTC) + ) + .build() + ) + } + thrown.getReason should equal( + BasicReason.REVOKED + ) + } + } + } + + describe("3. The FIDO Server MUST be able to download the latest metadata BLOB object from the well-known URL when appropriate, e.g. https://mds.fidoalliance.org/. The nextUpdate field of the Metadata BLOB specifies a date when the download SHOULD occur at latest.") { + it("The BLOB is downloaded if there isn't a cached one.") { + val random = new SecureRandom() + val blobLegalHeader = + s"Kom ihåg att du aldrig får snyta dig i mattan! ${random.nextInt(10000)}" + val blobNo = random.nextInt(10000); + + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob( + List(blobCert), + blobKeypair, + LocalDate.parse("2022-01-19"), + no = blobNo, + legalHeader = blobLegalHeader, + ) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - val newBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, - no = newBlobNo, + + val (server, serverUrl, httpsCert) = + makeHttpServer("/blob.jwt", blobJwt) + startServer(server) + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader(blobLegalHeader) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCache(() => Optional.empty(), _ => {}) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ).getPayload + blob should not be null + blob.getLegalHeader should equal(blobLegalHeader) + blob.getNo should equal(blobNo) + } + + it("The cache is used if the BLOB download fails.") { + val oldBlobNo = 1 + val newBlobNo = 2 + + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val oldBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + CertValidFrom.atOffset(ZoneOffset.UTC).toLocalDate, + no = oldBlobNo, + ) + val newBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, + no = newBlobNo, + ) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + + val (server, serverUrl, httpsCert) = + makeHttpServer( + Map( + "/blob.jwt" -> (HttpStatus.TOO_MANY_REQUESTS_429, newBlobJwt + .getBytes(StandardCharsets.UTF_8)) + ) + ) + startServer(server) + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCache( + () => + Optional.of( + new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) + ), + _ => {}, + ) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ).getPayload + blob should not be null + blob.getNo should equal(oldBlobNo) + } + } + + describe("4. If the x5u attribute is present in the JWT Header, then:") { + + describe("1. The FIDO Server MUST verify that the URL specified by the x5u attribute has the same web-origin as the URL used to download the metadata BLOB from. The FIDO Server SHOULD ignore the file if the web-origin differs (in order to prevent loading objects from arbitrary sites).") { + it("x5u on a different host is rejected.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + + val certChain = List(blobCert) + val certChainPem = certChain + .map(cert => new ByteArray(cert.getEncoded).getBase64) + .mkString( + "-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----", + ) + + val blobJwt = + makeBlob( + blobKeypair, + s"""{"alg":"ES256","x5u": "https://localhost:8444/chain.pem"}""", + s"""{ + "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", + "no": 1, + "nextUpdate": "2022-01-19", + "entries": [] + }""", + ) + + val (server, _, httpsCert) = + makeHttpServer( + Map( + "/chain.pem" -> (200, certChainPem.getBytes( + StandardCharsets.UTF_8 + )), + "/blob.jwt" -> (200, blobJwt.getBytes(StandardCharsets.UTF_8)), + ) + ) + startServer(server) + + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + ) + + val thrown = the[IllegalArgumentException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL("https://localhost:8443/blob.jwt")) + .useBlobCache(() => Optional.empty(), _ => {}) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ) + } + thrown should not be null + } + } + + describe("2. The FIDO Server MUST download the certificate (chain) from the URL specified by the x5u attribute [JWS]. The certificate chain MUST be verified to properly chain to the metadata BLOB signing trust anchor according to [RFC5280]. All certificates in the chain MUST be checked for revocation according to [RFC5280].") { + it("x5u with one cert is accepted.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + + val certChain = List(blobCert) + val certChainPem = certChain + .map(cert => new ByteArray(cert.getEncoded).getBase64) + .mkString( + "-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----", + ) + + val (server, serverUrl, httpsCert) = + makeHttpServer("/chain.pem", certChainPem) + startServer(server) + + val blobJwt = + makeBlob( + blobKeypair, + s"""{"alg":"ES256","x5u": "${serverUrl}/chain.pem"}""", + s"""{ + "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", + "no": 1, + "nextUpdate": "2022-01-19", + "entries": [] + }""", + ) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + ) + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() + ) + blob should not be null + } + + it("x5u with an unknown trust anchor is rejected.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (_, untrustedCaKeypair, untrustedCaName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = + makeCert(untrustedCaKeypair, untrustedCaName) + + val certChain = List(blobCert) + val certChainPem = certChain + .map(cert => new ByteArray(cert.getEncoded).getBase64) + .mkString( + "-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----", + ) + + val (server, serverUrl, httpsCert) = + makeHttpServer("/chain.pem", certChainPem) + startServer(server) + + val blobJwt = + makeBlob( + blobKeypair, + s"""{"alg":"ES256","x5u": "${serverUrl}/chain.pem"}""", + s"""{ + "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", + "no": 1, + "nextUpdate": "2022-01-19", + "entries": [] + }""", + ) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + ) + + val thrown = the[CertPathValidatorException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() + ) + } + thrown should not be null + thrown.getReason should be( + CertPathValidatorException.BasicReason.INVALID_SIGNATURE + ) + } + + it("x5u with three certs requires a CRL for each CA certificate.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val certChain = makeCertChain(caKeypair, caName, 3) + certChain.length should be(3) + val certChainPem = certChain + .map({ + case (cert, _, _) => new ByteArray(cert.getEncoded).getBase64 + }) + .mkString( + "-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----", + ) + + val crls = + (certChain.tail :+ (trustRootCert, caKeypair, caName)).map({ + case (_, keypair, name) => + TestAuthenticator.buildCrl( + name, + keypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + }) + + val (server, serverUrl, httpsCert) = + makeHttpServer("/chain.pem", certChainPem) + startServer(server) + + val blobJwt = + makeBlob( + certChain.head._2, + s"""{"alg":"ES256","x5u": "${serverUrl}/chain.pem"}""", + s"""{ + "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", + "no": 1, + "nextUpdate": "2022-01-19", + "entries": [] + }""", + ) + + val clock = Clock.fixed(CertValidFrom, ZoneOffset.UTC) + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .clock(clock) + .build() + ) + blob should not be null + + for { i <- certChain.indices } { + val splicedCrls = crls.take(i) ++ crls.drop(i + 1) + splicedCrls.length should be(crls.length - 1) + val thrown = the[CertPathValidatorException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(splicedCrls.asJava) + .trustHttpsCerts(httpsCert) + .clock(clock) + .build() + ) + } + thrown should not be null + thrown.getReason should be( + CertPathValidatorException.BasicReason.UNDETERMINED_REVOCATION_STATUS + ) + } + } + } + + describe("3. The FIDO Server SHOULD ignore the file if the chain cannot be verified or if one of the chain certificates is revoked.") { + it("Verification fails if explicitly given CRLs where a cert in the chain is revoked.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val certChain = makeCertChain(caKeypair, caName, 3) + certChain.length should be(3) + val certChainPem = certChain + .map({ + case (cert, _, _) => new ByteArray(cert.getEncoded).getBase64 + }) + .mkString( + "-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n", + "\n-----END CERTIFICATE-----", + ) + + val crls = + (certChain.tail :+ (trustRootCert, caKeypair, caName)).map({ + case (_, keypair, name) => + TestAuthenticator.buildCrl( + name, + keypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + }) + + val (server, serverUrl, httpsCert) = + makeHttpServer("/chain.pem", certChainPem) + startServer(server) + + val blobJwt = + makeBlob( + certChain.head._2, + s"""{"alg":"ES256","x5u": "${serverUrl}/chain.pem"}""", + s"""{ + "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", + "no": 1, + "nextUpdate": "2022-01-19", + "entries": [] + }""", + ) + + val clock = + Clock.fixed(CertValidFrom.plusSeconds(1), ZoneOffset.UTC) + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .clock(clock) + .build() + ) + blob should not be null + + for { i <- certChain.indices } { + val crlsWithRevocation = + crls.take(i) ++ crls.drop(i + 1) :+ TestAuthenticator.buildCrl( + certChain.lift(i + 1).map(_._3).getOrElse(caName), + certChain + .lift(i + 1) + .map(_._2) + .getOrElse(caKeypair) + .getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + revoked = Set(certChain(i)._1), + ) + crlsWithRevocation.length should equal(crls.length) + val thrown = the[CertPathValidatorException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crlsWithRevocation.asJava) + .trustHttpsCerts(httpsCert) + .clock(clock) + .build() + ) + } + thrown should not be null + thrown.getReason should be(BasicReason.REVOKED) + thrown.getIndex should equal(i) + } + } + } + } + + describe("5. If the x5u attribute is missing, the chain should be retrieved from the x5c attribute. If that attribute is missing as well, Metadata BLOB signing trust anchor is considered the BLOB signing certificate chain.") { + it("x5c with one cert is accepted.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val certChain = List(blobCert) + val certChainJson = certChain + .map(cert => new ByteArray(cert.getEncoded).getBase64) + .mkString("[\"", "\",\"", "\"]") + val blobJwt = + makeBlob( + blobKeypair, + s"""{"alg":"ES256","x5c": ${certChainJson}}""", + s"""{ + "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", + "no": 1, + "nextUpdate": "2022-01-19", + "entries": [] + }""", + ) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + ) + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() + ) + blob should not be null + } + + it("x5c with three certs requires a CRL for each CA certificate.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val certChain = makeCertChain(caKeypair, caName, 3) + certChain.length should be(3) + val certChainJson = certChain + .map({ + case (cert, _, _) => new ByteArray(cert.getEncoded).getBase64 + }) + .mkString("[\"", "\",\"", "\"]") + + val blobJwt = + makeBlob( + certChain.head._2, + s"""{"alg":"ES256","x5c": ${certChainJson}}""", + s"""{ + "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", + "no": 1, + "nextUpdate": "2022-01-19", + "entries": [] + }""", + ) + val crls = + (certChain.tail :+ (trustRootCert, caKeypair, caName)).map({ + case (_, keypair, name) => + TestAuthenticator.buildCrl( + name, + keypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + }) + + val clock = Clock.fixed(CertValidFrom, ZoneOffset.UTC) + + val blob = Try( + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .clock(clock) + .build() + ) + ) + blob should not be null + blob shouldBe a[Success[_]] + + for { i <- certChain.indices } { + val splicedCrls = crls.take(i) ++ crls.drop(i + 1) + splicedCrls.length should be(crls.length - 1) + val thrown = the[CertPathValidatorException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(splicedCrls.asJava) + .clock(clock) + .build() + ) + } + thrown should not be null + thrown.getReason should be( + CertPathValidatorException.BasicReason.UNDETERMINED_REVOCATION_STATUS + ) + } + } + + it("Missing x5c means the trust root cert is used as the signer.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val blobJwt = + makeBlob( + caKeypair, + s"""{"alg":"ES256"}""", + s"""{ + "legalHeader": "Kom ihåg att du aldrig får snyta dig i mattan!", + "no": 1, + "nextUpdate": "2022-01-19", + "entries": [] + }""", + ) + + val crls = List( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/blob.jwt", newBlobJwt) - startServer(server) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCache( - () => - Optional.of( - new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) - ), - _ => {}, + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .useBlob(blobJwt) + .useCrls(crls.asJava) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() ) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob - .getPayload - blob should not be null - blob.getNo should equal(oldBlobNo) + blob should not be null + } } - it("A newly downloaded BLOB is disregarded if it has an invalid signature but the cached one has a valid signature.") { - val oldBlobNo = 1 - val newBlobNo = 2 + describe("6. Verify the signature of the Metadata BLOB object using the BLOB signing certificate chain (as determined by the steps above). The FIDO Server SHOULD ignore the file if the signature is invalid. It SHOULD also ignore the file if its number (no) is less or equal to the number of the last Metadata BLOB object cached locally.") { + it("Invalid signatures are detected.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val oldBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - CertValidFrom.atOffset(ZoneOffset.UTC).toLocalDate, - no = oldBlobNo, - ) - val newBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, - no = newBlobNo, - ) - val crls = List[CRL]( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + val validBlobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.parse("2022-01-19")) + val crls = List( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - ) - - val badNewBlobJwt = newBlobJwt - .split(raw"\.") - .updated( - 1, { - val json = JacksonCodecs.json() - val badBlobBody = json - .readTree( - ByteArray.fromBase64Url(newBlobJwt.split(raw"\.")(1)).getBytes + val badBlobJwt = validBlobJwt + .split(raw"\.") + .updated( + 1, { + val json = JacksonCodecs.json() + val badBlobBody = json + .readTree( + ByteArray + .fromBase64Url(validBlobJwt.split(raw"\.")(1)) + .getBytes + ) + .asInstanceOf[ObjectNode] + badBlobBody.set("no", new IntNode(7)) + new ByteArray( + json + .writeValueAsString(badBlobBody) + .getBytes(StandardCharsets.UTF_8) + ).getBase64 + }, + ) + .mkString(".") + + val thrown = the[FidoMetadataDownloaderException] thrownBy { + load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" ) - .asInstanceOf[ObjectNode] - badBlobBody.set("no", new IntNode(7)) - new ByteArray( - json - .writeValueAsString(badBlobBody) - .getBytes(StandardCharsets.UTF_8) - ).getBase64 - }, - ) - .mkString(".") - - val (server, serverUrl, httpsCert) = - makeHttpServer("/blob.jwt", badNewBlobJwt) - startServer(server) - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCache( - () => - Optional.of( - new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) - ), - _ => {}, - ) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob - .getPayload - blob should not be null - blob.getNo should equal(oldBlobNo) - } - } + .useTrustRoot(trustRootCert) + .useBlob(badBlobJwt) + .useCrls(crls.asJava) + .build() + ) + } + thrown.getReason should be(Reason.BAD_SIGNATURE) + } - describe("7. Write the verified object to a local cache as required.") { - it("Cache consumer works.") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob(List(blobCert), blobKeypair, LocalDate.parse("2022-01-19")) - val crls = List( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, - ) - ) - - val (server, serverUrl, httpsCert) = - makeHttpServer("/blob.jwt", blobJwt) - startServer(server) - - var writtenCache: Option[ByteArray] = None - - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCache( - () => Optional.empty(), - cacheme => { writtenCache = Some(cacheme) }, - ) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob - .getPayload - blob should not be null - writtenCache should equal( - Some(new ByteArray(blobJwt.getBytes(StandardCharsets.UTF_8))) - ) - } + it("""A newly downloaded BLOB is disregarded if the cached one has a greater "no".""") { + val oldBlobNo = 2 + val newBlobNo = 1 - describe("File cache") { - val (trustRootCert, caKeypair, caName) = makeTrustRootCert() - val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) - val blobJwt = - makeBlob( - List(blobCert), - blobKeypair, - LocalDate.parse("2022-01-19"), - no = 2, - ) - val oldBlobJwt = - makeBlob( - List(blobCert), - blobKeypair, - LocalDate.parse("2022-01-19"), - no = 1, - ) - val crls = List( - TestAuthenticator.buildCrl( - caName, - caKeypair.getPrivate, - "SHA256withECDSA", - CertValidFrom, - CertValidTo, + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val oldBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + CertValidFrom.atOffset(ZoneOffset.UTC).toLocalDate, + no = oldBlobNo, + ) + val newBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, + no = newBlobNo, + ) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - ) - it("is overwritten if it exists.") { val (server, serverUrl, httpsCert) = - makeHttpServer("/blob.jwt", blobJwt) + makeHttpServer("/blob.jwt", newBlobJwt) startServer(server) - val cacheFile = File.createTempFile( - s"${getClass.getCanonicalName}_test_cache_", - ".tmp", + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCache( + () => + Optional.of( + new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) + ), + _ => {}, + ) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ).getPayload + blob should not be null + blob.getNo should equal(oldBlobNo) + } + + it("A newly downloaded BLOB is disregarded if it has an invalid signature but the cached one has a valid signature.") { + val oldBlobNo = 1 + val newBlobNo = 2 + + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val oldBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + CertValidFrom.atOffset(ZoneOffset.UTC).toLocalDate, + no = oldBlobNo, + ) + val newBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, + no = newBlobNo, + ) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - val f = new FileOutputStream(cacheFile) - f.write(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) - f.close() - cacheFile.deleteOnExit() - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCacheFile(cacheFile) - .clock( - Clock.fixed(Instant.parse("2022-01-19T00:00:00Z"), ZoneOffset.UTC) - ) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob - .getPayload + val badNewBlobJwt = newBlobJwt + .split(raw"\.") + .updated( + 1, { + val json = JacksonCodecs.json() + val badBlobBody = json + .readTree( + ByteArray + .fromBase64Url(newBlobJwt.split(raw"\.")(1)) + .getBytes + ) + .asInstanceOf[ObjectNode] + badBlobBody.set("no", new IntNode(7)) + new ByteArray( + json + .writeValueAsString(badBlobBody) + .getBytes(StandardCharsets.UTF_8) + ).getBase64 + }, + ) + .mkString(".") + + val (server, serverUrl, httpsCert) = + makeHttpServer("/blob.jwt", badNewBlobJwt) + startServer(server) + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCache( + () => + Optional.of( + new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) + ), + _ => {}, + ) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ).getPayload blob should not be null - blob.getNo should be(2) - cacheFile.exists() should be(true) - BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( - blobJwt.getBytes(StandardCharsets.UTF_8) - ) + blob.getNo should equal(oldBlobNo) } + } + + describe("7. Write the verified object to a local cache as required.") { + it("Cache consumer works.") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.parse("2022-01-19")) + val crls = List( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + ) - it("is created if it does not exist.") { val (server, serverUrl, httpsCert) = makeHttpServer("/blob.jwt", blobJwt) startServer(server) - val cacheFile = File.createTempFile( - s"${getClass.getCanonicalName}_test_cache_", - ".tmp", - ) - cacheFile.delete() - cacheFile.deleteOnExit() + var writtenCache: Option[ByteArray] = None - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCacheFile(cacheFile) - .clock( - Clock.fixed(Instant.parse("2022-01-19T00:00:00Z"), ZoneOffset.UTC) - ) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .build() - .loadCachedBlob - .getPayload + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCache( + () => Optional.empty(), + cacheme => { + writtenCache = Some(cacheme) + }, + ) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ).getPayload blob should not be null - blob.getNo should be(2) - cacheFile.exists() should be(true) - BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( - blobJwt.getBytes(StandardCharsets.UTF_8) + writtenCache should equal( + Some(new ByteArray(blobJwt.getBytes(StandardCharsets.UTF_8))) ) } - it("is read from.") { - val (server, serverUrl, httpsCert) = - makeHttpServer("/blob.jwt", oldBlobJwt) - startServer(server) - - val cacheFile = File.createTempFile( - s"${getClass.getCanonicalName}_test_cache_", - ".tmp", + describe("File cache") { + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob( + List(blobCert), + blobKeypair, + LocalDate.parse("2022-01-19"), + no = 2, + ) + val oldBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + LocalDate.parse("2022-01-19"), + no = 1, + ) + val crls = List( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) ) - cacheFile.deleteOnExit() - val f = new FileOutputStream(cacheFile) - f.write(blobJwt.getBytes(StandardCharsets.UTF_8)) - f.close() - val blob = FidoMetadataDownloader - .builder() - .expectLegalHeader("Kom ihåg att du aldrig får snyta dig i mattan!") - .useTrustRoot(trustRootCert) - .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) - .useBlobCacheFile(cacheFile) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .trustHttpsCerts(httpsCert) - .build() - .loadCachedBlob - .getPayload - blob should not be null - blob.getNo should be(2) + it("is overwritten if it exists.") { + val (server, serverUrl, httpsCert) = + makeHttpServer("/blob.jwt", blobJwt) + startServer(server) + + val cacheFile = File.createTempFile( + s"${getClass.getCanonicalName}_test_cache_", + ".tmp", + ) + val f = new FileOutputStream(cacheFile) + f.write(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) + f.close() + cacheFile.deleteOnExit() + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCacheFile(cacheFile) + .clock( + Clock.fixed( + Instant.parse("2022-01-19T00:00:00Z"), + ZoneOffset.UTC, + ) + ) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() + ).getPayload + blob should not be null + blob.getNo should be(2) + cacheFile.exists() should be(true) + BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( + blobJwt.getBytes(StandardCharsets.UTF_8) + ) + } + + it("is created if it does not exist.") { + val (server, serverUrl, httpsCert) = + makeHttpServer("/blob.jwt", blobJwt) + startServer(server) + + val cacheFile = File.createTempFile( + s"${getClass.getCanonicalName}_test_cache_", + ".tmp", + ) + cacheFile.delete() + cacheFile.deleteOnExit() + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCacheFile(cacheFile) + .clock( + Clock.fixed( + Instant.parse("2022-01-19T00:00:00Z"), + ZoneOffset.UTC, + ) + ) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .build() + ).getPayload + blob should not be null + blob.getNo should be(2) + cacheFile.exists() should be(true) + BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( + blobJwt.getBytes(StandardCharsets.UTF_8) + ) + } + + it("is read from.") { + val (server, serverUrl, httpsCert) = + makeHttpServer("/blob.jwt", oldBlobJwt) + startServer(server) + + val cacheFile = File.createTempFile( + s"${getClass.getCanonicalName}_test_cache_", + ".tmp", + ) + cacheFile.deleteOnExit() + val f = new FileOutputStream(cacheFile) + f.write(blobJwt.getBytes(StandardCharsets.UTF_8)) + f.close() + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCacheFile(cacheFile) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ).getPayload + blob should not be null + blob.getNo should be(2) + } } } + + describe("8. Iterate through the individual entries (of type MetadataBLOBPayloadEntry). For each entry:") { + it("Nothing to test - see instead FidoMetadataService.") {} + } + } + } + + describe("3. The FIDO Server MUST be able to download the latest metadata BLOB object from the well-known URL when appropriate, e.g. https://mds.fidoalliance.org/. The nextUpdate field of the Metadata BLOB specifies a date when the download SHOULD occur at latest.") { + + val oldBlobNo = 1 + val newBlobNo = 2 + + val (trustRootCert, caKeypair, caName) = makeTrustRootCert() + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val oldBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, + no = oldBlobNo, + ) + val newBlobJwt = + makeBlob( + List(blobCert), + blobKeypair, + CertValidTo.atOffset(ZoneOffset.UTC).toLocalDate, + no = newBlobNo, + ) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + ) + + val (server, serverUrl, httpsCert) = + makeHttpServer("/blob.jwt", newBlobJwt) + + val downloader = FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .useTrustRoot(trustRootCert) + .downloadBlob(new URL(s"${serverUrl}/blob.jwt")) + .useBlobCache( + () => + Optional.of( + new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8)) + ), + _ => {}, + ) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + + it( + "[using loadCachedBlob] The BLOB is not downloaded if the cached one is not yet out of date." + ) { + startServer(server) + val blob = downloader.loadCachedBlob().getPayload + blob should not be null + blob.getNo should equal(oldBlobNo) } - describe("8. Iterate through the individual entries (of type MetadataBLOBPayloadEntry). For each entry:") { - it("Nothing to test - see instead FidoMetadataService.") {} + it( + "[using refreshBlob] The BLOB is always downloaded even if the cached one is not yet out of date." + ) { + startServer(server) + val blob = downloader.refreshBlob().getPayload + blob should not be null + blob.getNo should equal(newBlobNo) } } diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/JsonIoSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/JsonIoSpec.scala index 419af153e..94458a644 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/JsonIoSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/JsonIoSpec.scala @@ -29,14 +29,14 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.yubico.fido.metadata.Generators._ import org.junit.runner.RunWith import org.scalacheck.Arbitrary -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @RunWith(classOf[JUnitRunner]) class JsonIoSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/MetadataBlobSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/MetadataBlobSpec.scala index 7d07a1e82..ed0d126a9 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/MetadataBlobSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/MetadataBlobSpec.scala @@ -2,12 +2,12 @@ package com.yubico.fido.metadata import com.yubico.internal.util.JacksonCodecs import com.yubico.webauthn.data.ByteArray -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks class MetadataBlobSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/webauthn-server-core/README b/webauthn-server-core/README index a4d096156..4da98cd32 100644 --- a/webauthn-server-core/README +++ b/webauthn-server-core/README @@ -14,7 +14,6 @@ it. == Unimplemented features * Attestation statement formats: - ** https://www.w3.org/TR/webauthn/#sctn-tpm-attestation[`tpm`] ** https://www.w3.org/TR/webauthn/#sctn-android-key-attestation[`android-key`] diff --git a/webauthn-server-core/build.gradle b/webauthn-server-core/build.gradle deleted file mode 100644 index f593e94e7..000000000 --- a/webauthn-server-core/build.gradle +++ /dev/null @@ -1,89 +0,0 @@ -plugins { - id 'java-library' - id 'scala' - id 'maven-publish' - id 'signing' - id 'info.solidsoft.pitest' - id 'io.github.cosmicsilence.scalafix' -} - -description = 'Yubico WebAuthn server core API' - -project.ext.publishMe = true - -sourceCompatibility = 1.8 -targetCompatibility = 1.8 - -dependencies { - api(platform(rootProject)) - - api( - project(':yubico-util'), - ) - - implementation( - 'com.augustcellars.cose:cose-java', - 'com.fasterxml.jackson.core:jackson-databind', - 'com.google.guava:guava', - 'com.upokecenter:cbor', - 'org.apache.httpcomponents:httpclient', - 'org.slf4j:slf4j-api', - ) - - testImplementation( - project(':yubico-util-scala'), - 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8', - 'junit:junit', - 'org.bouncycastle:bcpkix-jdk15on', - 'org.bouncycastle:bcprov-jdk15on', - 'org.mockito:mockito-core', - 'org.scala-lang:scala-library', - 'org.scalacheck:scalacheck_2.13', - 'org.scalatest:scalatest_2.13', - 'uk.org.lidalia:slf4j-test', - ) - - testImplementation('org.slf4j:slf4j-api') { - version { - strictly '[1.7.25,1.8-a)' // Pre-1.8 version required by slf4j-test - } - } -} - -jar { - manifest { - attributes([ - 'Specification-Title': 'Web Authentication: An API for accessing Public Key Credentials', - 'Specification-Version': 'Level 2 Proposed Recommendation 2021-04-08', - 'Specification-Vendor': 'World Wide Web Consortium', - - 'Specification-Url': 'https://www.w3.org/TR/2021/REC-webauthn-2-20210408/', - 'Specification-Url-Latest': 'https://www.w3.org/TR/webauthn-2/', - 'Specification-W3c-Status': 'recommendation', - 'Specification-Release-Date': '2021-04-08', - - 'Implementation-Id': 'java-webauthn-server', - 'Implementation-Title': 'Yubico Web Authentication server library', - 'Implementation-Version': project.version, - 'Implementation-Vendor': 'Yubico', - 'Implementation-Source-Url': 'https://github.com/Yubico/java-webauthn-server', - 'Git-Commit': getGitCommitOrUnknown(), - ]) - } -} - -pitest { - pitestVersion = '1.4.11' - - timestampedReports = false - outputFormats = ['XML', 'HTML'] - - avoidCallsTo = [ - 'java.util.logging', - 'org.apache.log4j', - 'org.slf4j', - 'org.apache.commons.logging', - 'com.google.common.io.Closeables', - ] -} - diff --git a/webauthn-server-core/build.gradle.kts b/webauthn-server-core/build.gradle.kts new file mode 100644 index 000000000..8ddc51f50 --- /dev/null +++ b/webauthn-server-core/build.gradle.kts @@ -0,0 +1,89 @@ +import com.yubico.gradle.GitUtils + +plugins { + `java-library` + scala + `maven-publish` + signing + id("info.solidsoft.pitest") + id("io.github.cosmicsilence.scalafix") +} + +description = "Yubico WebAuthn server core API" + +val publishMe by extra(true) + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + api(platform(rootProject)) + + implementation(project(":yubico-util")) + implementation("com.augustcellars.cose:cose-java") + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("com.google.guava:guava") + implementation("com.upokecenter:cbor") + implementation("org.apache.httpcomponents.client5:httpclient5") + implementation("org.slf4j:slf4j-api") + + testImplementation(platform(project(":test-platform"))) + testImplementation(project(":yubico-util-scala")) + testImplementation("com.fasterxml.jackson.core:jackson-databind") + testImplementation("com.upokecenter:cbor") + testImplementation("junit:junit") + testImplementation("org.bouncycastle:bcpkix-jdk18on") + testImplementation("org.bouncycastle:bcprov-jdk18on") + testImplementation("org.mockito:mockito-core") + testImplementation("org.scala-lang:scala-library") + testImplementation("org.scalacheck:scalacheck_2.13") + testImplementation("org.scalatest:scalatest_2.13") + testImplementation("org.scalatestplus:junit-4-13_2.13") + testImplementation("org.scalatestplus:scalacheck-1-16_2.13") + testImplementation("uk.org.lidalia:slf4j-test") + + testImplementation("org.slf4j:slf4j-api") { + version { + strictly("[1.7.25,1.8-a)") // Pre-1.8 version required by slf4j-test + } + } +} + +tasks.jar { + manifest { + attributes(mapOf( + "Specification-Title" to "Web Authentication: An API for accessing Public Key Credentials", + "Specification-Version" to "Level 2 Proposed Recommendation 2021-04-08", + "Specification-Vendor" to "World Wide Web Consortium", + + "Specification-Url" to "https://www.w3.org/TR/2021/REC-webauthn-2-20210408/", + "Specification-Url-Latest" to "https://www.w3.org/TR/webauthn-2/", + "Specification-W3c-Status" to "recommendation", + "Specification-Release-Date" to "2021-04-08", + + "Implementation-Id" to "java-webauthn-server", + "Implementation-Title" to "Yubico Web Authentication server library", + "Implementation-Version" to project.version, + "Implementation-Vendor" to "Yubico", + "Implementation-Source-Url" to "https://github.com/Yubico/java-webauthn-server", + "Git-Commit" to GitUtils.getGitCommitOrUnknown(projectDir), + )) + } +} + +pitest { + pitestVersion.set("1.9.5") + timestampedReports.set(false) + + outputFormats.set(listOf("XML", "HTML")) + + avoidCallsTo.set(listOf( + "java.util.logging", + "org.apache.log4j", + "org.slf4j", + "org.apache.commons.logging", + "com.google.common.io.Closeables", + )) +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java index c135374a0..542921101 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AndroidSafetynetAttestationStatementVerifier.java @@ -24,7 +24,7 @@ import javax.net.ssl.SSLException; import lombok.Value; import lombok.extern.slf4j.Slf4j; -import org.apache.http.conn.ssl.DefaultHostnameVerifier; +import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; @Slf4j class AndroidSafetynetAttestationStatementVerifier diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java index 2b8c3c0f1..1fb1d9909 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.yubico.internal.util.ExceptionUtil; import com.yubico.webauthn.data.AuthenticatorAssertionExtensionOutputs; import com.yubico.webauthn.data.AuthenticatorData; import com.yubico.webauthn.data.ByteArray; @@ -46,24 +47,16 @@ public class AssertionResult { private final boolean success; /** - * The credential - * ID of the credential used for the assertion. - * - * @see Credential - * ID - * @see PublicKeyCredentialRequestOptions#getAllowCredentials() - */ - @NonNull private final ByteArray credentialId; - - /** - * The user handle - * of the authenticated user. + * The {@link RegisteredCredential} that was returned by {@link + * CredentialRepository#lookup(ByteArray, ByteArray)} and whose public key was used to + * successfully verify the assertion signature. * - * @see User Handle - * @see UserIdentity#getId() - * @see #getUsername() + *

NOTE: The {@link RegisteredCredential#getSignatureCount() signature count} in this object + * will reflect the signature counter state before the assertion operation, not the new + * counter value. When updating your database state, use the signature counter from {@link + * #getSignatureCount()} instead. */ - @NonNull private final ByteArray userHandle; + private final RegisteredCredential credential; /** * The username of the authenticated user. @@ -107,12 +100,33 @@ public class AssertionResult { private final AuthenticatorAssertionExtensionOutputs authenticatorExtensionOutputs; + private AssertionResult( + boolean success, + @NonNull @JsonProperty("credential") RegisteredCredential credential, + @NonNull String username, + long signatureCount, + boolean signatureCounterValid, + ClientAssertionExtensionOutputs clientExtensionOutputs, + AuthenticatorAssertionExtensionOutputs authenticatorExtensionOutputs) { + this( + success, + credential, + username, + null, + null, + signatureCount, + signatureCounterValid, + clientExtensionOutputs, + authenticatorExtensionOutputs); + } + @JsonCreator private AssertionResult( @JsonProperty("success") boolean success, - @NonNull @JsonProperty("credentialId") ByteArray credentialId, - @NonNull @JsonProperty("userHandle") ByteArray userHandle, + @NonNull @JsonProperty("credential") RegisteredCredential credential, @NonNull @JsonProperty("username") String username, + @JsonProperty("credentialId") ByteArray credentialId, // TODO: Delete in next major release + @JsonProperty("userHandle") ByteArray userHandle, // TODO: Delete in next major release @JsonProperty("signatureCount") long signatureCount, @JsonProperty("signatureCounterValid") boolean signatureCounterValid, @JsonProperty("clientExtensionOutputs") @@ -120,9 +134,20 @@ private AssertionResult( @JsonProperty("authenticatorExtensionOutputs") AuthenticatorAssertionExtensionOutputs authenticatorExtensionOutputs) { this.success = success; - this.credentialId = credentialId; - this.userHandle = userHandle; + this.credential = credential; this.username = username; + + if (credentialId != null) { + ExceptionUtil.assure( + credential.getCredentialId().equals(credentialId), + "Legacy credentialId is present and does not equal credential.credentialId"); + } + if (userHandle != null) { + ExceptionUtil.assure( + credential.getUserHandle().equals(userHandle), + "Legacy userHandle is present and does not equal credential.userHandle"); + } + this.signatureCount = signatureCount; this.signatureCounterValid = signatureCounterValid; this.clientExtensionOutputs = @@ -132,6 +157,36 @@ private AssertionResult( this.authenticatorExtensionOutputs = authenticatorExtensionOutputs; } + /** + * The credential + * ID of the credential used for the assertion. + * + * @see Credential + * ID + * @see PublicKeyCredentialRequestOptions#getAllowCredentials() + * @deprecated Use {@link #getCredential()}.{@link RegisteredCredential#getCredentialId() + * getCredentialId()} instead. + */ + @Deprecated + public ByteArray getCredentialId() { + return credential.getCredentialId(); + } + + /** + * The user handle + * of the authenticated user. + * + * @see User Handle + * @see UserIdentity#getId() + * @see #getUsername() + * @deprecated Use {@link #getCredential()}.{@link RegisteredCredential#getUserHandle()} () + * getUserHandle()} instead. + */ + @Deprecated + public ByteArray getUserHandle() { + return credential.getUserHandle(); + } + /** * The client @@ -180,49 +235,42 @@ public Step2 success(boolean success) { } public class Step2 { - public Step3 credentialId(ByteArray credentialId) { - builder.credentialId(credentialId); + public Step3 credential(RegisteredCredential credential) { + builder.credential(credential); return new Step3(); } } public class Step3 { - public Step4 userHandle(ByteArray userHandle) { - builder.userHandle(userHandle); + public Step4 username(String username) { + builder.username(username); return new Step4(); } } public class Step4 { - public Step5 username(String username) { - builder.username(username); + public Step5 signatureCount(long signatureCount) { + builder.signatureCount(signatureCount); return new Step5(); } } public class Step5 { - public Step6 signatureCount(long signatureCount) { - builder.signatureCount(signatureCount); + public Step6 signatureCounterValid(boolean signatureCounterValid) { + builder.signatureCounterValid(signatureCounterValid); return new Step6(); } } public class Step6 { - public Step7 signatureCounterValid(boolean signatureCounterValid) { - builder.signatureCounterValid(signatureCounterValid); - return new Step7(); - } - } - - public class Step7 { - public Step8 clientExtensionOutputs( + public Step7 clientExtensionOutputs( ClientAssertionExtensionOutputs clientExtensionOutputs) { builder.clientExtensionOutputs(clientExtensionOutputs); - return new Step8(); + return new Step7(); } } - public class Step8 { + public class Step7 { public AssertionResultBuilder assertionExtensionOutputs( AuthenticatorAssertionExtensionOutputs authenticatorExtensionOutputs) { return builder.authenticatorExtensionOutputs(authenticatorExtensionOutputs); diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/Crypto.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/Crypto.java index 5893f0dd6..60ddff1bc 100755 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/Crypto.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/Crypto.java @@ -96,9 +96,18 @@ public static boolean verifySignature( public static ByteArray sha256(ByteArray bytes) { //noinspection UnstableApiUsage + // TODO remove noinspection return new ByteArray(Hashing.sha256().hashBytes(bytes.getBytes()).asBytes()); } + public static ByteArray sha384(ByteArray bytes) { + return new ByteArray(Hashing.sha384().hashBytes(bytes.getBytes()).asBytes()); + } + + public static ByteArray sha512(ByteArray bytes) { + return new ByteArray(Hashing.sha512().hashBytes(bytes.getBytes()).asBytes()); + } + public static ByteArray sha256(String str) { return sha256(new ByteArray(str.getBytes(StandardCharsets.UTF_8))); } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java index 91496ec98..c2388207e 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java @@ -203,7 +203,7 @@ class Step7 implements Step { @Override public Step8 nextStep() { - return new Step8(username, userHandle, credential.get()); + return new Step8(username, credential.get()); } @Override @@ -220,7 +220,6 @@ public void validate() { class Step8 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -232,7 +231,7 @@ public void validate() { @Override public Step10 nextStep() { - return new Step10(username, userHandle, credential); + return new Step10(username, credential); } public ByteArray authenticatorData() { @@ -253,7 +252,6 @@ public ByteArray signature() { @Value class Step10 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -263,7 +261,7 @@ public void validate() { @Override public Step11 nextStep() { - return new Step11(username, userHandle, credential, clientData()); + return new Step11(username, credential, clientData()); } public CollectedClientData clientData() { @@ -274,7 +272,6 @@ public CollectedClientData clientData() { @Value class Step11 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; private final CollectedClientData clientData; @@ -289,14 +286,13 @@ public void validate() { @Override public Step12 nextStep() { - return new Step12(username, userHandle, credential); + return new Step12(username, credential); } } @Value class Step12 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -311,14 +307,13 @@ public void validate() { @Override public Step13 nextStep() { - return new Step13(username, userHandle, credential); + return new Step13(username, credential); } } @Value class Step13 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -331,14 +326,13 @@ public void validate() { @Override public Step14 nextStep() { - return new Step14(username, userHandle, credential); + return new Step14(username, credential); } } @Value class Step14 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -349,14 +343,13 @@ public void validate() { @Override public Step15 nextStep() { - return new Step15(username, userHandle, credential); + return new Step15(username, credential); } } @Value class Step15 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -382,14 +375,13 @@ public void validate() { @Override public Step16 nextStep() { - return new Step16(username, userHandle, credential); + return new Step16(username, credential); } } @Value class Step16 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -401,14 +393,13 @@ public void validate() { @Override public Step17 nextStep() { - return new Step17(username, userHandle, credential); + return new Step17(username, credential); } } @Value class Step17 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -425,14 +416,13 @@ public void validate() { @Override public Step18 nextStep() { - return new Step18(username, userHandle, credential); + return new Step18(username, credential); } } @Value class Step18 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -440,14 +430,13 @@ public void validate() {} @Override public Step19 nextStep() { - return new Step19(username, userHandle, credential); + return new Step19(username, credential); } } @Value class Step19 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; @Override @@ -457,7 +446,7 @@ public void validate() { @Override public Step20 nextStep() { - return new Step20(username, userHandle, credential, clientDataJsonHash()); + return new Step20(username, credential, clientDataJsonHash()); } public ByteArray clientDataJsonHash() { @@ -468,7 +457,6 @@ public ByteArray clientDataJsonHash() { @Value class Step20 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; private final ByteArray clientDataJsonHash; @@ -490,7 +478,7 @@ public void validate() { } final COSEAlgorithmIdentifier alg = - WebAuthnCodecs.getCoseKeyAlg(cose) + COSEAlgorithmIdentifier.fromPublicKey(cose) .orElseThrow( () -> new IllegalArgumentException( @@ -503,7 +491,7 @@ public void validate() { @Override public Step21 nextStep() { - return new Step21(username, userHandle, credential); + return new Step21(username, credential); } public ByteArray signedBytes() { @@ -514,14 +502,15 @@ public ByteArray signedBytes() { @Value class Step21 implements Step { private final String username; - private final ByteArray userHandle; private final RegisteredCredential credential; + private final long assertionSignatureCount; private final long storedSignatureCountBefore; - public Step21(String username, ByteArray userHandle, RegisteredCredential credential) { + public Step21(String username, RegisteredCredential credential) { this.username = username; - this.userHandle = userHandle; this.credential = credential; + this.assertionSignatureCount = + response.getResponse().getParsedAuthenticatorData().getSignatureCounter(); this.storedSignatureCountBefore = credential.getSignatureCount(); } @@ -529,29 +518,25 @@ public Step21(String username, ByteArray userHandle, RegisteredCredential creden public void validate() throws InvalidSignatureCountException { if (validateSignatureCounter && !signatureCounterValid()) { throw new InvalidSignatureCountException( - response.getId(), storedSignatureCountBefore + 1, assertionSignatureCount()); + response.getId(), storedSignatureCountBefore + 1, assertionSignatureCount); } } private boolean signatureCounterValid() { - return (assertionSignatureCount() == 0 && storedSignatureCountBefore == 0) - || assertionSignatureCount() > storedSignatureCountBefore; + return (assertionSignatureCount == 0 && storedSignatureCountBefore == 0) + || assertionSignatureCount > storedSignatureCountBefore; } @Override public Finished nextStep() { - return new Finished(username, userHandle, assertionSignatureCount(), signatureCounterValid()); - } - - private long assertionSignatureCount() { - return response.getResponse().getParsedAuthenticatorData().getSignatureCounter(); + return new Finished(credential, username, assertionSignatureCount, signatureCounterValid()); } } @Value class Finished implements Step { + private final RegisteredCredential credential; private final String username; - private final ByteArray userHandle; private final long assertionSignatureCount; private final boolean signatureCounterValid; @@ -570,8 +555,7 @@ public Optional result() { return Optional.of( AssertionResult.builder() .success(true) - .credentialId(response.getId()) - .userHandle(userHandle) + .credential(credential) .username(username) .signatureCount(assertionSignatureCount) .signatureCounterValid(signatureCounterValid) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java index 66bf3bd77..3aa0a2906 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java @@ -30,6 +30,7 @@ import COSE.CoseException; import com.upokecenter.cbor.CBORObject; import com.yubico.webauthn.attestation.AttestationTrustSource; +import com.yubico.webauthn.attestation.AttestationTrustSource.TrustRootsResult; import com.yubico.webauthn.data.AttestationObject; import com.yubico.webauthn.data.AttestationType; import com.yubico.webauthn.data.AuthenticatorAttestationResponse; @@ -50,7 +51,9 @@ import java.security.cert.CertPathValidatorException; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; +import java.security.cert.PKIXCertPathValidatorResult; import java.security.cert.PKIXParameters; +import java.security.cert.PKIXReason; import java.security.cert.TrustAnchor; import java.security.cert.X509Certificate; import java.security.spec.InvalidKeySpecException; @@ -373,6 +376,8 @@ public Optional attestationStatementVerifier() { return Optional.of(new AndroidSafetynetAttestationStatementVerifier()); case "apple": return Optional.of(new AppleAttestationStatementVerifier()); + case "tpm": + return Optional.of(new TpmAttestationStatementVerifier()); default: return Optional.empty(); } @@ -411,9 +416,6 @@ public AttestationType attestationType() { case "android-key": // TODO delete this once android-key attestation verification is implemented return AttestationType.BASIC; - case "tpm": - // TODO delete this once tpm attestation verification is implemented - return AttestationType.ATTESTATION_CA; default: return AttestationType.UNKNOWN; } @@ -536,21 +538,48 @@ public boolean attestationTrusted() { .collect(Collectors.toSet())); pathParams.setDate(Date.from(clock.instant())); pathParams.setRevocationEnabled(trustRoots.get().isEnableRevocationChecking()); + pathParams.setPolicyQualifiersRejected( + !trustRoots.get().getPolicyTreeValidator().isPresent()); trustRoots.get().getCertStore().ifPresent(pathParams::addCertStore); - cpv.validate(certPath, pathParams); - return true; + final PKIXCertPathValidatorResult result = + (PKIXCertPathValidatorResult) cpv.validate(certPath, pathParams); + return trustRoots + .get() + .getPolicyTreeValidator() + .map( + policyNodePredicate -> { + if (policyNodePredicate.test(result.getPolicyTree())) { + return true; + } else { + log.info( + "Failed to derive trust in attestation statement: Certificate path policy tree does not satisfy policy tree validator. Attestation object: {}", + response.getResponse().getAttestationObject()); + return false; + } + }) + .orElse(true); } } catch (CertPathValidatorException e) { log.info( - "Failed to derive trust in attestation statement: {} at cert index {}: {}", + "Failed to derive trust in attestation statement: {} at cert index {}: {}. Attestation object: {}", e.getReason(), e.getIndex(), - e.getMessage()); + e.getMessage(), + response.getResponse().getAttestationObject()); + if (PKIXReason.INVALID_POLICY.equals(e.getReason())) { + log.info( + "You may need to set the policyTreeValidator property on the {} returned by your {}.", + TrustRootsResult.class.getSimpleName(), + AttestationTrustSource.class.getSimpleName()); + } return false; } catch (CertificateException e) { - log.warn("Failed to build attestation certificate path.", e); + log.warn( + "Failed to build attestation certificate path. Attestation object: {}", + response.getResponse().getAttestationObject(), + e); return false; } catch (NoSuchAlgorithmException e) { diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java index 0ba783bf0..41012537a 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java @@ -113,7 +113,7 @@ public static RegisteredCredentialBuilder.MandatoryStages builder() { public static class RegisteredCredentialBuilder { public static class MandatoryStages { - private RegisteredCredentialBuilder builder = new RegisteredCredentialBuilder(); + private final RegisteredCredentialBuilder builder = new RegisteredCredentialBuilder(); /** * {@link RegisteredCredentialBuilder#credentialId(ByteArray) credentialId} is a required diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index afa059e9f..2f8af9741 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -285,7 +285,7 @@ static RegistrationResultBuilder.MandatoryStages builder() { static class RegistrationResultBuilder { static class MandatoryStages { - private RegistrationResultBuilder builder = new RegistrationResultBuilder(); + private final RegistrationResultBuilder builder = new RegistrationResultBuilder(); Step2 keyId(PublicKeyCredentialDescriptor keyId) { builder.keyId(keyId); diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java index 58ce3de51..b2378f523 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java @@ -210,11 +210,13 @@ public class RelyingParty { *

This is a list of acceptable public key algorithms and their parameters, ordered from most * to least preferred. * - *

The default is the following list: + *

The default is the following list, in order: * *

    *
  1. {@link com.yubico.webauthn.data.PublicKeyCredentialParameters#ES256 ES256} *
  2. {@link com.yubico.webauthn.data.PublicKeyCredentialParameters#EdDSA EdDSA} + *
  3. {@link com.yubico.webauthn.data.PublicKeyCredentialParameters#ES256 ES384} + *
  4. {@link com.yubico.webauthn.data.PublicKeyCredentialParameters#ES256 ES512} *
  5. {@link com.yubico.webauthn.data.PublicKeyCredentialParameters#RS256 RS256} *
* @@ -228,6 +230,8 @@ public class RelyingParty { Arrays.asList( PublicKeyCredentialParameters.ES256, PublicKeyCredentialParameters.EdDSA, + PublicKeyCredentialParameters.ES384, + PublicKeyCredentialParameters.ES512, PublicKeyCredentialParameters.RS256)); /** @@ -417,6 +421,8 @@ private static List filterAvailableAlgorithms( break; case ES256: + case ES384: + case ES512: KeyFactory.getInstance("EC"); break; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/TpmAttestationStatementVerifier.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/TpmAttestationStatementVerifier.java new file mode 100644 index 000000000..dc215eee4 --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/TpmAttestationStatementVerifier.java @@ -0,0 +1,691 @@ +// Copyright (c) 2018, Yubico AB +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package com.yubico.webauthn; + +import COSE.CoseException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.upokecenter.cbor.CBORObject; +import com.yubico.internal.util.BinaryUtil; +import com.yubico.internal.util.ByteInputStream; +import com.yubico.internal.util.CertificateParser; +import com.yubico.internal.util.ExceptionUtil; +import com.yubico.webauthn.data.AttestationObject; +import com.yubico.webauthn.data.AttestationType; +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.COSEAlgorithmIdentifier; +import java.io.IOException; +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Arrays; +import java.util.List; +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +final class TpmAttestationStatementVerifier + implements AttestationStatementVerifier, X5cAttestationStatementVerifier { + + private static final String TPM_VER = "2.0"; + static final ByteArray TPM_GENERATED_VALUE = ByteArray.fromBase64("/1RDRw=="); + static final ByteArray TPM_ST_ATTEST_CERTIFY = ByteArray.fromBase64("gBc="); + + static final int TPM_ALG_NULL = 0x0010; + + private static final String OID_TCG_AT_TPM_MANUFACTURER = "2.23.133.2.1"; + private static final String OID_TCG_AT_TPM_MODEL = "2.23.133.2.2"; + private static final String OID_TCG_AT_TPM_VERSION = "2.23.133.2.3"; + + /** + * Object attributes + * + *

see section 8.3 of + * https://www.trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-2-Structures-01.38.pdf + */ + static final class Attributes { + static final int SIGN_ENCRYPT = 1 << 18; + + private static final int SHALL_BE_ZERO = + (1 << 0) // 0 Reserved + | (1 << 3) // 3 Reserved + | (0x3 << 8) // 9:8 Reserved + | (0xF << 12) // 15:12 Reserved + | ((0xFFFFFFFF << 19) & ((1 << 32) - 1)) // 31:19 Reserved + ; + } + + @Override + public AttestationType getAttestationType(AttestationObject attestation) { + return AttestationType.ATTESTATION_CA; + } + + @Override + public boolean verifyAttestationSignature( + AttestationObject attestationObject, ByteArray clientDataJsonHash) { + + // Step 1: Verify that attStmt is valid CBOR conforming to the syntax defined above and perform + // CBOR decoding on it to extract the contained fields. + + ObjectNode attStmt = attestationObject.getAttestationStatement(); + + JsonNode verNode = attStmt.get("ver"); + ExceptionUtil.assure( + verNode != null && verNode.isTextual() && verNode.textValue().equals(TPM_VER), + "attStmt.ver must equal \"%s\", was: %s", + TPM_VER, + verNode); + + JsonNode algNode = attStmt.get("alg"); + ExceptionUtil.assure( + algNode != null && algNode.canConvertToLong(), + "attStmt.alg must be set to an integer value, was: %s", + algNode); + final COSEAlgorithmIdentifier alg = + COSEAlgorithmIdentifier.fromId(algNode.longValue()) + .orElseThrow( + () -> + new IllegalArgumentException("Unknown COSE algorithm identifier: " + algNode)); + + JsonNode x5cNode = attStmt.get("x5c"); + ExceptionUtil.assure( + x5cNode != null && x5cNode.isArray(), + "attStmt.x5c must be set to an array value, was: %s", + x5cNode); + final List x5c; + try { + x5c = + getAttestationTrustPath(attestationObject) + .orElseThrow( + () -> + new IllegalArgumentException( + "Failed to parse \"x5c\" attestation certificate chain in \"tpm\" attestation statement.")); + } catch (CertificateException e) { + throw new RuntimeException(e); + } + final X509Certificate aikCert = x5c.get(0); + + JsonNode sigNode = attStmt.get("sig"); + ExceptionUtil.assure( + sigNode != null && sigNode.isBinary(), + "attStmt.sig must be set to a binary value, was: %s", + sigNode); + final ByteArray sig; + try { + sig = new ByteArray(sigNode.binaryValue()); + } catch (IOException e) { + throw new RuntimeException(e); + } + + JsonNode certInfoNode = attStmt.get("certInfo"); + ExceptionUtil.assure( + certInfoNode != null && certInfoNode.isBinary(), + "attStmt.certInfo must be set to a binary value, was: %s", + certInfoNode); + + JsonNode pubAreaNode = attStmt.get("pubArea"); + ExceptionUtil.assure( + pubAreaNode != null && pubAreaNode.isBinary(), + "attStmt.pubArea must be set to a binary value, was: %s", + pubAreaNode); + + final TpmtPublic pubArea; + try { + pubArea = TpmtPublic.parse(pubAreaNode.binaryValue()); + } catch (IOException e) { + throw new RuntimeException("Failed to parse TPMT_PUBLIC data structure.", e); + } + + final TpmsAttest certInfo; + try { + certInfo = TpmsAttest.parse(certInfoNode.binaryValue()); + } catch (IOException e) { + throw new RuntimeException("Failed to parse TPMS_ATTEST data structure.", e); + } + + // Step 2: Verify that the public key specified by the parameters and unique fields of pubArea + // is identical to the credentialPublicKey in the attestedCredentialData in authenticatorData. + try { + verifyPublicKeysMatch(attestationObject, pubArea); + } catch (CoseException | IOException | InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new RuntimeException( + "Failed to verify that public key in TPM attestation matches public key in authData.", e); + } + + // Step 3: Concatenate authenticatorData and clientDataHash to form attToBeSigned. + final ByteArray attToBeSigned = + attestationObject.getAuthenticatorData().getBytes().concat(clientDataJsonHash); + + // Step 4: Validate that certInfo is valid: + try { + validateCertInfo(alg, aikCert, sig, pubArea, certInfo, attToBeSigned, attestationObject); + } catch (CertificateParsingException e) { + throw new RuntimeException("Failed to verify TPM attestation.", e); + } + + return true; + } + + private void validateCertInfo( + COSEAlgorithmIdentifier alg, + X509Certificate aikCert, + ByteArray sig, + TpmtPublic pubArea, + TpmsAttest certInfo, + ByteArray attToBeSigned, + AttestationObject attestationObject) + throws CertificateParsingException { + // Sub-steps 1-2 handled in TpmsAttest.parse() + // Sub-step 3: Verify that extraData is set to the hash of attToBeSigned using the hash + // algorithm employed in "alg". + final ByteArray expectedExtraData; + switch (alg) { + case ES256: + case RS256: + expectedExtraData = Crypto.sha256(attToBeSigned); + break; + + case ES384: + expectedExtraData = Crypto.sha384(attToBeSigned); + break; + + case ES512: + expectedExtraData = Crypto.sha512(attToBeSigned); + break; + + case RS1: + try { + expectedExtraData = Crypto.sha1(attToBeSigned); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to hash attToBeSigned to verify TPM attestation.", e); + } + break; + + default: + throw new UnsupportedOperationException("Signing algorithm not implemented: " + alg); + } + ExceptionUtil.assure( + certInfo.extraData.equals(expectedExtraData), "Incorrect certInfo.extraData."); + + // Sub-step 4: Verify that attested contains a TPMS_CERTIFY_INFO structure as specified in + // [TPMv2-Part2] section 10.12.3, whose name field contains a valid Name for pubArea, as + // computed using the algorithm in the nameAlg field of pubArea using the procedure specified in + // [TPMv2-Part1] section 16. + ExceptionUtil.assure( + certInfo.attestedName.equals(pubArea.name()), "Incorrect certInfo.attestedName."); + + // Sub-step 5 handled by parsing above + // Sub-step 6: Nothing to do + + // Sub-step 7: Verify the sig is a valid signature over certInfo using the attestation public + // key in aikCert with the algorithm specified in alg. + ExceptionUtil.assure( + Crypto.verifySignature(aikCert, certInfo.getRawBytes(), sig, alg), + "Incorrect TPM attestation signature."); + + // Sub-step 8: Verify that aikCert meets the requirements in § 8.3.1 TPM Attestation Statement + // Certificate Requirements. + // Sub-step 9: If aikCert contains an extension with OID 1.3.6.1.4.1.45724.1.1.4 + // (id-fido-gen-ce-aaguid) verify that the value of this extension matches the aaguid in + // authenticatorData. + verifyX5cRequirements( + aikCert, + attestationObject.getAuthenticatorData().getAttestedCredentialData().get().getAaguid()); + } + + private void verifyPublicKeysMatch(AttestationObject attestationObject, TpmtPublic pubArea) + throws CoseException, IOException, InvalidKeySpecException, NoSuchAlgorithmException { + final PublicKey credentialPubKey = + WebAuthnCodecs.importCosePublicKey( + attestationObject + .getAuthenticatorData() + .getAttestedCredentialData() + .get() + .getCredentialPublicKey()); + + final PublicKey signedCredentialPublicKey; + switch (pubArea.signAlg) { + case TpmAlgAsym.RSA: + { + TpmsRsaParms params = (TpmsRsaParms) pubArea.parameters; + Tpm2bPublicKeyRsa unique = (Tpm2bPublicKeyRsa) pubArea.unique; + RSAPublicKeySpec spec = + new RSAPublicKeySpec( + new BigInteger(1, unique.bytes.getBytes()), BigInteger.valueOf(params.exponent)); + KeyFactory kf = KeyFactory.getInstance("RSA"); + signedCredentialPublicKey = kf.generatePublic(spec); + } + + ExceptionUtil.assure( + Arrays.equals(credentialPubKey.getEncoded(), signedCredentialPublicKey.getEncoded()), + "Signed public key in TPM attestation is not identical to credential public key in authData."); + break; + + case TpmAlgAsym.ECC: + { + TpmsEccParms params = (TpmsEccParms) pubArea.parameters; + TpmsEccPoint unique = (TpmsEccPoint) pubArea.unique; + + final COSEAlgorithmIdentifier algId = + COSEAlgorithmIdentifier.fromPublicKey( + attestationObject + .getAuthenticatorData() + .getAttestedCredentialData() + .get() + .getCredentialPublicKey()) + .get(); + final COSEAlgorithmIdentifier tpmAlgId; + final CBORObject cosePubkey = + CBORObject.DecodeFromBytes( + attestationObject + .getAuthenticatorData() + .getAttestedCredentialData() + .get() + .getCredentialPublicKey() + .getBytes()); + + switch (params.curve_id) { + case TpmEccCurve.NIST_P256: + tpmAlgId = COSEAlgorithmIdentifier.ES256; + break; + + case TpmEccCurve.NIST_P384: + tpmAlgId = COSEAlgorithmIdentifier.ES384; + break; + + case TpmEccCurve.NIST_P521: + tpmAlgId = COSEAlgorithmIdentifier.ES512; + break; + + default: + throw new UnsupportedOperationException( + "Unsupported elliptic curve: " + params.curve_id); + } + + ExceptionUtil.assure( + algId.equals(tpmAlgId), + "Signed public key in TPM attestation is not identical to credential public key in authData; elliptic curve differs: %s != %s", + tpmAlgId, + algId); + byte[] cosePubkeyX = cosePubkey.get(CBORObject.FromObject(-2)).GetByteString(); + byte[] cosePubkeyY = cosePubkey.get(CBORObject.FromObject(-3)).GetByteString(); + ExceptionUtil.assure( + new BigInteger(1, unique.x.getBytes()).equals(new BigInteger(1, cosePubkeyX)), + "Signed public key in TPM attestation is not identical to credential public key in authData; EC X coordinate differs: %s != %s", + unique.x, + new ByteArray(cosePubkeyX)); + ExceptionUtil.assure( + new BigInteger(1, unique.y.getBytes()).equals(new BigInteger(1, cosePubkeyY)), + "Signed public key in TPM attestation is not identical to credential public key in authData; EC Y coordinate differs: %s != %s", + unique.y, + new ByteArray(cosePubkeyY)); + } + break; + + default: + throw new UnsupportedOperationException( + "Unsupported algorithm for credential public key: " + pubArea.signAlg); + } + } + + static final class TpmAlgAsym { + static final int RSA = 0x0001; + static final int ECC = 0x0023; + } + + private interface Parameters {} + + private interface Unique {} + + @Value + private static class TpmtPublic { + int signAlg; + int nameAlg; + Parameters parameters; + Unique unique; + ByteArray rawBytes; + + private static TpmtPublic parse(byte[] pubArea) throws IOException { + try (ByteInputStream reader = new ByteInputStream(pubArea)) { + final int signAlg = reader.readUnsignedShort(); + final int nameAlg = reader.readUnsignedShort(); + + final int attributes = reader.readInt(); + ExceptionUtil.assure( + (attributes & Attributes.SHALL_BE_ZERO) == 0, + "Attributes contains 1 bits in reserved position(s): 0x%08x", + attributes); + + // authPolicy is not used by this implementation + reader.skipBytes(reader.readUnsignedShort()); + + final Parameters parameters; + final Unique unique; + + ExceptionUtil.assure( + (attributes & Attributes.SIGN_ENCRYPT) == Attributes.SIGN_ENCRYPT, + "Public key is expected to have the SIGN_ENCRYPT attribute set, attributes were: 0x%08x", + attributes); + + if (signAlg == TpmAlgAsym.RSA) { + parameters = TpmsRsaParms.parse(reader); + unique = Tpm2bPublicKeyRsa.parse(reader); + } else if (signAlg == TpmAlgAsym.ECC) { + parameters = TpmsEccParms.parse(reader); + unique = TpmsEccPoint.parse(reader); + } else { + throw new UnsupportedOperationException("Signing algorithm not implemented: " + signAlg); + } + + ExceptionUtil.assure( + reader.available() == 0, + "%d remaining bytes in TPMT_PUBLIC buffer", + reader.available()); + + return new TpmtPublic(signAlg, nameAlg, parameters, unique, new ByteArray(pubArea)); + } + } + + /** + * Computing Entity Names + * + *

see: + * https://www.trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-1-Architecture-01.38.pdf + * section 16 Names + * + *

+     * Name ≔ nameAlg || HnameAlg (handle→nvPublicArea)
+     * where
+     * nameAlg algorithm used to compute Name
+     * HnameAlg hash using the nameAlg parameter in the NV Index location
+     * associated with handle
+     * nvPublicArea contents of the TPMS_NV_PUBLIC associated with handle
+     * 
+ */ + private ByteArray name() { + final ByteArray hash; + switch (this.nameAlg) { + case TpmAlgHash.SHA1: + try { + hash = Crypto.sha1(this.rawBytes); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to hash TPMU_ATTEST name.", e); + } + break; + + case TpmAlgHash.SHA256: + hash = Crypto.sha256(this.rawBytes); + break; + + case TpmAlgHash.SHA384: + hash = Crypto.sha384(this.rawBytes); + break; + + case TpmAlgHash.SHA512: + hash = Crypto.sha512(this.rawBytes); + break; + + default: + throw new IllegalArgumentException("Unknown hash algorithm identifier: " + this.nameAlg); + } + return new ByteArray(BinaryUtil.encodeUint16(this.nameAlg)).concat(hash); + } + } + + static class TpmAlgHash { + static final int SHA1 = 0x0004; + static final int SHA256 = 0x000B; + static final int SHA384 = 0x000C; + static final int SHA512 = 0x000D; + } + + private void verifyX5cRequirements(X509Certificate cert, ByteArray aaguid) + throws CertificateParsingException { + ExceptionUtil.assure( + cert.getVersion() == 3, + "Invalid TPM attestation certificate: Version MUST be 3, but was: %s", + cert.getVersion()); + + ExceptionUtil.assure( + cert.getSubjectX500Principal().getName().isEmpty(), + "Invalid TPM attestation certificate: subject MUST be empty, but was: %s", + cert.getSubjectX500Principal()); + + boolean foundManufacturer = false; + boolean foundModel = false; + boolean foundVersion = false; + for (List n : cert.getSubjectAlternativeNames()) { + if ((Integer) n.get(0) == 4) { // GeneralNames CHOICE 4: directoryName + if (n.get(1) instanceof String) { + try { + javax.naming.directory.Attributes attrs = + new LdapName((String) n.get(1)).getRdns().get(0).toAttributes(); + foundManufacturer = foundManufacturer || attrs.get(OID_TCG_AT_TPM_MANUFACTURER) != null; + foundModel = foundModel || attrs.get(OID_TCG_AT_TPM_MODEL) != null; + foundVersion = foundVersion || attrs.get(OID_TCG_AT_TPM_VERSION) != null; + } catch (InvalidNameException e) { + throw new RuntimeException( + "Failed to decode subject alternative name in TPM attestation cert", e); + } + } else { + log.debug("Unknown type of SubjectAlternativeNames entry: {}", n.get(1)); + } + } + } + ExceptionUtil.assure( + foundManufacturer && foundModel && foundVersion, + "Invalid TPM attestation certificate: The Subject Alternative Name extension MUST be set as defined in [TPMv2-EK-Profile] section 3.2.9.%s%s%s", + foundManufacturer ? "" : " Missing TPM manufacturer.", + foundModel ? "" : " Missing TPM model.", + foundVersion ? "" : " Missing TPM version."); + + ExceptionUtil.assure( + cert.getExtendedKeyUsage() != null && cert.getExtendedKeyUsage().contains("2.23.133.8.3"), + "Invalid TPM attestation certificate: extended key usage extension MUST contain the OID 2.23.133.8.3, but was: %s", + cert.getExtendedKeyUsage()); + + ExceptionUtil.assure( + cert.getBasicConstraints() == -1, + "Invalid TPM attestation certificate: MUST NOT be a CA certificate, but was."); + + CertificateParser.parseFidoAaguidExtension(cert) + .ifPresent( + extensionAaguid -> { + ExceptionUtil.assure( + Arrays.equals(aaguid.getBytes(), extensionAaguid), + "Invalid TPM attestation certificate: X.509 extension \"id-fido-gen-ce-aaguid\" is present but does not match the authenticator AAGUID."); + }); + } + + static final class TpmRsaScheme { + static final int RSASSA = 0x0014; + } + + /** + * See: + * https://www.trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-2-Structures-01.38.pdf + * section 12.2.3.5 + */ + @Value + private static class TpmsRsaParms implements Parameters { + + long exponent; + + private static TpmsRsaParms parse(ByteInputStream reader) throws IOException { + final int symmetric = reader.readUnsignedShort(); + ExceptionUtil.assure( + symmetric == TPM_ALG_NULL, + "RSA key is expected to have \"symmetric\" set to TPM_ALG_NULL, was: 0x%04x", + symmetric); + + final int scheme = reader.readUnsignedShort(); + ExceptionUtil.assure( + scheme == TpmRsaScheme.RSASSA || scheme == TPM_ALG_NULL, + "RSA key is expected to have \"scheme\" set to TPM_ALG_RSASSA or TPM_ALG_NULL, was: 0x%04x", + scheme); + + reader.skipBytes(2); // key_bits is not used by this implementation + + int exponent = reader.readInt(); + ExceptionUtil.assure( + exponent >= 0, "Exponent is too large and wrapped around to negative: %d", exponent); + if (exponent == 0) { + // When zero, indicates that the exponent is the default of 2^16 + 1 + exponent = (1 << 16) + 1; + } + + return new TpmsRsaParms(exponent); + } + } + + @Value + private static class Tpm2bPublicKeyRsa implements Unique { + ByteArray bytes; + + private static Tpm2bPublicKeyRsa parse(ByteInputStream reader) throws IOException { + return new Tpm2bPublicKeyRsa(new ByteArray(reader.read(reader.readUnsignedShort()))); + } + } + + @Value + private static class TpmsEccParms implements Parameters { + int curve_id; + + private static TpmsEccParms parse(ByteInputStream reader) throws IOException { + final int symmetric = reader.readUnsignedShort(); + final int scheme = reader.readUnsignedShort(); + ExceptionUtil.assure( + symmetric == TPM_ALG_NULL, + "ECC key is expected to have \"symmetric\" set to TPM_ALG_NULL, was: 0x%04x", + symmetric); + ExceptionUtil.assure( + scheme == TPM_ALG_NULL, + "ECC key is expected to have \"scheme\" set to TPM_ALG_NULL, was: 0x%04x", + scheme); + + final int curve_id = reader.readUnsignedShort(); + reader.skipBytes(2); // kdf_scheme is not used by this implementation + + return new TpmsEccParms(curve_id); + } + } + + /** + * TPMS_ECC_POINT + * + *

See + * https://www.trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-2-Structures-01.38.pdf + * Section 11.2.5.2 + */ + @Value + private static class TpmsEccPoint implements Unique { + + ByteArray x; + ByteArray y; + + private static TpmsEccPoint parse(ByteInputStream reader) throws IOException { + final ByteArray x = new ByteArray(reader.read(reader.readUnsignedShort())); + final ByteArray y = new ByteArray(reader.read(reader.readUnsignedShort())); + + return new TpmsEccPoint(x, y); + } + } + + /** + * TPM_ECC_CURVE + * + *

https://www.trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-2-Structures-01.38.pdf + * section 6.4 + */ + private static class TpmEccCurve { + + private static final int NONE = 0x0000; + private static final int NIST_P256 = 0x0003; + private static final int NIST_P384 = 0x0004; + private static final int NIST_P521 = 0x0005; + } + + /** + * the signature data is defined by [TPMv2-Part2] Section 10.12.8 (TPMS_ATTEST) as: + * TPM_GENERATED_VALUE (0xff544347 aka "\xffTCG") TPMI_ST_ATTEST - always TPM_ST_ATTEST_CERTIFY + * (0x8017) because signing procedure defines it should call TPM_Certify [TPMv2-Part3] Section + * 18.2 TPM2B_NAME size (uint16) name (size long) TPM2B_DATA size (uint16) name (size long) + * TPMS_CLOCK_INFO clock (uint64) resetCount (uint32) restartCount (uint32) safe (byte) 1 yes, 0 + * no firmwareVersion uint64 attested TPMS_CERTIFY_INFO (because TPM_ST_ATTEST_CERTIFY) name + * TPM2B_NAME qualified_name TPM2B_NAME See: + * https://www.trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-2-Structures-01.38.pdf + * https://www.trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-3-Commands-01.38.pdf + */ + @Value + private static class TpmsAttest { + + ByteArray rawBytes; + ByteArray extraData; + ByteArray attestedName; + + private static TpmsAttest parse(byte[] certInfo) throws IOException { + try (ByteInputStream reader = new ByteInputStream(certInfo)) { + final ByteArray magic = new ByteArray(reader.read(4)); + + // Verify that magic is set to TPM_GENERATED_VALUE. + // see https://w3c.github.io/webauthn/#sctn-tpm-attestation + // verification procedure + ExceptionUtil.assure( + magic.equals(TPM_GENERATED_VALUE), "magic field is invalid: %s", magic); + + // Verify that type is set to TPM_ST_ATTEST_CERTIFY. + // see https://w3c.github.io/webauthn/#sctn-tpm-attestation + // verification procedure + final ByteArray type = new ByteArray(reader.read(2)); + ExceptionUtil.assure(type.equals(TPM_ST_ATTEST_CERTIFY), "type field is invalid: %s", type); + + // qualifiedSigner is not used by this implementation + reader.skipBytes(reader.readUnsignedShort()); + + final ByteArray extraData = new ByteArray(reader.read(reader.readUnsignedShort())); + + // clockInfo is not used by this implementation + reader.skipBytes(8 + 4 + 4 + 1); + + // firmwareVersion is not used by this implementation + reader.skipBytes(8); + + final ByteArray attestedName = new ByteArray(reader.read(reader.readUnsignedShort())); + + // attestedQualifiedName is not used by this implementation + + return new TpmsAttest(new ByteArray(certInfo), extraData, attestedName); + } + } + } +} diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java index 34d961bae..b5f8e079d 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/WebAuthnCodecs.java @@ -42,7 +42,6 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import java.util.Optional; final class WebAuthnCodecs { @@ -50,10 +49,14 @@ final class WebAuthnCodecs { new ByteArray(new byte[] {0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x70}); static ByteArray ecPublicKeyToRaw(ECPublicKey key) { + + final int fieldSizeBytes = + Math.toIntExact( + Math.round(Math.ceil(key.getParams().getCurve().getField().getFieldSize() / 8.0))); byte[] x = key.getW().getAffineX().toByteArray(); byte[] y = key.getW().getAffineY().toByteArray(); - byte[] xPadding = new byte[Math.max(0, 32 - x.length)]; - byte[] yPadding = new byte[Math.max(0, 32 - y.length)]; + byte[] xPadding = new byte[Math.max(0, fieldSizeBytes - x.length)]; + byte[] yPadding = new byte[Math.max(0, fieldSizeBytes - y.length)]; Arrays.fill(xPadding, (byte) 0); Arrays.fill(yPadding, (byte) 0); @@ -61,28 +64,57 @@ static ByteArray ecPublicKeyToRaw(ECPublicKey key) { return new ByteArray( Bytes.concat( new byte[] {0x04}, - Bytes.concat(xPadding, Arrays.copyOfRange(x, Math.max(0, x.length - 32), x.length)), - Bytes.concat(yPadding, Arrays.copyOfRange(y, Math.max(0, y.length - 32), y.length)))); + xPadding, + Arrays.copyOfRange(x, Math.max(0, x.length - fieldSizeBytes), x.length), + yPadding, + Arrays.copyOfRange(y, Math.max(0, y.length - fieldSizeBytes), y.length))); } static ByteArray rawEcKeyToCose(ByteArray key) { final byte[] keyBytes = key.getBytes(); - if (!(keyBytes.length == 64 || (keyBytes.length == 65 && keyBytes[0] == 0x04))) { + final int len = keyBytes.length; + final int lenSub1 = keyBytes.length - 1; + if (!(len == 64 + || len == 96 + || len == 132 + || (keyBytes[0] == 0x04 && (lenSub1 == 64 || lenSub1 == 96 || lenSub1 == 132)))) { throw new IllegalArgumentException( String.format( - "Raw key must be 64 bytes long or be 65 bytes long and start with 0x04, was %d bytes starting with %02x", + "Raw key must be 64, 96 or 132 bytes long, or start with 0x04 and be 65, 97 or 133 bytes long; was %d bytes starting with %02x", keyBytes.length, keyBytes[0])); } - final int start = (keyBytes.length == 64) ? 0 : 1; + final int start = (len == 64 || len == 96 || len == 132) ? 0 : 1; + final int coordinateLength = (len - start) / 2; final Map coseKey = new HashMap<>(); coseKey.put(1L, 2L); // Key type: EC - coseKey.put(3L, COSEAlgorithmIdentifier.ES256.getId()); - coseKey.put(-1L, 1L); // Curve: P-256 + final COSEAlgorithmIdentifier coseAlg; + final int coseCrv; + switch (len - start) { + case 64: + coseAlg = COSEAlgorithmIdentifier.ES256; + coseCrv = 1; + break; + case 96: + coseAlg = COSEAlgorithmIdentifier.ES384; + coseCrv = 2; + break; + case 132: + coseAlg = COSEAlgorithmIdentifier.ES512; + coseCrv = 3; + break; + default: + throw new RuntimeException( + "Failed to determine COSE EC algorithm. This should not be possible, please file a bug report."); + } + coseKey.put(3L, coseAlg.getId()); + coseKey.put(-1L, coseCrv); - coseKey.put(-2L, Arrays.copyOfRange(keyBytes, start, start + 32)); // x - coseKey.put(-3L, Arrays.copyOfRange(keyBytes, start + 32, start + 64)); // y + coseKey.put(-2L, Arrays.copyOfRange(keyBytes, start, start + coordinateLength)); // x + coseKey.put( + -3L, + Arrays.copyOfRange(keyBytes, start + coordinateLength, start + 2 * coordinateLength)); // y return new ByteArray(CBORObject.FromObject(coseKey).EncodeToBytes()); } @@ -140,18 +172,16 @@ private static PublicKey importCoseEd25519PublicKey(CBORObject cose) return kFact.generatePublic(new X509EncodedKeySpec(x509Key.getBytes())); } - static Optional getCoseKeyAlg(ByteArray key) { - CBORObject cose = CBORObject.DecodeFromBytes(key.getBytes()); - final int alg = cose.get(CBORObject.FromObject(3)).AsInt32(); - return COSEAlgorithmIdentifier.fromId(alg); - } - static String getJavaAlgorithmName(COSEAlgorithmIdentifier alg) { switch (alg) { case EdDSA: return "EDDSA"; case ES256: return "SHA256withECDSA"; + case ES384: + return "SHA384withECDSA"; + case ES512: + return "SHA512withECDSA"; case RS256: return "SHA256withRSA"; case RS1: diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/attestation/AttestationTrustSource.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/attestation/AttestationTrustSource.java index cc9fc17e9..290437efc 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/attestation/AttestationTrustSource.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/attestation/AttestationTrustSource.java @@ -27,10 +27,12 @@ import com.yubico.internal.util.CollectionUtil; import com.yubico.webauthn.data.ByteArray; import java.security.cert.CertStore; +import java.security.cert.PolicyNode; import java.security.cert.X509Certificate; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import lombok.Builder; import lombok.NonNull; import lombok.Value; @@ -44,7 +46,7 @@ public interface AttestationTrustSource { *

Note that it is possible for the same trust root to be used for different certificate * chains. For example, an authenticator vendor may make two different authenticator models, each * with its own attestation leaf certificate but both signed by the same attestation root - * certificate. If a Relying Party trusts one of those authenticators models but not the other, + * certificate. If a Relying Party trusts one of those authenticator models but not the other, * then its implementation of this method MUST return an empty set for the untrusted certificate * chain. * @@ -60,11 +62,21 @@ TrustRootsResult findTrustRoots( List attestationCertificateChain, Optional aaguid); /** - * A result of looking up attestation trust roots for a particular attestation statement. This - * primarily consists of a set of trust root certificates, but may also include a {@link - * CertStore} of additional CRLs and/or intermediate certificate to use during certificate path - * validation, and may also disable certificate revocation checking for the relevant attestation - * statement. + * A result of looking up attestation trust roots for a particular attestation statement. + * + *

This primarily consists of a set of trust root certificates - see {@link + * TrustRootsResultBuilder#trustRoots(Set) trustRoots(Set)} - but may also: + * + *

    + *
  • include a {@link CertStore} of additional CRLs and/or intermediate certificates to use + * during certificate path validation - see {@link + * TrustRootsResultBuilder#certStore(CertStore) certStore(CertStore)}; + *
  • disable certificate revocation checking for the relevant attestation statement - see + * {@link TrustRootsResultBuilder#enableRevocationChecking(boolean) + * enableRevocationChecking(boolean)}; and/or + *
  • define a policy tree validator for the PKIX policy tree result - see {@link + * TrustRootsResultBuilder#policyTreeValidator(Predicate) policyTreeValidator(Predicate)}. + *
*/ @Value @Builder(toBuilder = true) @@ -97,29 +109,189 @@ class TrustRootsResult { */ @Builder.Default private final boolean enableRevocationChecking = true; + /** + * If non-null, the PolicyQualifiersRejected flag will be set to false during certificate path + * validation. See {@link + * java.security.cert.PKIXParameters#setPolicyQualifiersRejected(boolean)}. + * + *

The given {@link Predicate} will be used to validate the policy tree. The {@link + * Predicate} should return true if the policy tree is acceptable, and false + * otherwise. + * + *

Depending on your "PKIX" JCA provider configuration, this may be required if + * any certificate in the certificate path contains a certificate policies extension marked + * critical. If this is not set, then such a certificate will be rejected by the certificate + * path validator from the default provider. + * + *

Consult the Java + * PKI Programmer's Guide for how to use the {@link PolicyNode} argument of the {@link + * Predicate}. + * + *

The default is null. + */ + @Builder.Default private final Predicate policyTreeValidator = null; + private TrustRootsResult( @NonNull Set trustRoots, CertStore certStore, - boolean enableRevocationChecking) { + boolean enableRevocationChecking, + Predicate policyTreeValidator) { this.trustRoots = CollectionUtil.immutableSet(trustRoots); this.certStore = certStore; this.enableRevocationChecking = enableRevocationChecking; + this.policyTreeValidator = policyTreeValidator; } + /** + * A {@link CertStore} of additional CRLs and/or intermediate certificates to use during + * certificate path validation, if any. This will not be used if {@link + * TrustRootsResultBuilder#trustRoots(Set) trustRoots} is empty. + * + *

Any certificates included in this {@link CertStore} are NOT considered trusted; they will + * be trusted only if they chain to any of the {@link TrustRootsResultBuilder#trustRoots(Set) + * trustRoots}. + * + *

The default is null. + */ public Optional getCertStore() { return Optional.ofNullable(certStore); } + /** + * If non-null, the PolicyQualifiersRejected flag will be set to false during certificate path + * validation. See {@link + * java.security.cert.PKIXParameters#setPolicyQualifiersRejected(boolean)}. + * + *

The given {@link Predicate} will be used to validate the policy tree. The {@link + * Predicate} should return true if the policy tree is acceptable, and false + * otherwise. + * + *

Depending on your "PKIX" JCA provider configuration, this may be required if + * any certificate in the certificate path contains a certificate policies extension marked + * critical. If this is not set, then such a certificate will be rejected by the certificate + * path validator from the default provider. + * + *

Consult the Java + * PKI Programmer's Guide for how to use the {@link PolicyNode} argument of the {@link + * Predicate}. + * + *

The default is null. + */ + public Optional> getPolicyTreeValidator() { + return Optional.ofNullable(policyTreeValidator); + } + public static TrustRootsResultBuilder.Step1 builder() { return new TrustRootsResultBuilder.Step1(); } public static class TrustRootsResultBuilder { public static class Step1 { + /** + * A set of attestation root certificates trusted to certify the relevant attestation + * statement. If the attestation statement is not trusted, or if no trust roots were found, + * this should be an empty set. + */ public TrustRootsResultBuilder trustRoots(@NonNull Set trustRoots) { return new TrustRootsResultBuilder().trustRoots(trustRoots); } } + + /** + * A set of attestation root certificates trusted to certify the relevant attestation + * statement. If the attestation statement is not trusted, or if no trust roots were found, + * this should be an empty set. + */ + // TODO: Let this auto-generate (investigate why Lombok fails to copy javadoc) + public AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder trustRoots( + @NonNull final Set trustRoots) { + if (trustRoots == null) { + throw new java.lang.NullPointerException("trustRoots is marked non-null but is null"); + } + this.trustRoots = trustRoots; + return this; + } + + /** + * A {@link CertStore} of additional CRLs and/or intermediate certificates to use during + * certificate path validation, if any. This will not be used if {@link + * TrustRootsResultBuilder#trustRoots(Set) trustRoots} is empty. + * + *

Any certificates included in this {@link CertStore} are NOT considered trusted; they + * will be trusted only if they chain to any of the {@link + * TrustRootsResultBuilder#trustRoots(Set) trustRoots}. + * + *

The default is null. + */ + // TODO: Let this auto-generate (investigate why Lombok fails to copy javadoc) + public AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder certStore( + final CertStore certStore) { + this.certStore$value = certStore; + certStore$set = true; + return this; + } + + /** + * Whether certificate revocation should be checked during certificate path validation. + * + *

The default is true. + */ + // TODO: Let this auto-generate (investigate why Lombok fails to copy javadoc) + public AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder + enableRevocationChecking(final boolean enableRevocationChecking) { + this.enableRevocationChecking$value = enableRevocationChecking; + enableRevocationChecking$set = true; + return this; + } + + /** + * If non-null, the PolicyQualifiersRejected flag will be set to false during certificate path + * validation. See {@link + * java.security.cert.PKIXParameters#setPolicyQualifiersRejected(boolean)}. + * + *

The given {@link Predicate} will be used to validate the policy tree. The {@link + * Predicate} should return true if the policy tree is acceptable, and + * false + * otherwise. + * + *

Depending on your "PKIX" JCA provider configuration, this may be required + * if any certificate in the certificate path contains a certificate policies extension marked + * critical. If this is not set, then such a certificate will be rejected by the certificate + * path validator from the default provider. + * + *

Consult the Java + * PKI Programmer's Guide for how to use the {@link PolicyNode} argument of the {@link + * Predicate}. + * + *

The default is null. + */ + // TODO: Let this auto-generate (investigate why Lombok fails to copy javadoc) + public AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder policyTreeValidator( + final Predicate policyTreeValidator) { + this.policyTreeValidator$value = policyTreeValidator; + policyTreeValidator$set = true; + return this; + } + } + + /** + * A set of attestation root certificates trusted to certify the relevant attestation statement. + * If the attestation statement is not trusted, or if no trust roots were found, this should be + * an empty set. + */ + // TODO: Let this auto-generate (investigate why Lombok fails to copy javadoc) + @NonNull + public Set getTrustRoots() { + return this.trustRoots; + } + + /** Whether certificate revocation should be checked during certificate path validation. */ + // TODO: Let this auto-generate (investigate why Lombok fails to copy javadoc) + public boolean isEnableRevocationChecking() { + return this.enableRevocationChecking; } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationConveyancePreference.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationConveyancePreference.java index 442849945..8442b736c 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationConveyancePreference.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AttestationConveyancePreference.java @@ -73,7 +73,20 @@ public enum AttestationConveyancePreference { * Indicates that the Relying Party wants to receive the attestation statement as generated by the * authenticator. */ - DIRECT("direct"); + DIRECT("direct"), + + /** + * This value indicates that the Relying Party wants to receive an attestation statement that may + * include uniquely identifying information. This is intended for controlled deployments within an + * enterprise where the organization wishes to tie registrations to specific authenticators. User + * agents MUST NOT provide such an attestation unless the user agent or authenticator + * configuration permits it for the requested RP ID. + * + *

If permitted, the user agent SHOULD signal to the authenticator (at invocation time) that + * enterprise attestation is requested, and convey the resulting AAGUID and attestation statement, + * unaltered, to the Relying Party. + */ + ENTERPRISE("enterprise"); @JsonValue @Getter @NonNull private final String value; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorTransport.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorTransport.java index c8f47b154..884e606d8 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorTransport.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorTransport.java @@ -58,23 +58,54 @@ public class AuthenticatorTransport implements Comparable5.8.4. + * Authenticator Transport Enumeration (enum AuthenticatorTransport) + */ public static final AuthenticatorTransport USB = new AuthenticatorTransport("usb"); /** * Indicates the respective authenticator can be contacted over Near Field Communication (NFC). + * + * @see 5.8.4. + * Authenticator Transport Enumeration (enum AuthenticatorTransport) */ public static final AuthenticatorTransport NFC = new AuthenticatorTransport("nfc"); /** * Indicates the respective authenticator can be contacted over Bluetooth Smart (Bluetooth Low * Energy / BLE). + * + * @see 5.8.4. + * Authenticator Transport Enumeration (enum AuthenticatorTransport) */ public static final AuthenticatorTransport BLE = new AuthenticatorTransport("ble"); + /** + * Indicates the respective authenticator can be contacted using a combination of (often separate) + * data-transport and proximity mechanisms. This supports, for example, authentication on a + * desktop computer using a smartphone. + * + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + * @see 5.8.4. + * Authenticator Transport Enumeration (enum AuthenticatorTransport) + */ + @Deprecated + public static final AuthenticatorTransport HYBRID = new AuthenticatorTransport("hybrid"); + /** * Indicates the respective authenticator is contacted using a client device-specific transport. * These authenticators are not removable from the client device. + * + * @see 5.8.4. + * Authenticator Transport Enumeration (enum AuthenticatorTransport) */ public static final AuthenticatorTransport INTERNAL = new AuthenticatorTransport("internal"); @@ -83,13 +114,13 @@ public class AuthenticatorTransport implements Comparableid is the same as that of any of {@link #USB}, {@link #NFC}, {@link - * #BLE} or {@link #INTERNAL}, returns that constant instance. Otherwise returns a new - * instance containing id. + * #BLE}, {@link #HYBRID} or {@link #INTERNAL}, returns that constant instance. Otherwise + * returns a new instance containing id. * @see #valueOf(String) */ @JsonCreator @@ -101,8 +132,8 @@ public static AuthenticatorTransport of(@NonNull String id) { } /** - * @return If name equals "USB", "NFC", "BLE" - * or "INTERNAL", returns the constant by that name. + * @return If name equals "USB", "NFC", "BLE", + * "HYBRID" or "INTERNAL", returns the constant by that name. * @throws IllegalArgumentException if name is anything else. * @see #of(String) */ @@ -114,6 +145,8 @@ public static AuthenticatorTransport valueOf(String name) { return NFC; case "BLE": return BLE; + case "HYBRID": + return HYBRID; case "INTERNAL": return INTERNAL; default: diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/COSEAlgorithmIdentifier.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/COSEAlgorithmIdentifier.java index 1ba31d5ca..f003121c5 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/COSEAlgorithmIdentifier.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/COSEAlgorithmIdentifier.java @@ -26,9 +26,12 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import com.upokecenter.cbor.CBORException; +import com.upokecenter.cbor.CBORObject; import java.util.Optional; import java.util.stream.Stream; import lombok.Getter; +import lombok.NonNull; /** * A number identifying a cryptographic algorithm. The algorithm identifiers SHOULD be values @@ -42,6 +45,8 @@ public enum COSEAlgorithmIdentifier { EdDSA(-8), ES256(-7), + ES384(-35), + ES512(-36), RS256(-257), RS1(-65535); @@ -51,10 +56,50 @@ public enum COSEAlgorithmIdentifier { this.id = id; } + /** + * Attempt to parse an integer as a {@link COSEAlgorithmIdentifier}. + * + * @param id an integer equal to the {@link #getId() id} of a constant in {@link + * COSEAlgorithmIdentifier} + * @return The {@link COSEAlgorithmIdentifier} instance whose {@link #getId() id} equals id + * , if any. + * @see §5.8.5. + * Cryptographic Algorithm Identifier (typedef COSEAlgorithmIdentifier) + */ public static Optional fromId(long id) { return Stream.of(values()).filter(v -> v.id == id).findAny(); } + /** + * Read the {@link COSEAlgorithmIdentifier} from a public key in COSE_Key format. + * + * @param publicKeyCose a public key in COSE_Key format. + * @return The alg of the publicKeyCose parsed as a {@link + * COSEAlgorithmIdentifier}, if possible. Returns empty if the {@link COSEAlgorithmIdentifier} + * enum has no constant matching the alg value. + * @throws IllegalArgumentException if publicKeyCose is not a well-formed COSE_Key. + */ + public static Optional fromPublicKey(@NonNull ByteArray publicKeyCose) { + final CBORObject ALG = CBORObject.FromObject(3); + final int alg; + try { + CBORObject cose = CBORObject.DecodeFromBytes(publicKeyCose.getBytes()); + if (!cose.ContainsKey(ALG)) { + throw new IllegalArgumentException( + "Public key does not contain an \"alg\"(3) value: " + publicKeyCose); + } + CBORObject algCbor = cose.get(ALG); + if (!(algCbor.isNumber() && algCbor.AsNumber().IsInteger())) { + throw new IllegalArgumentException( + "Public key has non-integer \"alg\"(3) value: " + publicKeyCose); + } + alg = algCbor.AsInt32(); + } catch (CBORException e) { + throw new IllegalArgumentException("Failed to parse public key", e); + } + return fromId(alg); + } + @JsonCreator private static COSEAlgorithmIdentifier fromJson(long id) { return fromId(id) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java index 7da6ea2c4..c1ac9517a 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.java @@ -238,7 +238,7 @@ public static class PublicKeyCredentialCreationOptionsBuilder { private AuthenticatorSelectionCriteria authenticatorSelection = null; public static class MandatoryStages { - private PublicKeyCredentialCreationOptionsBuilder builder = + private final PublicKeyCredentialCreationOptionsBuilder builder = new PublicKeyCredentialCreationOptionsBuilder(); /** @@ -376,6 +376,8 @@ private static List filterAvailableAlgorithms( break; case ES256: + case ES384: + case ES512: KeyFactory.getInstance("EC"); break; @@ -405,6 +407,14 @@ private static List filterAvailableAlgorithms( Signature.getInstance("SHA256withECDSA"); break; + case ES384: + Signature.getInstance("SHA384withECDSA"); + break; + + case ES512: + Signature.getInstance("SHA512withECDSA"); + break; + case RS256: Signature.getInstance("SHA256withRSA"); break; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialDescriptor.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialDescriptor.java index 87548b099..b2487b5c1 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialDescriptor.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialDescriptor.java @@ -112,7 +112,7 @@ public static class PublicKeyCredentialDescriptorBuilder { private Set transports = null; public static class MandatoryStages { - private PublicKeyCredentialDescriptorBuilder builder = + private final PublicKeyCredentialDescriptorBuilder builder = new PublicKeyCredentialDescriptorBuilder(); /** diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialParameters.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialParameters.java index fed7c04b6..e2e59d7b4 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialParameters.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialParameters.java @@ -72,6 +72,20 @@ private PublicKeyCredentialParameters( public static final PublicKeyCredentialParameters ES256 = builder().alg(COSEAlgorithmIdentifier.ES256).build(); + /** + * Algorithm {@link COSEAlgorithmIdentifier#ES384} and type {@link + * PublicKeyCredentialType#PUBLIC_KEY}. + */ + public static final PublicKeyCredentialParameters ES384 = + builder().alg(COSEAlgorithmIdentifier.ES384).build(); + + /** + * Algorithm {@link COSEAlgorithmIdentifier#ES512} and type {@link + * PublicKeyCredentialType#PUBLIC_KEY}. + */ + public static final PublicKeyCredentialParameters ES512 = + builder().alg(COSEAlgorithmIdentifier.ES512).build(); + /** * Algorithm {@link COSEAlgorithmIdentifier#RS1} and type {@link * PublicKeyCredentialType#PUBLIC_KEY}. @@ -92,7 +106,7 @@ public static PublicKeyCredentialParametersBuilder.MandatoryStages builder() { public static class PublicKeyCredentialParametersBuilder { public static class MandatoryStages { - private PublicKeyCredentialParametersBuilder builder = + private final PublicKeyCredentialParametersBuilder builder = new PublicKeyCredentialParametersBuilder(); /** diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java index d5a8baec9..4834d81a4 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialRequestOptions.java @@ -170,7 +170,7 @@ public static class PublicKeyCredentialRequestOptionsBuilder { private List allowCredentials = null; public static class MandatoryStages { - private PublicKeyCredentialRequestOptionsBuilder builder = + private final PublicKeyCredentialRequestOptionsBuilder builder = new PublicKeyCredentialRequestOptionsBuilder(); /** diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/RelyingPartyIdentity.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/RelyingPartyIdentity.java index d299c3804..7067c135d 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/RelyingPartyIdentity.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/RelyingPartyIdentity.java @@ -79,7 +79,7 @@ public static RelyingPartyIdentityBuilder.MandatoryStages builder() { public static class RelyingPartyIdentityBuilder { public static class MandatoryStages { - private RelyingPartyIdentityBuilder builder = new RelyingPartyIdentityBuilder(); + private final RelyingPartyIdentityBuilder builder = new RelyingPartyIdentityBuilder(); /** * A unique identifier for the Relying Party, which sets the = testData.attestation.authenticatorData.getSignatureCounter diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala index ddae4cfa4..337e4aaeb 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala @@ -25,6 +25,7 @@ package com.yubico.webauthn import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode import com.upokecenter.cbor.CBORObject @@ -33,10 +34,15 @@ import com.yubico.internal.util.CertificateParser import com.yubico.internal.util.JacksonCodecs import com.yubico.webauthn.TestAuthenticator.AttestationCert import com.yubico.webauthn.TestAuthenticator.AttestationMaker +import com.yubico.webauthn.TestAuthenticator.AttestationSigner +import com.yubico.webauthn.TpmAttestationStatementVerifier.Attributes +import com.yubico.webauthn.TpmAttestationStatementVerifier.TPM_ALG_NULL +import com.yubico.webauthn.TpmAttestationStatementVerifier.TpmRsaScheme import com.yubico.webauthn.attestation.AttestationTrustSource import com.yubico.webauthn.attestation.AttestationTrustSource.TrustRootsResult import com.yubico.webauthn.data.AttestationObject import com.yubico.webauthn.data.AttestationType +import com.yubico.webauthn.data.AuthenticatorAttestationResponse import com.yubico.webauthn.data.AuthenticatorData import com.yubico.webauthn.data.AuthenticatorSelectionCriteria import com.yubico.webauthn.data.AuthenticatorTransport @@ -63,29 +69,44 @@ import com.yubico.webauthn.extension.uvm.UserVerificationMethod import com.yubico.webauthn.test.Helpers import com.yubico.webauthn.test.RealExamples import com.yubico.webauthn.test.Util.toStepWithUtilities +import org.bouncycastle.asn1.ASN1Encodable +import org.bouncycastle.asn1.ASN1ObjectIdentifier import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.DERUTF8String +import org.bouncycastle.asn1.x500.AttributeTypeAndValue +import org.bouncycastle.asn1.x500.RDN import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNamesBuilder import org.bouncycastle.cert.jcajce.JcaX500NameUtil +import org.bouncycastle.jce.provider.BouncyCastleProvider import org.junit.runner.RunWith import org.mockito.Mockito +import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks import java.io.IOException +import java.math.BigInteger import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.security.KeyFactory import java.security.KeyPair import java.security.MessageDigest import java.security.PrivateKey +import java.security.Security import java.security.SignatureException import java.security.cert.CRL import java.security.cert.CertStore import java.security.cert.CollectionCertStoreParameters +import java.security.cert.PolicyNode import java.security.cert.X509Certificate +import java.security.interfaces.ECPublicKey import java.security.interfaces.RSAPublicKey import java.time.Clock import java.time.Instant @@ -93,6 +114,7 @@ import java.time.ZoneOffset import java.util import java.util.Collections import java.util.Optional +import java.util.function.Predicate import javax.security.auth.x500.X500Principal import scala.jdk.CollectionConverters._ import scala.jdk.OptionConverters.RichOption @@ -103,7 +125,7 @@ import scala.util.Try @RunWith(classOf[JUnitRunner]) class RelyingPartyRegistrationSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks with TestWithEachProvider { @@ -132,20 +154,14 @@ class RelyingPartyRegistrationSpec Helpers.CredentialRepository.unimplemented, attestationTrustSource: Option[AttestationTrustSource] = None, origins: Option[Set[String]] = None, - preferredPubkeyParams: List[PublicKeyCredentialParameters] = Nil, - rp: RelyingPartyIdentity = RelyingPartyIdentity - .builder() - .id("localhost") - .name("Test party") - .build(), + pubkeyCredParams: Option[List[PublicKeyCredentialParameters]] = None, testData: RegistrationTestData, clock: Clock = Clock.systemUTC(), ): FinishRegistrationSteps = { var builder = RelyingParty .builder() - .identity(rp) + .identity(testData.rpId) .credentialRepository(credentialRepository) - .preferredPubkeyParams(preferredPubkeyParams.asJava) .allowOriginPort(allowOriginPort) .allowOriginSubdomain(allowOriginSubdomain) .allowUntrustedAttestation(allowUntrustedAttestation) @@ -160,7 +176,11 @@ class RelyingPartyRegistrationSpec builder .build() ._finishRegistration( - testData.request, + pubkeyCredParams + .map(pkcp => + testData.request.toBuilder.pubKeyCredParams(pkcp.asJava).build() + ) + .getOrElse(testData.request), testData.response, callerTokenBindingId.toJava, ) @@ -177,6 +197,7 @@ class RelyingPartyRegistrationSpec trustedCert: X509Certificate, crls: Option[Set[CRL]] = None, enableRevocationChecking: Boolean = true, + policyTreeValidator: Option[Predicate[PolicyNode]] = None, ): AttestationTrustSource = (_: util.List[X509Certificate], _: Optional[ByteArray]) => { TrustRootsResult @@ -193,6 +214,7 @@ class RelyingPartyRegistrationSpec .orNull ) .enableRevocationChecking(enableRevocationChecking) + .policyTreeValidator(policyTreeValidator.orNull) .build() } @@ -1113,9 +1135,9 @@ class RelyingPartyRegistrationSpec checkKnown("fido-u2f") checkKnown("none") checkKnown("packed") + checkKnown("tpm") checkUnknown("android-key") - checkUnknown("tpm") checkUnknown("FIDO-U2F") checkUnknown("Fido-U2F") @@ -1241,9 +1263,11 @@ class RelyingPartyRegistrationSpec WebAuthnTestCodecs.publicKeyToCose( TestAuthenticator .generateKeypair( - WebAuthnTestCodecs.getCoseAlgId( - decoded.getAttestedCredentialData.get.getCredentialPublicKey - ) + COSEAlgorithmIdentifier + .fromPublicKey( + decoded.getAttestedCredentialData.get.getCredentialPublicKey + ) + .get ) .getPublic ) @@ -1786,8 +1810,8 @@ class RelyingPartyRegistrationSpec it("Succeeds for an RS1 test case.") { val testData = RegistrationTestData.Packed.SelfAttestationRs1 - val alg = WebAuthnCodecs - .getCoseKeyAlg( + val alg = COSEAlgorithmIdentifier + .fromPublicKey( testData.response.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey ) .get @@ -2058,14 +2082,836 @@ class RelyingPartyRegistrationSpec } } - ignore("The tpm statement format is supported.") { - val steps = - finishRegistration(testData = RegistrationTestData.Tpm.PrivacyCa) - val step: FinishRegistrationSteps#Step19 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + describe("The tpm statement format") { - step.validations shouldBe a[Success[_]] - step.tryNext shouldBe a[Success[_]] + it("is supported.") { + val testData = RealExamples.WindowsHelloTpm.asRegistrationTestData + val steps = + finishRegistration( + testData = testData, + origins = + Some(Set("https://dev.d2urpypvrhb05x.amplifyapp.com")), + credentialRepository = Helpers.CredentialRepository.empty, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationRootCertificate.get, + enableRevocationChecking = false, + policyTreeValidator = Some(_ => true), + ) + ), + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.run.getAttestationType should be( + AttestationType.ATTESTATION_CA + ) + } + + describe("is supported and accepts test-generated values:") { + + val emptySubject = new X500Name(Array.empty[RDN]) + val tcgAtTpmManufacturer = new AttributeTypeAndValue( + new ASN1ObjectIdentifier("2.23.133.2.1"), + new DERUTF8String("id:00000000"), + ) + val tcgAtTpmModel = new AttributeTypeAndValue( + new ASN1ObjectIdentifier("2.23.133.2.2"), + new DERUTF8String("TEST_Yubico_java-webauthn-server"), + ) + val tcgAtTpmVersion = new AttributeTypeAndValue( + new ASN1ObjectIdentifier("2.23.133.2.3"), + new DERUTF8String("id:00000000"), + ) + val tcgKpAikCertificate = new ASN1ObjectIdentifier("2.23.133.8.3") + + def makeCred( + authDataAndKeypair: Option[(ByteArray, KeyPair)] = None, + clientDataJson: Option[String] = None, + subject: X500Name = emptySubject, + rdn: Array[AttributeTypeAndValue] = + Array(tcgAtTpmManufacturer, tcgAtTpmModel, tcgAtTpmVersion), + extendedKeyUsage: Array[ASN1Encodable] = + Array(tcgKpAikCertificate), + ver: Option[String] = Some("2.0"), + magic: ByteArray = + TpmAttestationStatementVerifier.TPM_GENERATED_VALUE, + `type`: ByteArray = + TpmAttestationStatementVerifier.TPM_ST_ATTEST_CERTIFY, + modifyAttestedName: ByteArray => ByteArray = an => an, + overrideCosePubkey: Option[ByteArray] = None, + aaguidInCert: Option[ByteArray] = None, + attributes: Option[Long] = None, + symmetric: Option[Int] = None, + scheme: Option[Int] = None, + ): ( + PublicKeyCredential[ + AuthenticatorAttestationResponse, + ClientRegistrationExtensionOutputs, + ], + KeyPair, + List[(X509Certificate, PrivateKey)], + ) = { + val (authData, credentialKeypair) = + authDataAndKeypair.getOrElse( + TestAuthenticator.createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.ES256 + ) + ) + + TestAuthenticator.createCredential( + authDataBytes = authData, + credentialKeypair = credentialKeypair, + clientDataJson = clientDataJson.getOrElse( + TestAuthenticator.createClientData() + ), + attestationMaker = AttestationMaker.tpm( + cert = AttestationSigner.ca( + alg = COSEAlgorithmIdentifier.ES256, + certSubject = subject, + aaguid = aaguidInCert, + certExtensions = List( + ( + Extension.subjectAlternativeName.getId, + true, + new GeneralNamesBuilder() + .addName( + new GeneralName(new X500Name(Array(new RDN(rdn)))) + ) + .build(), + ), + ( + Extension.extendedKeyUsage.getId, + true, + new DERSequence(extendedKeyUsage), + ), + ), + validFrom = Instant.now(), + validTo = Instant.now().plusSeconds(600), + ), + ver = ver, + magic = magic, + `type` = `type`, + modifyAttestedName = modifyAttestedName, + overrideCosePubkey = overrideCosePubkey, + attributes = attributes, + symmetric = symmetric, + scheme = scheme, + ), + ) + } + + def init( + testData: RegistrationTestData + ): FinishRegistrationSteps#Step19 = { + val steps = + finishRegistration( + credentialRepository = Helpers.CredentialRepository.empty, + testData = testData, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationCertChain.last._1, + enableRevocationChecking = false, + ) + ), + ) + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + } + + def check( + testData: RegistrationTestData, + pubKeyCredParams: Option[ + List[PublicKeyCredentialParameters] + ] = None, + ) = { + val steps = + finishRegistration( + testData = testData, + credentialRepository = Helpers.CredentialRepository.empty, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationRootCertificate.getOrElse( + testData.attestationCertChain.last._1 + ), + enableRevocationChecking = false, + ) + ), + pubkeyCredParams = pubKeyCredParams, + clock = Clock.fixed( + TestAuthenticator.Defaults.certValidFrom, + ZoneOffset.UTC, + ), + ) + val step: FinishRegistrationSteps#Step19 = + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.run.getAttestationType should be( + AttestationType.ATTESTATION_CA + ) + } + + it("ES256.") { + check(RegistrationTestData.Tpm.ValidEs256) + } + it("ES384.") { + check(RegistrationTestData.Tpm.ValidEs384) + } + it("ES512.") { + check(RegistrationTestData.Tpm.ValidEs512) + } + it("RS256.") { + check(RegistrationTestData.Tpm.ValidRs256) + } + it("RS1.") { + check( + RegistrationTestData.Tpm.ValidRs1, + pubKeyCredParams = + Some(List(PublicKeyCredentialParameters.RS1)), + ) + } + + it("Default cert generator settings.") { + val testData = (RegistrationTestData.from _).tupled(makeCred()) + val step = init(testData) + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + step.run.getAttestationType should be( + AttestationType.ATTESTATION_CA + ) + } + + describe("Verify that the public key specified by the parameters and unique fields of pubArea is identical to the credentialPublicKey in the attestedCredentialData in authenticatorData.") { + it("Fails when EC key is unrelated but on the same curve.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + overrideCosePubkey = Some( + WebAuthnTestCodecs.ecPublicKeyToCose( + TestAuthenticator + .generateEcKeypair() + .getPublic + .asInstanceOf[ECPublicKey] + ) + ) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + step.validations.failed.get.getMessage should include( + "EC X coordinate differs" + ) + } + + it("Fails when EC key is on a different curve.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + overrideCosePubkey = Some( + WebAuthnTestCodecs.ecPublicKeyToCose( + TestAuthenticator + .generateEcKeypair("secp384r1") + .getPublic + .asInstanceOf[ECPublicKey] + ) + ) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + step.validations.failed.get.getMessage should include( + "elliptic curve differs" + ) + } + + it("Fails when EC key has an inverted Y coordinate.") { + val (authData, keypair) = + TestAuthenticator.createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.ES256 + ) + + val cose = CBORObject.DecodeFromBytes( + WebAuthnTestCodecs + .ecPublicKeyToCose( + keypair.getPublic.asInstanceOf[ECPublicKey] + ) + .getBytes + ) + val yneg = TestAuthenticator.Es256PrimeModulus + .subtract( + new BigInteger(1, cose.get(-3).GetByteString()) + ) + val ynegBytes = yneg.toByteArray.dropWhile(_ == 0) + cose.Set( + -3, + Array.fill[Byte](32 - ynegBytes.length)(0) ++ ynegBytes, + ) + + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some((authData, keypair)), + overrideCosePubkey = + Some(new ByteArray(cose.EncodeToBytes())), + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + step.validations.failed.get.getMessage should include( + "EC Y coordinate differs" + ) + } + + it("Fails when RSA key is unrelated.") { + val (authData, keypair) = + TestAuthenticator.createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.RS256 + ) + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some((authData, keypair)), + overrideCosePubkey = Some( + WebAuthnTestCodecs.rsaPublicKeyToCose( + TestAuthenticator + .generateRsaKeypair() + .getPublic + .asInstanceOf[RSAPublicKey], + COSEAlgorithmIdentifier.RS256, + ) + ), + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + it("""The "ver" property must equal "2.0".""") { + forAll( + Gen.option( + Gen.oneOf( + Gen.numStr, + for { + major <- arbitrary[Int] + minor <- arbitrary[Int] + } yield s"${major}.${minor}", + arbitrary[String], + ) + ) + ) { ver: Option[String] => + whenever(!ver.contains("2.0")) { + val testData = + (RegistrationTestData.from _).tupled(makeCred(ver = ver)) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""Verify that magic is set to TPM_GENERATED_VALUE.""") { + forAll(byteArray(4)) { magic => + whenever( + magic != TpmAttestationStatementVerifier.TPM_GENERATED_VALUE + ) { + val testData = (RegistrationTestData.from _).tupled( + makeCred(magic = magic) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""Verify that type is set to TPM_ST_ATTEST_CERTIFY.""") { + forAll( + Gen.oneOf( + byteArray(2), + flipOneBit( + TpmAttestationStatementVerifier.TPM_ST_ATTEST_CERTIFY + ), + ) + ) { `type` => + whenever( + `type` != TpmAttestationStatementVerifier.TPM_ST_ATTEST_CERTIFY + ) { + val testData = (RegistrationTestData.from _).tupled( + makeCred(`type` = `type`) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""Verify that extraData is set to the hash of attToBeSigned using the hash algorithm employed in "alg".""") { + val testData = (RegistrationTestData.from _).tupled(makeCred()) + val json = JacksonCodecs.json() + val clientData = json + .readTree(testData.clientDataJson) + .asInstanceOf[ObjectNode] + clientData.set( + "challenge", + jsonFactory.textNode( + Crypto + .sha256( + ByteArray.fromBase64Url( + clientData.get("challenge").textValue + ) + ) + .getBase64Url + ), + ) + val mutatedTestData = testData.copy(clientDataJson = + json.writeValueAsString(clientData) + ) + val step = init(mutatedTestData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + + it("Verify that attested contains a TPMS_CERTIFY_INFO structure as specified in [TPMv2-Part2] section 10.12.3, whose name field contains a valid Name for pubArea, as computed using the algorithm in the nameAlg field of pubArea using the procedure specified in [TPMv2-Part1] section 16.") { + forAll( + Gen.oneOf( + for { + flipBitIndex: Int <- + Gen.oneOf(Gen.const(0), Gen.posNum[Int]) + } yield (an: ByteArray) => + flipBit(flipBitIndex % (8 * an.size()))(an), + for { + attestedName <- arbitrary[ByteArray] + } yield (_: ByteArray) => attestedName, + ) + ) { (modifyAttestedName: ByteArray => ByteArray) => + val testData = (RegistrationTestData.from _).tupled( + makeCred(modifyAttestedName = modifyAttestedName) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + it("Verify the sig is a valid signature over certInfo using the attestation public key in aikCert with the algorithm specified in alg.") { + val testData = (RegistrationTestData.from _).tupled(makeCred()) + forAll( + flipOneBit( + new ByteArray( + new AttestationObject( + testData.attestationObject + ).getAttestationStatement.get("sig").binaryValue() + ) + ) + ) { sig => + val mutatedTestData = testData.updateAttestationObject( + "attStmt", + attStmt => + attStmt + .asInstanceOf[ObjectNode] + .set[ObjectNode]( + "sig", + jsonFactory.binaryNode(sig.getBytes), + ), + ) + val step = init(mutatedTestData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("Verify that aikCert meets the requirements in §8.3.1 TPM Attestation Statement Certificate Requirements.") { + it("Version MUST be set to 3.") { + val testData = + (RegistrationTestData.from _).tupled(makeCred()) + forAll(arbitrary[Byte] suchThat { _ != 2 }) { version => + val mutatedTestData = testData.updateAttestationObject( + "attStmt", + attStmt => { + val origAikCert = attStmt + .get("x5c") + .get(0) + .binaryValue + + val x509VerOffset = 12 + attStmt + .get("x5c") + .asInstanceOf[ArrayNode] + .set(0, origAikCert.updated(x509VerOffset, version)) + attStmt + }, + ) + val step = init(mutatedTestData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("Subject field MUST be set to empty.") { + it("Fails if a subject is set.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(subject = + new X500Name( + Array( + new RDN( + Array( + tcgAtTpmManufacturer, + tcgAtTpmModel, + tcgAtTpmVersion, + ) + ) + ) + ) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("The Subject Alternative Name extension MUST be set as defined in [TPMv2-EK-Profile] section 3.2.9.") { + it("Fails when manufacturer is absent.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(rdn = Array(tcgAtTpmModel, tcgAtTpmVersion)) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + + it("Fails when model is absent.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(rdn = + Array(tcgAtTpmManufacturer, tcgAtTpmVersion) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + + it("Fails when version is absent.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(rdn = Array(tcgAtTpmManufacturer, tcgAtTpmModel)) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("The Extended Key Usage extension MUST contain the OID 2.23.133.8.3 (\"joint-iso-itu-t(2) internationalorganizations(23) 133 tcg-kp(8) tcg-kp-AIKCertificate(3)\").") { + it("Fails when extended key usage is empty.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(extendedKeyUsage = Array.empty) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + + it("""Fails when extended key usage contains only "serverAuth".""") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(extendedKeyUsage = + Array(new ASN1ObjectIdentifier("1.3.6.1.5.5.7.3.1")) + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("The Basic Constraints extension MUST have the CA component set to false.") { + it( + "Fails when the attestation cert is a self-signed CA cert." + ) { + val testData = (RegistrationTestData.from _).tupled( + TestAuthenticator.createBasicAttestedCredential( + keyAlgorithm = COSEAlgorithmIdentifier.ES256, + attestationMaker = AttestationMaker.tpm( + AttestationSigner.selfsigned( + alg = COSEAlgorithmIdentifier.ES256, + certSubject = emptySubject, + issuerSubject = + Some(TestAuthenticator.Defaults.caCertSubject), + certExtensions = List( + ( + Extension.subjectAlternativeName.getId, + true, + new GeneralNamesBuilder() + .addName( + new GeneralName( + new X500Name( + Array( + new RDN( + Array( + tcgAtTpmManufacturer, + tcgAtTpmModel, + tcgAtTpmVersion, + ) + ) + ) + ) + ) + ) + .build(), + ), + ( + Extension.extendedKeyUsage.getId, + true, + new DERSequence(tcgKpAikCertificate), + ), + ), + validFrom = Instant.now(), + validTo = Instant.now().plusSeconds(600), + isCa = true, + ) + ), + ) + ) + val step = init(testData) + testData.attestationCertChain.head._1.getBasicConstraints should not be (-1) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + describe("An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL Distribution Point extension [RFC5280] are both OPTIONAL as the status of many attestation certificates is available through metadata services. See, for example, the FIDO Metadata Service [FIDOMetadataService].") { + it("Nothing to test.") {} + } + } + + describe("If aikCert contains an extension with OID 1.3.6.1.4.1.45724.1.1.4 (id-fido-gen-ce-aaguid) verify that the value of this extension matches the aaguid in authenticatorData.") { + it("Succeeds if the cert does not have the extension.") { + val testData = (RegistrationTestData.from _).tupled( + makeCred(aaguidInCert = None) + ) + val step = init(testData) + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + + it( + "Succeeds if the cert has the extension with the right value." + ) { + forAll(byteArray(16)) { aaguid => + val (authData, keypair) = + TestAuthenticator.createAuthenticatorData( + aaguid = aaguid, + keyAlgorithm = COSEAlgorithmIdentifier.ES256, + ) + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some((authData, keypair)), + aaguidInCert = Some(aaguid), + ) + ) + val step = init(testData) + + step.validations shouldBe a[Success[_]] + step.tryNext shouldBe a[Success[_]] + } + } + + it( + "Fails if the cert has the extension with the wrong value." + ) { + forAll(byteArray(16), byteArray(16)) { + (aaguidInCred, aaguidInCert) => + whenever(aaguidInCred != aaguidInCert) { + val (authData, keypair) = + TestAuthenticator.createAuthenticatorData( + aaguid = aaguidInCred, + keyAlgorithm = COSEAlgorithmIdentifier.ES256, + ) + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some((authData, keypair)), + aaguidInCert = Some(aaguidInCert), + ) + ) + val step = init(testData) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + } + + describe("Other requirements:") { + it("RSA keys must have the SIGN_ENCRYPT attribute.") { + forAll(Gen.chooseNum(0, Int.MaxValue.toLong * 2 + 1)) { + attributes: Long => + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some( + TestAuthenticator + .createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.RS256 + ) + ), + attributes = + Some(attributes & ~Attributes.SIGN_ENCRYPT), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.RS256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + it("""RSA keys must have "symmetric" set to TPM_ALG_NULL""") { + forAll(Gen.chooseNum(0, Short.MaxValue * 2 + 1)) { + symmetric: Int => + whenever(symmetric != TPM_ALG_NULL) { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some( + TestAuthenticator + .createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.RS256 + ) + ), + symmetric = Some(symmetric), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.RS256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""RSA keys must have "scheme" set to TPM_ALG_RSASSA or TPM_ALG_NULL""") { + forAll(Gen.chooseNum(0, Short.MaxValue * 2 + 1)) { + scheme: Int => + whenever( + scheme != TpmRsaScheme.RSASSA && scheme != TPM_ALG_NULL + ) { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some( + TestAuthenticator + .createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.RS256 + ) + ), + scheme = Some(scheme), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.RS256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("ECC keys must have the SIGN_ENCRYPT attribute.") { + forAll(Gen.chooseNum(0, Int.MaxValue.toLong * 2 + 1)) { + attributes: Long => + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some( + TestAuthenticator + .createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.ES256 + ) + ), + attributes = + Some(attributes & ~Attributes.SIGN_ENCRYPT), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.ES256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + + it("""ECC keys must have "symmetric" set to TPM_ALG_NULL""") { + forAll(Gen.chooseNum(0, Short.MaxValue * 2 + 1)) { + symmetric: Int => + whenever(symmetric != TPM_ALG_NULL) { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some( + TestAuthenticator + .createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.ES256 + ) + ), + symmetric = Some(symmetric), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.ES256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + + it("""ECC keys must have "scheme" set to TPM_ALG_NULL""") { + forAll(Gen.chooseNum(0, Short.MaxValue * 2 + 1)) { + scheme: Int => + whenever(scheme != TPM_ALG_NULL) { + val testData = (RegistrationTestData.from _).tupled( + makeCred( + authDataAndKeypair = Some( + TestAuthenticator + .createAuthenticatorData(keyAlgorithm = + COSEAlgorithmIdentifier.ES256 + ) + ), + scheme = Some(scheme), + ) + ) + val step = init(testData) + testData.alg should be(COSEAlgorithmIdentifier.ES256) + + step.validations shouldBe a[Failure[_]] + step.tryNext shouldBe a[Failure[_]] + } + } + } + } + } } ignore("The android-key statement format is supported.") { @@ -2088,7 +2934,6 @@ class RelyingPartyRegistrationSpec val steps = finishRegistration( testData = defaultTestData, allowUntrustedAttestation = false, - rp = defaultTestData.rpId, ) val step: FinishRegistrationSteps#Step19 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next @@ -2224,8 +3069,7 @@ class RelyingPartyRegistrationSpec describe("5. If successful, return implementation-specific values representing attestation type Basic and attestation trust path x5c.") { it("The real example succeeds.") { val steps = finishRegistration( - testData = testDataContainer.RealExample, - rp = testDataContainer.RealExample.rpId, + testData = testDataContainer.RealExample ) val step: FinishRegistrationSteps#Step19 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next @@ -2256,12 +3100,7 @@ class RelyingPartyRegistrationSpec it("The android-safetynet statement format is supported.") { val steps = finishRegistration( - testData = RegistrationTestData.AndroidSafetynet.RealExample, - rp = RelyingPartyIdentity - .builder() - .id("demo.yubico.com") - .name("") - .build(), + testData = RegistrationTestData.AndroidSafetynet.RealExample ) val step: FinishRegistrationSteps#Step19 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next @@ -2272,9 +3111,7 @@ class RelyingPartyRegistrationSpec it("The apple statement format is supported.") { val steps = finishRegistration( - testData = - RealExamples.AppleAttestationIos.asRegistrationTestData, - rp = RealExamples.AppleAttestationIos.rp, + testData = RealExamples.AppleAttestationIos.asRegistrationTestData ) val step: FinishRegistrationSteps#Step19 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next @@ -2352,7 +3189,6 @@ class RelyingPartyRegistrationSpec val steps = finishRegistration( testData = testData, attestationTrustSource = Some(attestationTrustSource), - rp = testData.rpId, ) val step: FinishRegistrationSteps#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next @@ -2372,7 +3208,6 @@ class RelyingPartyRegistrationSpec val steps = finishRegistration( testData = testData, attestationTrustSource = None, - rp = testData.rpId, ) val step: FinishRegistrationSteps#Step20 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next @@ -2540,14 +3375,16 @@ class RelyingPartyRegistrationSpec clock: Clock, trustedRootCert: Option[X509Certificate] = None, enableRevocationChecking: Boolean = true, + origins: Option[Set[String]] = None, + policyTreeValidator: Option[Predicate[PolicyNode]] = None, ): Unit = { it("is rejected if untrusted attestation is not allowed and the trust source does not trust it.") { val steps = finishRegistration( allowUntrustedAttestation = false, testData = testData, attestationTrustSource = Some(emptyTrustSource), - rp = testData.rpId, clock = clock, + origins = origins, ) val step: FinishRegistrationSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next @@ -2562,8 +3399,8 @@ class RelyingPartyRegistrationSpec allowUntrustedAttestation = true, testData = testData, attestationTrustSource = Some(emptyTrustSource), - rp = testData.rpId, clock = clock, + origins = origins, ) val step: FinishRegistrationSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next @@ -2594,13 +3431,14 @@ class RelyingPartyRegistrationSpec ) }), enableRevocationChecking = enableRevocationChecking, + policyTreeValidator = policyTreeValidator, ) ) val steps = finishRegistration( testData = testData, attestationTrustSource = attestationTrustSource, - rp = testData.rpId, clock = clock, + origins = origins, ) val step: FinishRegistrationSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next @@ -2645,13 +3483,14 @@ class RelyingPartyRegistrationSpec .trustRoots(Collections.emptySet()) .certStore(certStore) .enableRevocationChecking(enableRevocationChecking) + .policyTreeValidator(policyTreeValidator.orNull) .build() } val steps = finishRegistration( testData = testData, attestationTrustSource = Some(attestationTrustSource), - rp = testData.rpId, clock = clock, + origins = origins, ) val step: FinishRegistrationSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next @@ -2673,13 +3512,14 @@ class RelyingPartyRegistrationSpec .trustRoots(Collections.singleton(rootCert)) .certStore(certStore) .enableRevocationChecking(enableRevocationChecking) + .policyTreeValidator(policyTreeValidator.orNull) .build() } val steps = finishRegistration( testData = testData, attestationTrustSource = Some(attestationTrustSource), - rp = testData.rpId, clock = clock, + origins = origins, ) val step: FinishRegistrationSteps#Step21 = steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next @@ -2737,8 +3577,81 @@ class RelyingPartyRegistrationSpec ), ) } - } + describe("A tpm attestation") { + val testData = RealExamples.WindowsHelloTpm.asRegistrationTestData + generateTests( + testData = testData, + clock = Clock.fixed( + Instant.parse("2022-08-25T16:00:00Z"), + ZoneOffset.UTC, + ), + origins = Some(Set(testData.clientData.getOrigin)), + trustedRootCert = Some(testData.attestationRootCertificate.get), + enableRevocationChecking = false, + policyTreeValidator = Some(_ => true), + ) + } + + describe("Critical certificate policy extensions") { + def init( + policyTreeValidator: Option[Predicate[PolicyNode]] + ): FinishRegistrationSteps#Step21 = { + val testData = + RealExamples.WindowsHelloTpm.asRegistrationTestData + val clock = Clock.fixed( + Instant.parse("2022-08-25T16:00:00Z"), + ZoneOffset.UTC, + ) + val steps = finishRegistration( + allowUntrustedAttestation = false, + origins = Some(Set(testData.clientData.getOrigin)), + testData = testData, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationRootCertificate.get, + enableRevocationChecking = false, + policyTreeValidator = policyTreeValidator, + ) + ), + clock = clock, + ) + + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + } + + it("are rejected if no policy tree validator is set.") { + // BouncyCastle provider does not reject critical policy extensions + // TODO Mark test as ignored instead of just skipping (assume() and cancel() currently break pitest) + if ( + !Security.getProviders + .exists(p => p.isInstanceOf[BouncyCastleProvider]) + ) { + val step = init(policyTreeValidator = None) + + step.validations shouldBe a[Failure[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + } + + it("are accepted if a policy tree validator is set and accepts the policy tree.") { + val step = init(policyTreeValidator = Some(_ => true)) + + step.validations shouldBe a[Success[_]] + step.attestationTrusted should be(true) + step.tryNext shouldBe a[Success[_]] + } + + it("are rejected if a policy tree validator is set and does not accept the policy tree.") { + val step = init(policyTreeValidator = Some(_ => false)) + + step.validations shouldBe a[Failure[_]] + step.attestationTrusted should be(false) + step.tryNext shouldBe a[Failure[_]] + } + } + } } describe("22. Check that the credentialId is not yet registered to any other user. If registration is requested for a credential that is already registered to a different user, the Relying Party SHOULD fail this registration ceremony, or it MAY decide to accept the registration, e.g. while deleting the older registration.") { @@ -2904,7 +3817,7 @@ class RelyingPartyRegistrationSpec testUntrusted(RegistrationTestData.AndroidSafetynet.BasicAttestation) testUntrusted(RegistrationTestData.FidoU2f.BasicAttestation) testUntrusted(RegistrationTestData.NoneAttestation.Default) - testUntrusted(RegistrationTestData.Tpm.PrivacyCa) + testUntrusted(RealExamples.WindowsHelloTpm.asRegistrationTestData) } } } @@ -2995,17 +3908,24 @@ class RelyingPartyRegistrationSpec } it("accept TPM attestations but report they're untrusted.") { - val result = rp.finishRegistration( - FinishRegistrationOptions - .builder() - .request(request) - .response(RegistrationTestData.Tpm.PrivacyCa.response) - .build() - ) + val testData = RealExamples.WindowsHelloTpm.asRegistrationTestData + val result = rp.toBuilder + .identity(testData.rpId) + .origins(Set("https://dev.d2urpypvrhb05x.amplifyapp.com").asJava) + .build() + .finishRegistration( + FinishRegistrationOptions + .builder() + .request( + request.toBuilder.challenge(testData.responseChallenge).build() + ) + .response(testData.response) + .build() + ) result.isAttestationTrusted should be(false) result.getKeyId.getId should equal( - RegistrationTestData.Tpm.PrivacyCa.response.getId + RealExamples.WindowsHelloTpm.asRegistrationTestData.response.getId ) } @@ -3521,40 +4441,66 @@ class RelyingPartyRegistrationSpec } } - it("exposes getAttestationTrustPath() with the attestation trust path, if any.") { - val testData = RegistrationTestData.FidoU2f.BasicAttestation - val steps = finishRegistration( - testData = testData, - attestationTrustSource = Some( - trustSourceWith( - testData.attestationCertChain.last._1, - crls = Some( - testData.attestationCertChain - .map({ - case (cert, key) => - TestAuthenticator.buildCrl( - JcaX500NameUtil.getSubject(cert), - key, - "SHA256withECDSA", - TestAuthenticator.Defaults.certValidFrom, - TestAuthenticator.Defaults.certValidTo, - ) - }) - .toSet - ), - ) - ), - credentialRepository = Helpers.CredentialRepository.empty, - clock = Clock.fixed( - TestAuthenticator.Defaults.certValidFrom, - ZoneOffset.UTC, - ), - ) - val result = steps.run() - result.isAttestationTrusted should be(true) - result.getAttestationTrustPath.toScala.map(_.asScala) should equal( - Some(testData.attestationCertChain.init.map(_._1)) - ) + describe( + "exposes getAttestationTrustPath() with the attestation trust path" + ) { + it("for a fido-u2f attestation.") { + val testData = RegistrationTestData.FidoU2f.BasicAttestation + val steps = finishRegistration( + testData = testData, + attestationTrustSource = Some( + trustSourceWith( + testData.attestationCertChain.last._1, + crls = Some( + testData.attestationCertChain + .map({ + case (cert, key) => + TestAuthenticator.buildCrl( + JcaX500NameUtil.getSubject(cert), + key, + "SHA256withECDSA", + TestAuthenticator.Defaults.certValidFrom, + TestAuthenticator.Defaults.certValidTo, + ) + }) + .toSet + ), + ) + ), + credentialRepository = Helpers.CredentialRepository.empty, + clock = Clock.fixed( + TestAuthenticator.Defaults.certValidFrom, + ZoneOffset.UTC, + ), + ) + val result = steps.run() + result.isAttestationTrusted should be(true) + result.getAttestationTrustPath.toScala.map(_.asScala) should equal( + Some(testData.attestationCertChain.init.map(_._1)) + ) + } + + it("for a tpm attestation.") { + val testData = RealExamples.WindowsHelloTpm.asRegistrationTestData + val steps = finishRegistration( + testData = testData, + origins = Some(Set("https://dev.d2urpypvrhb05x.amplifyapp.com")), + attestationTrustSource = Some( + trustSourceWith( + testData.attestationRootCertificate.get, + enableRevocationChecking = false, + policyTreeValidator = Some(_ => true), + ) + ), + credentialRepository = Helpers.CredentialRepository.empty, + clock = Clock.fixed( + Instant.parse("2022-05-11T12:34:50Z"), + ZoneOffset.UTC, + ), + ) + val result = steps.run() + result.isAttestationTrusted should be(true) + } } it("exposes getAaguid() with the authenticator AAGUID.") { @@ -3574,8 +4520,57 @@ class RelyingPartyRegistrationSpec } describe("RelyingParty.finishRegistration") { - it("throws RegistrationFailedException in case of errors.") { + it("supports 1023 bytes long credential IDs.") { + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Test party") + .build() + ) + .credentialRepository(Helpers.CredentialRepository.empty) + .build() + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user( + UserIdentity + .builder() + .name("test") + .displayName("Test Testsson") + .id(new ByteArray(Array())) + .build() + ) + .build() + ) + + forAll(byteArray(1023)) { credId => + val credential = TestAuthenticator + .createUnattestedCredential(challenge = pkcco.getChallenge) + ._1 + .toBuilder() + .id(credId) + .build() + + val result = Try( + rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(pkcco) + .response(credential) + .build() + ) + ) + result shouldBe a[Success[_]] + result.get.getKeyId.getId should equal(credId) + result.get.getKeyId.getId.size should be(1023) + } + } + + it("throws RegistrationFailedException in case of errors.") { val rp = RelyingParty .builder() .identity( diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala index 055348a73..b0893616f 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala @@ -44,8 +44,8 @@ import com.yubico.webauthn.extension.appid.Generators._ import org.junit.runner.RunWith import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @@ -56,7 +56,7 @@ import scala.jdk.OptionConverters.RichOptional @RunWith(classOf[JUnitRunner]) class RelyingPartyStartOperationSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala index 6eadda8c8..034e2338d 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyUserIdentificationSpec.scala @@ -33,8 +33,8 @@ import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.PublicKeyCredentialDescriptor import com.yubico.webauthn.data.RelyingPartyIdentity import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import java.security.KeyPair @@ -47,7 +47,7 @@ import scala.util.Success import scala.util.Try @RunWith(classOf[JUnitRunner]) -class RelyingPartyUserIdentificationSpec extends FunSpec with Matchers { +class RelyingPartyUserIdentificationSpec extends AnyFunSpec with Matchers { private object Defaults { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala index 88f06de19..1fd23b828 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala @@ -27,9 +27,14 @@ package com.yubico.webauthn import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode +import com.upokecenter.cbor.CBORObject import com.yubico.internal.util.BinaryUtil import com.yubico.internal.util.CertificateParser import com.yubico.internal.util.JacksonCodecs +import com.yubico.webauthn.TpmAttestationStatementVerifier.Attributes +import com.yubico.webauthn.TpmAttestationStatementVerifier.TpmAlgAsym +import com.yubico.webauthn.TpmAttestationStatementVerifier.TpmAlgHash +import com.yubico.webauthn.TpmAttestationStatementVerifier.TpmRsaScheme import com.yubico.webauthn.data.AuthenticatorAssertionResponse import com.yubico.webauthn.data.AuthenticatorAttestationResponse import com.yubico.webauthn.data.AuthenticatorData @@ -39,6 +44,7 @@ import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions +import org.bouncycastle.asn1.ASN1Encodable import org.bouncycastle.asn1.ASN1ObjectIdentifier import org.bouncycastle.asn1.ASN1Primitive import org.bouncycastle.asn1.DEROctetString @@ -110,9 +116,24 @@ object TestAuthenticator { val credentialKey: KeyPair = generateEcKeypair() + val leafCertSubject: X500Name = new X500Name( + "CN=Yubico WebAuthn unit tests, O=Yubico, OU=Authenticator Attestation, C=SE" + ) + val caCertSubject: X500Name = new X500Name( + "CN=Yubico WebAuthn unit tests CA, O=Yubico, OU=Authenticator Attestation, C=SE" + ) val certValidFrom: Instant = Instant.parse("2018-09-06T17:42:00Z") val certValidTo: Instant = certValidFrom.plusSeconds(7 * 24 * 3600) } + val RsaKeySizeBits = 2048 + val Es256PrimeModulus: BigInteger = new BigInteger( + 1, + ByteArray + .fromHex( + "FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF" + ) + .getBytes, + ) private def jsonFactory: JsonNodeFactory = JsonNodeFactory.instance private def toBytes(s: String): ByteArray = new ByteArray(s.getBytes("UTF-8")) @@ -221,6 +242,39 @@ object TestAuthenticator { caKey, ) } + def tpm( + cert: AttestationCert, + ver: Option[String] = Some("2.0"), + magic: ByteArray = TpmAttestationStatementVerifier.TPM_GENERATED_VALUE, + `type`: ByteArray = + TpmAttestationStatementVerifier.TPM_ST_ATTEST_CERTIFY, + modifyAttestedName: ByteArray => ByteArray = an => an, + overrideCosePubkey: Option[ByteArray] = None, + attributes: Option[Long] = None, + symmetric: Option[Int] = None, + scheme: Option[Int] = None, + ): AttestationMaker = + new AttestationMaker { + override val format = "tpm" + override def certChain = cert.certChain + override def makeAttestationStatement( + authDataBytes: ByteArray, + clientDataJson: String, + ): JsonNode = + makeTpmAttestationStatement( + authDataBytes, + clientDataJson, + cert, + ver = ver, + magic = magic, + `type` = `type`, + modifyAttestedName = modifyAttestedName, + overrideCosePubkey = overrideCosePubkey, + attributes = attributes, + symmetric = symmetric, + scheme = scheme, + ) + } def none(): AttestationMaker = new AttestationMaker { @@ -261,10 +315,9 @@ object TestAuthenticator { object AttestationSigner { def ca( alg: COSEAlgorithmIdentifier, - aaguid: ByteArray = Defaults.aaguid, - certSubject: X500Name = new X500Name( - "CN=Yubico WebAuthn unit tests, O=Yubico, OU=Authenticator Attestation, C=SE" - ), + aaguid: Option[ByteArray] = Some(Defaults.aaguid), + certSubject: X500Name = Defaults.leafCertSubject, + certExtensions: List[(String, Boolean, ASN1Encodable)] = Nil, validFrom: Instant = Defaults.certValidFrom, validTo: Instant = Defaults.certValidTo, ): AttestationCert = { @@ -278,13 +331,15 @@ object TestAuthenticator { alg, caCertAndKey = Some((caCert, caKey)), name = certSubject, - extensions = List( - ( - "1.3.6.1.4.1.45724.1.1.4", - false, - new DEROctetString(aaguid.getBytes), + extensions = aaguid + .map(aaguid => + ( + "1.3.6.1.4.1.45724.1.1.4", + false, + new DEROctetString(aaguid.getBytes), + ) ) - ), + .toList ++ certExtensions, validFrom = validFrom, validTo = validTo, ) @@ -296,34 +351,72 @@ object TestAuthenticator { ) } - def selfsigned(alg: COSEAlgorithmIdentifier): AttestationCert = { - val (cert, key) = generateAttestationCertificate(alg = alg) + def selfsigned( + alg: COSEAlgorithmIdentifier, + certSubject: X500Name = Defaults.leafCertSubject, + issuerSubject: Option[X500Name] = None, + certExtensions: List[(String, Boolean, ASN1Encodable)] = Nil, + isCa: Boolean = false, + validFrom: Instant = Defaults.certValidFrom, + validTo: Instant = Defaults.certValidTo, + ): AttestationCert = { + val (cert, key) = generateAttestationCertificate( + alg = alg, + name = certSubject, + issuerName = issuerSubject, + extensions = certExtensions, + isCa = isCa, + validFrom = validFrom, + validTo = validTo, + ) AttestationCert(cert, key, alg, certChain = List((cert, key))) } } - private def createCredential( + def createAuthenticatorData( aaguid: ByteArray = Defaults.aaguid, - attestationMaker: AttestationMaker, authenticatorExtensions: Option[JsonNode] = None, - challenge: ByteArray = Defaults.challenge, - clientData: Option[JsonNode] = None, - clientExtensions: ClientRegistrationExtensionOutputs = - ClientRegistrationExtensionOutputs.builder().build(), credentialKeypair: Option[KeyPair] = None, keyAlgorithm: COSEAlgorithmIdentifier = Defaults.keyAlgorithm, - origin: String = Defaults.origin, - tokenBindingStatus: String = Defaults.TokenBinding.status, - tokenBindingId: Option[String] = Defaults.TokenBinding.id, ): ( - data.PublicKeyCredential[ - data.AuthenticatorAttestationResponse, - ClientRegistrationExtensionOutputs, - ], + ByteArray, KeyPair, - List[(X509Certificate, PrivateKey)], ) = { + val keypair = + credentialKeypair.getOrElse(generateKeypair(algorithm = keyAlgorithm)) + val publicKeyCose = keypair.getPublic match { + case pub: ECPublicKey => WebAuthnTestCodecs.ecPublicKeyToCose(pub) + case pub: BCEdDSAPublicKey => WebAuthnTestCodecs.eddsaPublicKeyToCose(pub) + case pub: RSAPublicKey => + WebAuthnTestCodecs.rsaPublicKeyToCose(pub, keyAlgorithm) + } + + val authDataBytes: ByteArray = makeAuthDataBytes( + rpId = Defaults.rpId, + attestedCredentialDataBytes = Some( + makeAttestedCredentialDataBytes( + aaguid = aaguid, + publicKeyCose = publicKeyCose, + ) + ), + extensionsCborBytes = authenticatorExtensions map (ext => + new ByteArray(JacksonCodecs.cbor().writeValueAsBytes(ext)) + ), + ) + + ( + authDataBytes, + keypair, + ) + } + def createClientData( + challenge: ByteArray = Defaults.challenge, + clientData: Option[JsonNode] = None, + origin: String = Defaults.origin, + tokenBindingStatus: String = Defaults.TokenBinding.status, + tokenBindingId: Option[String] = Defaults.TokenBinding.id, + ): String = { val clientDataJson: String = JacksonCodecs.json.writeValueAsString(clientData getOrElse { val json: ObjectNode = jsonFactory.objectNode() @@ -349,29 +442,27 @@ object TestAuthenticator { json }) - val clientDataJsonBytes = toBytes(clientDataJson) - val keypair = - credentialKeypair.getOrElse(generateKeypair(algorithm = keyAlgorithm)) - val publicKeyCose = keypair.getPublic match { - case pub: ECPublicKey => WebAuthnTestCodecs.ecPublicKeyToCose(pub) - case pub: BCEdDSAPublicKey => WebAuthnTestCodecs.eddsaPublicKeyToCose(pub) - case pub: RSAPublicKey => - WebAuthnTestCodecs.rsaPublicKeyToCose(pub, keyAlgorithm) - } + clientDataJson + } - val authDataBytes: ByteArray = makeAuthDataBytes( - rpId = Defaults.rpId, - attestedCredentialDataBytes = Some( - makeAttestedCredentialDataBytes( - aaguid = aaguid, - publicKeyCose = publicKeyCose, - ) - ), - extensionsCborBytes = authenticatorExtensions map (ext => - new ByteArray(JacksonCodecs.cbor().writeValueAsBytes(ext)) - ), - ) + def createCredential( + authDataBytes: ByteArray, + clientDataJson: String, + credentialKeypair: KeyPair, + attestationMaker: AttestationMaker, + clientExtensions: ClientRegistrationExtensionOutputs = + ClientRegistrationExtensionOutputs.builder().build(), + ): ( + data.PublicKeyCredential[ + data.AuthenticatorAttestationResponse, + ClientRegistrationExtensionOutputs, + ], + KeyPair, + List[(X509Certificate, PrivateKey)], + ) = { + + val clientDataJsonBytes = toBytes(clientDataJson) val attestationObjectBytes = attestationMaker.makeAttestationObjectBytes(authDataBytes, clientDataJson) @@ -391,7 +482,7 @@ object TestAuthenticator { .response(response) .clientExtensionResults(clientExtensions) .build(), - keypair, + credentialKeypair, attestationMaker.certChain, ) } @@ -407,13 +498,20 @@ object TestAuthenticator { ], KeyPair, List[(X509Certificate, PrivateKey)], - ) = - createCredential( + ) = { + val (authData, credentialKeypair) = createAuthenticatorData( aaguid = aaguid, - attestationMaker = attestationMaker, keyAlgorithm = keyAlgorithm, ) + createCredential( + authDataBytes = authData, + credentialKeypair = credentialKeypair, + clientDataJson = createClientData(), + attestationMaker = attestationMaker, + ) + } + def createSelfAttestedCredential( attestationMaker: SelfAttestation => AttestationMaker, keyAlgorithm: COSEAlgorithmIdentifier = Defaults.keyAlgorithm, @@ -425,12 +523,15 @@ object TestAuthenticator { KeyPair, List[(X509Certificate, PrivateKey)], ) = { - val keypair = generateKeypair(keyAlgorithm) + val (authData, keypair) = createAuthenticatorData(credentialKeypair = + Some(generateKeypair(keyAlgorithm)) + ) val signer = SelfAttestation(keypair, keyAlgorithm) createCredential( + authDataBytes = authData, + clientDataJson = createClientData(), + credentialKeypair = keypair, attestationMaker = attestationMaker(signer), - credentialKeypair = Some(keypair), - keyAlgorithm = keyAlgorithm, ) } @@ -444,12 +545,17 @@ object TestAuthenticator { ], KeyPair, List[(X509Certificate, PrivateKey)], - ) = + ) = { + val (authData, keypair) = createAuthenticatorData( + authenticatorExtensions = authenticatorExtensions + ) createCredential( + authDataBytes = authData, + clientDataJson = createClientData(challenge = challenge), + credentialKeypair = keypair, attestationMaker = AttestationMaker.none(), - authenticatorExtensions = authenticatorExtensions, - challenge = challenge, ) + } def createAssertionFromTestData( testData: RegistrationTestData, @@ -773,6 +879,171 @@ object TestAuthenticator { ) } + def makeTpmAttestationStatement( + authDataBytes: ByteArray, + clientDataJson: String, + cert: AttestationCert, + ver: Option[String] = Some("2.0"), + magic: ByteArray = TpmAttestationStatementVerifier.TPM_GENERATED_VALUE, + `type`: ByteArray = TpmAttestationStatementVerifier.TPM_ST_ATTEST_CERTIFY, + modifyAttestedName: ByteArray => ByteArray = an => an, + overrideCosePubkey: Option[ByteArray] = None, + attributes: Option[Long] = None, + symmetric: Option[Int] = None, + scheme: Option[Int] = None, + ): JsonNode = { + assert(magic.size() == 4) + assert(`type`.size() == 2) + + val authData = new AuthenticatorData(authDataBytes) + val cosePubkey = overrideCosePubkey.getOrElse( + authData.getAttestedCredentialData.get.getCredentialPublicKey + ) + + val coseKeyAlg = COSEAlgorithmIdentifier.fromPublicKey(cosePubkey).get + val (hashId, signAlg) = coseKeyAlg match { + case COSEAlgorithmIdentifier.ES256 => + (TpmAlgHash.SHA256, TpmAlgAsym.ECC) + case COSEAlgorithmIdentifier.ES384 => + (TpmAlgHash.SHA384, TpmAlgAsym.ECC) + case COSEAlgorithmIdentifier.ES512 => + (TpmAlgHash.SHA512, TpmAlgAsym.ECC) + case COSEAlgorithmIdentifier.RS256 => + (TpmAlgHash.SHA256, TpmAlgAsym.RSA) + case COSEAlgorithmIdentifier.RS1 => (TpmAlgHash.SHA1, TpmAlgAsym.RSA) + } + val hashFunc = hashId match { + case TpmAlgHash.SHA256 => Crypto.sha256(_: ByteArray) + case TpmAlgHash.SHA384 => Crypto.sha384 _ + case TpmAlgHash.SHA512 => Crypto.sha512 _ + case TpmAlgHash.SHA1 => Crypto.sha1 _ + } + val extraData = { + hashFunc( + authDataBytes concat Crypto.sha256( + new ByteArray(clientDataJson.getBytes(StandardCharsets.UTF_8)) + ) + ) + } + val (parameters, unique) = WebAuthnTestCodecs.getCoseKty(cosePubkey) match { + case 3 => { // RSA + val cose = CBORObject.DecodeFromBytes(cosePubkey.getBytes) + ( + new ByteArray(BinaryUtil.encodeUint16(symmetric getOrElse 0x0010)) + .concat( + new ByteArray( + BinaryUtil.encodeUint16(scheme getOrElse TpmRsaScheme.RSASSA) + ) + ) + .concat( + new ByteArray(BinaryUtil.encodeUint16(RsaKeySizeBits)) + ) // key_bits + .concat( + new ByteArray( + BinaryUtil.encodeUint32( + new BigInteger(1, cose.get(-2).GetByteString()).longValue() + ) + ) + ) // exponent + , + new ByteArray( + BinaryUtil.encodeUint16(cose.get(-1).GetByteString().length) + ).concat(new ByteArray(cose.get(-1).GetByteString())), // modulus + ) + } + case 2 => { // EC + val pubkey = WebAuthnCodecs + .importCosePublicKey(cosePubkey) + .asInstanceOf[ECPublicKey] + ( + new ByteArray(BinaryUtil.encodeUint16(symmetric getOrElse 0x0010)) + .concat( + new ByteArray(BinaryUtil.encodeUint16(scheme getOrElse 0x0010)) + ) + .concat( + new ByteArray(BinaryUtil.encodeUint16(coseKeyAlg match { + case COSEAlgorithmIdentifier.ES256 => 0x0003 + case COSEAlgorithmIdentifier.ES384 => 0x0004 + case COSEAlgorithmIdentifier.ES512 => 0x0005 + })) + ) + .concat( + new ByteArray(BinaryUtil.encodeUint16(0x0010)) + ) // kdf_scheme: ??? (unused?) + , + new ByteArray( + BinaryUtil.encodeUint16(pubkey.getW.getAffineX.toByteArray.length) + ) + .concat(new ByteArray(pubkey.getW.getAffineX.toByteArray)) + .concat( + new ByteArray( + BinaryUtil.encodeUint16( + pubkey.getW.getAffineY.toByteArray.length + ) + ) + ) + .concat(new ByteArray(pubkey.getW.getAffineY.toByteArray)), + ) + } + } + val pubArea = new ByteArray(BinaryUtil.encodeUint16(signAlg)) + .concat(new ByteArray(BinaryUtil.encodeUint16(hashId))) + .concat( + new ByteArray( + BinaryUtil.encodeUint32(attributes getOrElse Attributes.SIGN_ENCRYPT) + ) + ) + .concat( + new ByteArray(BinaryUtil.encodeUint16(0)) + ) // authPolicy is ignored by TpmAttestationStatementVerifier + .concat(parameters) + .concat(unique) + + val qualifiedSigner = ByteArray.fromHex("") + val clockInfo = ByteArray.fromHex("0000000000000000111111112222222233") + val firmwareVersion = ByteArray.fromHex("0000000000000000") + val attestedName = + modifyAttestedName( + new ByteArray(BinaryUtil.encodeUint16(hashId)).concat(hashFunc(pubArea)) + ) + val attestedQualifiedName = ByteArray.fromHex("") + + val certInfo = magic + .concat(`type`) + .concat(new ByteArray(BinaryUtil.encodeUint16(qualifiedSigner.size))) + .concat(qualifiedSigner) + .concat(new ByteArray(BinaryUtil.encodeUint16(extraData.size))) + .concat(extraData) + .concat(clockInfo) + .concat(firmwareVersion) + .concat(new ByteArray(BinaryUtil.encodeUint16(attestedName.size))) + .concat(attestedName) + .concat( + new ByteArray(BinaryUtil.encodeUint16(attestedQualifiedName.size)) + ) + .concat(attestedQualifiedName) + + val sig = sign(certInfo, cert.key, cert.alg) + + val f = JsonNodeFactory.instance + f + .objectNode() + .setAll[ObjectNode]( + Map( + "ver" -> ver.map(f.textNode).getOrElse(f.nullNode()), + "alg" -> f.numberNode(cert.alg.getId), + "x5c" -> f + .arrayNode() + .addAll( + cert.certChain.map(_._1.getEncoded).map(f.binaryNode).asJava + ), + "sig" -> f.binaryNode(sig.getBytes), + "certInfo" -> f.binaryNode(certInfo.getBytes), + "pubArea" -> f.binaryNode(pubArea.getBytes), + ).asJava + ) + } + def makeAuthDataBytes( rpId: String = Defaults.rpId, signatureCount: Option[Int] = None, @@ -839,7 +1110,9 @@ object TestAuthenticator { def generateKeypair(algorithm: COSEAlgorithmIdentifier): KeyPair = algorithm match { case COSEAlgorithmIdentifier.EdDSA => generateEddsaKeypair() - case COSEAlgorithmIdentifier.ES256 => generateEcKeypair() + case COSEAlgorithmIdentifier.ES256 => generateEcKeypair("secp256r1") + case COSEAlgorithmIdentifier.ES384 => generateEcKeypair("secp384r1") + case COSEAlgorithmIdentifier.ES512 => generateEcKeypair("secp521r1") case COSEAlgorithmIdentifier.RS256 => generateRsaKeypair() case COSEAlgorithmIdentifier.RS1 => generateRsaKeypair() } @@ -878,7 +1151,7 @@ object TestAuthenticator { def generateRsaKeypair(): KeyPair = { val g: KeyPairGenerator = KeyPairGenerator.getInstance("RSA") - g.initialize(2048, random) + g.initialize(RsaKeySizeBits, random) g.generateKeyPair() } @@ -938,9 +1211,7 @@ object TestAuthenticator { def generateAttestationCaCertificate( keypair: Option[KeyPair] = None, signingAlg: COSEAlgorithmIdentifier = COSEAlgorithmIdentifier.ES256, - name: X500Name = new X500Name( - "CN=Yubico WebAuthn unit tests CA, O=Yubico, OU=Authenticator Attestation, C=SE" - ), + name: X500Name = Defaults.caCertSubject, superCa: Option[(X509Certificate, PrivateKey)] = None, extensions: Iterable[(String, Boolean, ASN1Primitive)] = Nil, validFrom: Instant = Defaults.certValidFrom, @@ -967,10 +1238,9 @@ object TestAuthenticator { def generateAttestationCertificate( alg: COSEAlgorithmIdentifier = COSEAlgorithmIdentifier.ES256, keypair: Option[KeyPair] = None, - name: X500Name = new X500Name( - "CN=Yubico WebAuthn unit tests, O=Yubico, OU=Authenticator Attestation, C=SE" - ), - extensions: Iterable[(String, Boolean, ASN1Primitive)] = List( + name: X500Name = Defaults.leafCertSubject, + issuerName: Option[X500Name] = None, + extensions: Iterable[(String, Boolean, ASN1Encodable)] = List( ( "1.3.6.1.4.1.45724.1.1.4", false, @@ -980,20 +1250,23 @@ object TestAuthenticator { caCertAndKey: Option[(X509Certificate, PrivateKey)] = None, validFrom: Instant = Defaults.certValidFrom, validTo: Instant = Defaults.certValidTo, + isCa: Boolean = false, ): (X509Certificate, PrivateKey) = { val actualKeypair = keypair.getOrElse(generateKeypair(alg)) ( buildCertificate( publicKey = actualKeypair.getPublic, - issuerName = caCertAndKey - .map(_._1) - .map(JcaX500NameUtil.getSubject) - .getOrElse(name), + issuerName = issuerName.getOrElse( + caCertAndKey + .map(_._1) + .map(JcaX500NameUtil.getSubject) + .getOrElse(name) + ), subjectName = name, signingKey = caCertAndKey.map(_._2).getOrElse(actualKeypair.getPrivate), signingAlg = alg, - isCa = false, + isCa = isCa, extensions = extensions, validFrom = validFrom, validTo = validTo, @@ -1009,7 +1282,7 @@ object TestAuthenticator { signingKey: PrivateKey, signingAlg: COSEAlgorithmIdentifier, isCa: Boolean = false, - extensions: Iterable[(String, Boolean, ASN1Primitive)] = None, + extensions: Iterable[(String, Boolean, ASN1Encodable)] = None, validFrom: Instant = Defaults.certValidFrom, validTo: Instant = Defaults.certValidTo, ): X509Certificate = { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala index 948109799..f70ab9ca1 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnCodecsSpec.scala @@ -29,8 +29,8 @@ import com.yubico.webauthn.test.Util import org.junit.runner.RunWith import org.scalacheck.Arbitrary import org.scalacheck.Gen -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @@ -39,7 +39,7 @@ import scala.util.Try @RunWith(classOf[JUnitRunner]) class WebAuthnCodecsSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks with TestWithEachProvider { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnTestCodecs.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnTestCodecs.scala index 92a68dd67..7643a6d40 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnTestCodecs.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/WebAuthnTestCodecs.scala @@ -88,12 +88,10 @@ object WebAuthnTestCodecs { new ByteArray(CBORObject.FromObject(coseKey).EncodeToBytes) } - def getCoseAlgId(encodedPublicKey: ByteArray): COSEAlgorithmIdentifier = { - importCosePublicKey(encodedPublicKey).getAlgorithm match { - case "EC" => COSEAlgorithmIdentifier.ES256 - case other => - throw new UnsupportedOperationException("Unknown algorithm: " + other) - } + def getCoseKty(encodedPublicKey: ByteArray): Int = { + val cose = CBORObject.DecodeFromBytes(encodedPublicKey.getBytes) + val kty = cose.get(CBORObject.FromObject(1)).AsInt32 + kty } } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AttestationObjectSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AttestationObjectSpec.scala index 89b57f352..c9dfff099 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AttestationObjectSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AttestationObjectSpec.scala @@ -3,14 +3,14 @@ package com.yubico.webauthn.data import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.yubico.internal.util.JacksonCodecs import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import scala.jdk.CollectionConverters.MapHasAsJava @RunWith(classOf[JUnitRunner]) -class AttestationObjectSpec extends FunSpec with Matchers { +class AttestationObjectSpec extends AnyFunSpec with Matchers { private def jsonFactory: JsonNodeFactory = JsonNodeFactory.instance diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorAttestationResponseSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorAttestationResponseSpec.scala index b93b1321a..e78043867 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorAttestationResponseSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorAttestationResponseSpec.scala @@ -25,12 +25,12 @@ package com.yubico.webauthn.data import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner @RunWith(classOf[JUnitRunner]) -class AuthenticatorAttestationResponseSpec extends FunSpec with Matchers { +class AuthenticatorAttestationResponseSpec extends AnyFunSpec with Matchers { describe("AuthenticatorAttestationResponse") { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataFlagsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataFlagsSpec.scala index ec1aaff3b..33e7b16d6 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataFlagsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataFlagsSpec.scala @@ -26,12 +26,12 @@ package com.yubico.webauthn.data import com.yubico.internal.util.BinaryUtil import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner @RunWith(classOf[JUnitRunner]) -class AuthenticatorDataFlagsSpec extends FunSpec with Matchers { +class AuthenticatorDataFlagsSpec extends AnyFunSpec with Matchers { describe("AuthenticatorDataFlags") { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataSpec.scala index 13f943ed3..d3f5186bd 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataSpec.scala @@ -31,8 +31,8 @@ import com.yubico.webauthn.data.Generators.byteArray import org.junit.runner.RunWith import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @@ -43,7 +43,7 @@ import scala.util.Try @RunWith(classOf[JUnitRunner]) class AuthenticatorDataSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorTransportSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorTransportSpec.scala index 2e56f8539..3f0dd0696 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorTransportSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorTransportSpec.scala @@ -25,14 +25,14 @@ package com.yubico.webauthn.data import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @RunWith(classOf[JUnitRunner]) class AuthenticatorTransportSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { @@ -48,13 +48,16 @@ class AuthenticatorTransportSpec it("BLE.") { AuthenticatorTransport.BLE.getId should equal("ble") } + it("HYBRID.") { + AuthenticatorTransport.HYBRID.getId should equal("hybrid") + } it("INTERNAL.") { AuthenticatorTransport.INTERNAL.getId should equal("internal") } } it("has a values() function.") { - AuthenticatorTransport.values().length should equal(4) + AuthenticatorTransport.values().length should equal(5) AuthenticatorTransport.values() should not be theSameInstanceAs( AuthenticatorTransport.values() ) @@ -70,6 +73,9 @@ class AuthenticatorTransportSpec AuthenticatorTransport.valueOf( "BLE" ) should be theSameInstanceAs AuthenticatorTransport.BLE + AuthenticatorTransport.valueOf( + "HYBRID" + ) should be theSameInstanceAs AuthenticatorTransport.HYBRID AuthenticatorTransport.valueOf( "INTERNAL" ) should be theSameInstanceAs AuthenticatorTransport.INTERNAL diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/BuildersSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/BuildersSpec.scala index 31eb15301..d0819af58 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/BuildersSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/BuildersSpec.scala @@ -32,8 +32,8 @@ import com.yubico.webauthn.RegistrationResult import com.yubico.webauthn.data.Generators._ import org.junit.runner.RunWith import org.scalacheck.Arbitrary -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @@ -41,7 +41,7 @@ import scala.language.reflectiveCalls @RunWith(classOf[JUnitRunner]) class BuildersSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/CollectedClientDataSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/CollectedClientDataSpec.scala index 5117eed53..c34cfb92e 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/CollectedClientDataSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/CollectedClientDataSpec.scala @@ -28,12 +28,12 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode import com.yubico.internal.util.JacksonCodecs import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner @RunWith(classOf[JUnitRunner]) -class CollectedClientDataSpec extends FunSpec with Matchers { +class CollectedClientDataSpec extends AnyFunSpec with Matchers { def parse(json: JsonNode): CollectedClientData = new CollectedClientData( diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/EnumsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/EnumsSpec.scala index 5a0d63d12..8e35e8558 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/EnumsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/EnumsSpec.scala @@ -5,8 +5,8 @@ import com.yubico.webauthn.extension.uvm.KeyProtectionType import com.yubico.webauthn.extension.uvm.MatcherProtectionType import com.yubico.webauthn.extension.uvm.UserVerificationMethod import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @@ -14,7 +14,7 @@ import scala.util.Try @RunWith(classOf[JUnitRunner]) class EnumsSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { @@ -22,6 +22,34 @@ class EnumsSpec describe("AttestationConveyancePreference") { describe("can be parsed from JSON") { + it("""value: "none"""") { + json.readValue( + "\"none\"", + classOf[AttestationConveyancePreference], + ) should be theSameInstanceAs AttestationConveyancePreference.NONE + } + + it("""value: "indirect"""") { + json.readValue( + "\"indirect\"", + classOf[AttestationConveyancePreference], + ) should be theSameInstanceAs AttestationConveyancePreference.INDIRECT + } + + it("""value: "direct"""") { + json.readValue( + "\"direct\"", + classOf[AttestationConveyancePreference], + ) should be theSameInstanceAs AttestationConveyancePreference.DIRECT + } + + it("""value: "enterprise"""") { + json.readValue( + "\"enterprise\"", + classOf[AttestationConveyancePreference], + ) should be theSameInstanceAs AttestationConveyancePreference.ENTERPRISE + } + it("but throws IllegalArgumentException for unknown values.") { val result = Try( json.readValue("\"foo\"", classOf[AttestationConveyancePreference]) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala index 29dbcdaa2..6ded9bce3 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ExtensionsSpec.scala @@ -16,8 +16,8 @@ import com.yubico.webauthn.test.RealExamples import org.junit.runner.RunWith import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @@ -28,7 +28,7 @@ import scala.jdk.OptionConverters.RichOptional @RunWith(classOf[JUnitRunner]) class ExtensionsSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { @@ -321,7 +321,7 @@ class ExtensionsSpec it("can deserialize a real largeBlob write example.") { val testData = RealExamples.LargeBlobWrite val registrationCred = testData.attestation.credential - val assertionCred = testData.assertion.credential + val assertionCred = testData.assertion.get.credential registrationCred.getClientExtensionResults.getExtensionIds.asScala should equal( Set("largeBlob") @@ -341,7 +341,7 @@ class ExtensionsSpec it("can deserialize a real largeBlob read example.") { val testData = RealExamples.LargeBlobRead val registrationCred = testData.attestation.credential - val assertionCred = testData.assertion.credential + val assertionCred = testData.assertion.get.credential registrationCred.getClientExtensionResults.getExtensionIds.asScala should equal( Set("largeBlob") diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala index d83d942fc..493ef1a95 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala @@ -317,15 +317,20 @@ object Generators { len <- Gen.chooseNum(minSize, maxSize) } yield new ByteArray(nums.take(len).toArray) - def flipOneBit(bytes: ByteArray): Gen[ByteArray] = - for { - byteIndex: Int <- Gen.choose(0, bytes.size() - 1) - bitIndex: Int <- Gen.choose(0, 7) - flipMask: Byte = (1 << bitIndex).toByte - } yield new ByteArray( + def flipBit(bitIndex: Int)(bytes: ByteArray): ByteArray = { + val byteIndex: Int = bitIndex / 8 + val bitIndexInByte: Int = bitIndex % 8 + val flipMask: Byte = (1 << bitIndexInByte).toByte + new ByteArray( bytes.getBytes .updated(byteIndex, (bytes.getBytes()(byteIndex) ^ flipMask).toByte) ) + } + + def flipOneBit(bytes: ByteArray): Gen[ByteArray] = + for { + bitIndex <- Gen.choose(0, 8 * bytes.size() - 1) + } yield flipBit(bitIndex)(bytes) object Extensions { private val RegistrationExtensionIds: Set[String] = diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala index 17b4b3b20..29cfa491f 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/JsonIoSpec.scala @@ -42,14 +42,14 @@ import org.junit.runner.RunWith import org.scalacheck.Arbitrary import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @RunWith(classOf[JUnitRunner]) class JsonIoSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/PublicKeyCredentialDescriptorSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/PublicKeyCredentialDescriptorSpec.scala index 0b7218289..2fca15d80 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/PublicKeyCredentialDescriptorSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/PublicKeyCredentialDescriptorSpec.scala @@ -25,12 +25,12 @@ package com.yubico.webauthn.data import com.yubico.webauthn.data.Generators._ -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks class PublicKeyCredentialDescriptorSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/RealExamples.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/RealExamples.scala index f69873ce5..792b5301e 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/RealExamples.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/RealExamples.scala @@ -1,5 +1,6 @@ package com.yubico.webauthn.test +import com.yubico.internal.util.CertificateParser import com.yubico.internal.util.JacksonCodecs import com.yubico.webauthn.AssertionRequest import com.yubico.webauthn.AssertionTestData @@ -10,6 +11,7 @@ import com.yubico.webauthn.data.AuthenticatorAssertionResponse import com.yubico.webauthn.data.AuthenticatorAttestationResponse import com.yubico.webauthn.data.AuthenticatorData import com.yubico.webauthn.data.ByteArray +import com.yubico.webauthn.data.COSEAlgorithmIdentifier import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.CollectedClientData @@ -19,6 +21,7 @@ import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity import java.nio.charset.StandardCharsets +import java.security.cert.X509Certificate sealed trait HasClientData { def clientData: String @@ -42,6 +45,7 @@ object RealExamples { clientData: String, attestationObjectBytes: ByteArray, clientExtensionResultsJson: String = "{}", + attestationRootCertificate: Option[X509Certificate] = None, ) extends HasClientData { def attestationObject: AttestationObject = new AttestationObject(attestationObjectBytes) @@ -92,8 +96,15 @@ object RealExamples { rp: RelyingPartyIdentity, user: UserIdentity, attestation: AttestationExample, - assertion: AssertionExample, + assertion: Option[AssertionExample] = None, ) { + def this( + rp: RelyingPartyIdentity, + user: UserIdentity, + attestation: AttestationExample, + assertion: AssertionExample, + ) = this(rp, user, attestation, Some(assertion)) + def attestationCert: ByteArray = new ByteArray( attestation.attestationObject.getAttestationStatement @@ -104,15 +115,17 @@ object RealExamples { def asRegistrationTestData: RegistrationTestData = RegistrationTestData( - alg = WebAuthnTestCodecs.getCoseAlgId( - attestation.attestationObject.getAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey - ), + alg = COSEAlgorithmIdentifier + .fromPublicKey( + attestation.attestationObject.getAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey + ) + .get, attestationObject = attestation.attestationObjectBytes, clientDataJson = attestation.clientData, privateKey = None, rpId = rp, userId = user, - assertion = Some( + assertion = assertion.map({ assertion => AssertionTestData( request = AssertionRequest .builder() @@ -126,11 +139,12 @@ object RealExamples { .build(), response = assertion.credential, ) - ), + }), + attestationRootCertificate = attestation.attestationRootCertificate, ) } - val YubiKeyNeo = Example( + val YubiKeyNeo = new Example( RelyingPartyIdentity.builder().id("example.com").name("Example RP").build(), UserIdentity .builder() @@ -154,7 +168,7 @@ object RealExamples { ), ) - val YubiKey4 = Example( + val YubiKey4 = new Example( RelyingPartyIdentity.builder().id("example.com").name("Example RP").build(), UserIdentity .builder() @@ -178,7 +192,7 @@ object RealExamples { ), ) - val YubiKey5 = Example( + val YubiKey5 = new Example( RelyingPartyIdentity.builder().id("example.com").name("Example RP").build(), UserIdentity .builder() @@ -202,7 +216,7 @@ object RealExamples { ), ) - val YubiKey5Nfc = Example( + val YubiKey5Nfc = new Example( RelyingPartyIdentity .builder() .id("demo.yubico.com") @@ -232,7 +246,7 @@ object RealExamples { ), ) - val YubiKey5NfcPost5cNfc = Example( + val YubiKey5NfcPost5cNfc = new Example( RelyingPartyIdentity .builder() .id("demo.yubico.com") @@ -262,7 +276,7 @@ object RealExamples { ), ) - val YubiKey5cNfc = Example( + val YubiKey5cNfc = new Example( RelyingPartyIdentity .builder() .id("demo.yubico.com") @@ -292,7 +306,7 @@ object RealExamples { ), ) - val YubiKey5Nano = Example( + val YubiKey5Nano = new Example( RelyingPartyIdentity.builder().id("example.com").name("Example RP").build(), UserIdentity .builder() @@ -316,7 +330,7 @@ object RealExamples { ), ) - val YubiKey5Ci = Example( + val YubiKey5Ci = new Example( RelyingPartyIdentity.builder().id("example.com").name("Example RP").build(), UserIdentity .builder() @@ -340,7 +354,7 @@ object RealExamples { ), ) - val SecurityKey = Example( + val SecurityKey = new Example( RelyingPartyIdentity.builder().id("example.com").name("Example RP").build(), UserIdentity .builder() @@ -364,7 +378,7 @@ object RealExamples { ), ) - val SecurityKey2 = Example( + val SecurityKey2 = new Example( RelyingPartyIdentity.builder().id("example.com").name("Example RP").build(), UserIdentity .builder() @@ -388,7 +402,7 @@ object RealExamples { ), ) - val SecurityKeyNfc = Example( + val SecurityKeyNfc = new Example( RelyingPartyIdentity.builder().id("example.com").name("Example RP").build(), UserIdentity .builder() @@ -412,7 +426,7 @@ object RealExamples { ), ) - val AppleAttestationIos = Example( + val AppleAttestationIos = new Example( RelyingPartyIdentity .builder() .id("demo.yubico.com") @@ -442,7 +456,7 @@ object RealExamples { ), ) - val AppleAttestationMacos = Example( + val AppleAttestationMacos = new Example( RelyingPartyIdentity .builder() .id("demo.yubico.com") @@ -470,7 +484,7 @@ object RealExamples { ), ) - val YubikeyFips5Nfc = Example( + val YubikeyFips5Nfc = new Example( RelyingPartyIdentity.builder().id("demo.yubico.com").name("").build(), UserIdentity .builder() @@ -495,7 +509,7 @@ object RealExamples { ), ) - val Yubikey5ciFips = Example( + val Yubikey5ciFips = new Example( RelyingPartyIdentity.builder().id("demo.yubico.com").name("").build(), UserIdentity .builder() @@ -519,7 +533,7 @@ object RealExamples { ), ) - val YubikeyBio_5_5_4 = Example( + val YubikeyBio_5_5_4 = new Example( RelyingPartyIdentity .builder() .id("demo.yubico.com") @@ -548,7 +562,7 @@ object RealExamples { ), ) - val YubikeyBio_5_5_5 = Example( + val YubikeyBio_5_5_5 = new Example( RelyingPartyIdentity .builder() .id("demo.yubico.com") @@ -589,7 +603,7 @@ object RealExamples { clientExtensionResultsJson = """{"credProps":{"rk":true}}""", ) - val LargeBlobWrite = Example( + val LargeBlobWrite = new Example( RelyingPartyIdentity.builder().id("localhost").name("").build(), UserIdentity .builder() @@ -620,7 +634,7 @@ object RealExamples { ), ) - val LargeBlobRead = Example( + val LargeBlobRead = new Example( RelyingPartyIdentity.builder().id("localhost").name("").build(), UserIdentity .builder() @@ -651,4 +665,30 @@ object RealExamples { ), ) + val WindowsHelloTpm = + Example( + RelyingPartyIdentity + .builder() + .id("d2urpypvrhb05x.amplifyapp.com") + .name("") + .build(), + UserIdentity + .builder() + .name("foo") + .displayName("Foo Bar") + .id( + ByteArray.fromBase64Url("AAAA") + ) + .build(), + AttestationExample( + base64UrlToString( + "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoia0lnZElRbWFBbXM1NlVOencwREg4dU96M0JERjJVSllhSlA2eklRWDFhOCIsIm9yaWdpbiI6Imh0dHBzOi8vZGV2LmQydXJweXB2cmhiMDV4LmFtcGxpZnlhcHAuY29tIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ" + ), + ByteArray.fromBase64Url("o2NmbXRjdHBtZ2F0dFN0bXSmY2FsZzn__mNzaWdZAQBFEaTe-uZvbZBNsIMtJa26eigMUxEM1mBtddR7gdEBH5Hyeo9hFCqiJwYVKUq_iP9hvFaiLzoGbAWDgiG-fa3F-S71c8w83756dyRBMXNHYEvYjfv0TqGyky73V4xyKpf1iHiO_g4t31UjQiyTfypdP_rRcm42KVKgVyRPZzx_AKweN9XKEFfT2Ym3fmqD_scaIeKSyGs9qwH1MbILLUVnRK6fKK6sAA4ZaDVz4gUiSUoK9ZycCC2hfLBq5GjiTLgQF_Q2O3gRTqmU8VfwVsmtN5OMaGOyaFrUk97-RvZVrARXhNzrUAJT7KjTLDZeIA96F3pB_F_q3xd_dgvwVpWHY3ZlcmMyLjBjeDVjglkFuzCCBbcwggOfoAMCAQICEHHcna7VCE3QpRyKgi2uvXYwDQYJKoZIhvcNAQELBQAwQTE_MD0GA1UEAxM2RVVTLU5UQy1LRVlJRC04ODJGMDQ3Qjg3MTIxQ0Y5ODg1RjMxMTYwQkM3QkI1NTg2QUY0NzFCMB4XDTIyMDEyMDE5NTQxNloXDTI3MDYwMzE3NTE0OFowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALtT5frDB-WUq6N0VYnlYEalJzSut0JQx3vP29_ub-kZ7csJrm8uGXQGUkPlf4EFehFTnQ1jX_oZ8jNPw1m3rV5ijcuCe3r5GICFD6gpbuErmGS2mDVfe3fl_p0gPvhtulqatb1uYkWfW5SIKix1XWRvm92s3lRQvd-6vX_ExPIP-pEf0tkeINpBNNWgdtx3VdW4KVFTcv-q2FKhqfqXiAdOMHmwmWyXYulppYqW2XC7Pw9QmHZR_C5Urpc5UMmABz4zWSAYOyBMkKsX8koAsk8RgLtus07wW3FhJqi-BYczIe0IxG0q9UL295lkaxreTkfWYZHMMcU4M-Tm1w7QvKsCAwEAAaOCAeowggHmMA4GA1UdDwEB_wQEAwIHgDAMBgNVHRMBAf8EAjAAMG0GA1UdIAEB_wRjMGEwXwYJKwYBBAGCNxUfMFIwUAYIKwYBBQUHAgIwRB5CAFQAQwBQAEEAIAAgAFQAcgB1AHMAdABlAGQAIAAgAFAAbABhAHQAZgBvAHIAbQAgACAASQBkAGUAbgB0AGkAdAB5MBAGA1UdJQQJMAcGBWeBBQgDMFAGA1UdEQEB_wRGMESkQjBAMT4wEAYFZ4EFAgIMB05QQ1Q3NXgwFAYFZ4EFAgEMC2lkOjRFNTQ0MzAwMBQGBWeBBQIDDAtpZDowMDA3MDAwMjAfBgNVHSMEGDAWgBSMmnF_AA0xD8rW7i0pqjSXJYwSHjAdBgNVHQ4EFgQUFmUMIda76eb7Whi8CweaWhe7yNMwgbIGCCsGAQUFBwEBBIGlMIGiMIGfBggrBgEFBQcwAoaBkmh0dHA6Ly9hemNzcHJvZGV1c2Fpa3B1Ymxpc2guYmxvYi5jb3JlLndpbmRvd3MubmV0L2V1cy1udGMta2V5aWQtODgyZjA0N2I4NzEyMWNmOTg4NWYzMTE2MGJjN2JiNTU4NmFmNDcxYi84ODIzMGNhMi0yN2U1LTQxNTEtOWJhMi01OWI1ODJjMzlhYWEuY2VyMA0GCSqGSIb3DQEBCwUAA4ICAQCxsTbR5V8qnw6H6HEWJvrqcRy8fkY_vFUSjUq27hRl0t9D6LuS20l65FFm48yLwCkQbIf-aOBjwWafAbSVnEMig3KP-2Ml8IFtH63Msq9lwDlnXx2PNi7ISOemHNzBNeOG7pd_Zs69XUTq9zCriw9gAILCVCYllBluycdT7wZdjf0Bb5QJtTMuhwNXnOWmjv0VBOfsclWo-SEnnufaIDi0Vcf_TzbgmNn408Ej7R4Njy4qLnhPk64ruuWNJt3xlLMjbJXe_VKdO3lhM7JVFWSNAn8zfvEIwrrgCPhp1k2mFUGxJEvpSTnuZtNF35z4_54K6cEqZiqO-qd4FKt4KYs1GYJDyxttuUySGtnYyZg2aYB6hamg3asRDjBMPqoURsdVJcWQh3dFnD88cbs7Qt4_ytqAY61qfPE7bJ6E33o0X7OtxmECPd3aBJk6nsyXEXNF2vIww1UCrRC0OEr1HsTqA4bQU8KCWV6kduUnvkUWPT8CF0d2ER4wnszb053Tlcf2ebcytTMf_Nd95g520Hhqb2FZALCErijBi04Bu6SNeND1NQ3nxDSKC-CamOYW0ODch05Xzi1V0_sq0zmdKTxMSpg1jOZ1Q9924D4lJkruCB3zcsIBTUxV0EgAM1zGuoqwWjwYXr_8tO4_kEO1Lw8DckZIrk1s3ySsMVC89TRrIVkG7zCCBuswggTToAMCAQICEzMAAAQI5W53M7IUDf4AAAAABAgwDQYJKoZIhvcNAQELBQAwgYwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xNjA0BgNVBAMTLU1pY3Jvc29mdCBUUE0gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxNDAeFw0yMTA2MDMxNzUxNDhaFw0yNzA2MDMxNzUxNDhaMEExPzA9BgNVBAMTNkVVUy1OVEMtS0VZSUQtODgyRjA0N0I4NzEyMUNGOTg4NUYzMTE2MEJDN0JCNTU4NkFGNDcxQjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMEye3QdCUOGeseHj_QjLMJVbHBEJKkRbXFsmZi6Mob_3IsVfxO-mQQC-xfY9tDyB1jhxfFAG-rjUfRVBwYYPfaVo2W58Q09Kcpf-43Iw9SRxx2ThP-KPFJPFBofZroloNaTNz3DRaWZ2ha-_PUG2nwTXR7LoIpqMVW1PDzGxb47SNpRmKJxVZhQ2_wRhZZvHRHJpZmrCmHRpTRqWSzQT1jn7Zo9VuMYvp_OFj7-LFpkqi4BYyhi0kTBPDQTpYrBi7RtmF1MhZBmm1HGDhoXHcPSZkN5vq5at4g03R15KWyRDgBcckCAgtewd6Dtd_Zwaejlm57xyGqP6T-AE-N8udh1NPv_PZVlSc4CnCayUTPORuaJ7N-v7Y4wpNSIdipq29hw19WVuO_z7q6GpQbn17arYf6LSoDZfwO8GHXPrtBOYYSZCNKuZ_IK8nomBLJPtN5AzwEZNyLCZIkg0U0sJ-oVr2UEYxlwwZQm5RSDxProaKU-OXq4f_j_0pEu5_DbJx9syR3Nsv6Lt9Zkf3JSJTVtWXoM0-R_82vAJ669PX0LLr603PKWBZbW7zQvtGojT_Pc1FDGfwhcdckxd3MGpEjZwh_1D8elYcxj3Ndw5jClWosZKr33pUcjqeFtSZSur0lbm6vyCfS16XzSMn8IkHmbbXcpgGKHumUCFD8CHJIBAgMBAAGjggGOMIIBijAOBgNVHQ8BAf8EBAMCAoQwGwYDVR0lBBQwEgYJKwYBBAGCNxUkBgVngQUIAzAWBgNVHSAEDzANMAsGCSsGAQQBgjcVHzASBgNVHRMBAf8ECDAGAQH_AgEAMB0GA1UdDgQWBBSMmnF_AA0xD8rW7i0pqjSXJYwSHjAfBgNVHSMEGDAWgBR6jArOL0hiF-KU0a5VwVLscXSkVjBwBgNVHR8EaTBnMGWgY6Bhhl9odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNyb3NvZnQlMjBUUE0lMjBSb290JTIwQ2VydGlmaWNhdGUlMjBBdXRob3JpdHklMjAyMDE0LmNybDB9BggrBgEFBQcBAQRxMG8wbQYIKwYBBQUHMAKGYWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVFBNJTIwUm9vdCUyMENlcnRpZmljYXRlJTIwQXV0aG9yaXR5JTIwMjAxNC5jcnQwDQYJKoZIhvcNAQELBQADggIBAHG-1grb-6xpObMtxfFScl8PRLd_GjLFaeAd0kVPls0jzKplG2Am4O87Qg0OUY0VhQ-uD39590gGrWEWnOmdrVJ-R1lJc1yrIZFEASBEedJSvxw9YTNknD59uXtznIP_4Glk-4NpqpcYov2OkBdV59V4dTL5oWFH0vzkZQfvGFxEHwtB9O6Bh0Lk142zXAh5_vf_-hSw3t9adloBAnA0AtPUVkzmgNRGeTpfPm-Iud-MAoUXaccFn2EChjKb9ApbS1ww8ZvX4x2kFU6qctu32g7Vf6CgACc1i-UYDT_E-h6c1O4n7JK2OxVS-DVwybps-cALU0gj-ZMauNMej0_x_NerzvDuQ77eFMTDMY4ZTzYOzlg4Nj0K8y1Bx_KqeTBO0N9CdEG3dBxWCUUFQzAx-i38xJL-dtYTCCFATVhc9FFJ0CQgU07JAGAeuNm_GL8kN46bMXd_ApQYFzDQUWYYXvRIt9mCw0Zd45lpAMuiDKT9TgjUVDNu8LQ8FPK0KeiQVrGHFMhHgg2pbVH9Pvc1jNEeRpCo0BLpZQwuIgEt90mepkt6Va-C9krHsU4y2oalG2LUu-jOC3NWNK8LssYVUCFWtaKh5d-xdTQmjx4uO-1sq9GFntVJ94QnEDhldz0XMopQ8srTGLMqR3MT-GkSNb5X1UFC-X1udXI8YvB3ADr_Z3B1YkFyZWFZATYAAQALAAYEcgAgnf_L82w4OuaZ-5ho3G3LidcVOIS-KAOSLBJBWL-tIq4AEAAQCAAAAAAAAQC6zY02JuN_qf28iVCISa6d6aUM5sS2PsKvZMO0P_-28lLX_9-xbmbEcTPvCj5aIEqVUfCb07U4qCBBUdaWygAbhAckvzYrACm5I8hjMoGkxMkZLw5kX0SjtHx6VfgksElxnu4DcC2g9pqL_McgddZV0zNGrYNj1iamzoxTyOcYyvZjQv_4gU-t9mEc0uqHHv-H6k23p4mensyaGAhkGTV8odyBxsNpdnR8IPnXWPO7tBDTbk4mg3VtclqLhS0-TCh_QnZ6lcEl27wTE8FjwqVdQG9F1Ouyn-eNAAq0EufbzwjSNpuDrlj-Kj5xY_lS5BMEjQUXqP-U5nzW22TehX8VaGNlcnRJbmZvWKH_VENHgBcAIgALt-OVET9fzihC1dzyG5xG70MVdRkPpSEkWQ0nns4zaYkAFGo6XjXu3JY-wup-EHJYWEuLpb45AAAACMgZxo6-OwT1-cIZwQGjXsp0dUws1gAiAAvzCsernlTAewF0QwNOA-iWpyhGxC-k_xBRjTtGNz9m6gAiAAs54tyczU1aCAh_N3Vy3qcAnC9zGeOxtQQKCe8CAanbbGhhdXRoRGF0YVkBZ-MWwK8fdtoeGn4DEn0TAUu4IUP_PMiBiJd4lDRznbBDRQAAAAAImHBYytxLgbbhMN5Q3L6WACBxLUIzn9ngKAM11_UwWG7kCiAvVyO1mYGSsEhfWeyhDaQBAwM5AQAgWQEAus2NNibjf6n9vIlQiEmunemlDObEtj7Cr2TDtD__tvJS1__fsW5mxHEz7wo-WiBKlVHwm9O1OKggQVHWlsoAG4QHJL82KwApuSPIYzKBpMTJGS8OZF9Eo7R8elX4JLBJcZ7uA3AtoPaai_zHIHXWVdMzRq2DY9Ymps6MU8jnGMr2Y0L_-IFPrfZhHNLqhx7_h-pNt6eJnp7MmhgIZBk1fKHcgcbDaXZ0fCD511jzu7QQ025OJoN1bXJai4UtPkwof0J2epXBJdu8ExPBY8KlXUBvRdTrsp_njQAKtBLn288I0jabg65Y_io-cWP5UuQTBI0FF6j_lOZ81ttk3oV_FSFDAQAB"), + attestationRootCertificate = Some( + CertificateParser.parsePem("MIIF9TCCA92gAwIBAgIQXbYwTgy/J79JuMhpUB5dyzANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE2MDQGA1UEAxMtTWljcm9zb2Z0IFRQTSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDE0MB4XDTE0MTIxMDIxMzExOVoXDTM5MTIxMDIxMzkyOFowgYwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xNjA0BgNVBAMTLU1pY3Jvc29mdCBUUE0gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJ+n+bnKt/JHIRC/oI/xgkgsYdPzP0gpvduDA2GbRtth+L4WUyoZKGBw7uz5bjjP8Aql4YExyjR3EZQ4LqnZChMpoCofbeDR4MjCE1TGwWghGpS0mM3GtWD9XiME4rE2K0VW3pdN0CLzkYbvZbs2wQTFfE62yNQiDjyHFWAZ4BQH4eWa8wrDMUxIAneUCpU6zCwM+l6Qh4ohX063BHzXlTSTc1fDsiPaKuMMjWjK9vp5UHFPa+dMAWr6OljQZPFIg3aZ4cUfzS9y+n77Hs1NXPBn6E4Db679z4DThIXyoKeZTv1aaWOWl/exsDLGt2mTMTyykVV8uD1eRjYriFpmoRDwJKAEMOfaURarzp7hka9TOElGyD2gOV4Fscr2MxAYCywLmOLzA4VDSYLuKAhPSp7yawET30AvY1HRfMwBxetSqWP2+yZRNYJlHpor5QTuRDgzR+Zej+aWx6rWNYx43kLthozeVJ3QCsD5iEI/OZlmWn5WYf7O8LB/1A7scrYv44FD8ck3Z+hxXpkklAsjJMsHZa9mBqh+VR1AicX4uZG8m16x65ZU2uUpBa3rn8CTNmw17ZHOiuSWJtS9+PrZVA8ljgf4QgA1g6NPOEiLG2fn8Gm+r5Ak+9tqv72KDd2FPBJ7Xx4stYj/WjNPtEUhW4rcLK3ktLfcy6ea7Rocw5y5AgMBAAGjUTBPMAsGA1UdDwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR6jArOL0hiF+KU0a5VwVLscXSkVjAQBgkrBgEEAYI3FQEEAwIBADANBgkqhkiG9w0BAQsFAAOCAgEAW4ioo1+J9VWC0UntSBXcXRm1ePTVamtsxVy/GpP4EmJd3Ub53JzNBfYdgfUL51CppS3ZY6BoagB+DqoA2GbSL+7sFGHBl5ka6FNelrwsH6VVw4xV/8klIjmqOyfatPYsz0sUdZev+reeiGpKVoXrK6BDnUU27/mgPtem5YKWvHB/soofUrLKzZV3WfGdx9zBr8V0xW6vO3CKaqkqU9y6EsQw34n7eJCbEVVQ8VdFd9iV1pmXwaBAfBwkviPTKEP9Cm+zbFIOLr3V3CL9hJj+gkTUuXWlJJ6wVXEG5i4rIbLAV59UrW4LonP+seqvWMJYUFxu/niF0R3fSGM+NU11DtBVkhRZt1u0kFhZqjDz1dWyfT/N7Hke3WsDqUFsBi+8SEw90rWx2aUkLvKo83oU4Mx4na+2I3l9F2a2VNGk4K7l3a00g51miPiq0Da0jqw30PaLluTMTGY5+RnZVh50JD6nk+Ea3wRkU8aiYFnpIxfKBZ72whmYYa/egj9IKeqpR0vuLebbU0fJBf880K1jWD3Z5SFyJXo057Mv0OPw5mttytE585ZIy5JsaRXlsOoWGRXE3kUT/MKR1UoAgR54c8Bsh+9Dq2wqIK9mRn15zvBDeyHG6+czurLopziOUeWokxZN1syrEdKlhFoPYavm6t+PzIcpdxZwHA+V3jLJPfI=") + ), + ), + ) + } diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index 4808d4019..7fb166eaa 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -7,9 +7,9 @@ one can perform auxiliary actions such as adding an additional authenticator or deregistering a credential. The central part is the -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java[WebAuthnServer] +link:src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`] class, and the -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java[WebAuthnRestResource] +link:src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`] class which provides the REST API on top of it. @@ -24,25 +24,25 @@ $ $BROWSER https://localhost:8443/ == Architecture The example webapp is made up of three main layers, the bottom of which is the -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-core/[`webauthn-server-core`] +link:../webauthn-server-core/[`webauthn-server-core`] library: - The front end interacts with the server via a *REST API*, implemented in - https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java[WebAuthnRestResource]. + link:src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`]. + This layer manages translation between JSON request/response payloads and domain objects, and most methods simply call into analogous methods in the server layer. - The REST API then delegates to the *server layer*, implemented in - https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java[WebAuthnServer]. + link:src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`]. + This layer manages the general architecture of the system, and is where most business logic and integration code would go. The demo server implements the "persistent" storage of users and credential registrations - the -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java[CredentialRepository] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] integration point - as the -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[InMemoryRegistrationStorage] +link:src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[`InMemoryRegistrationStorage`] class, which simply keeps them stored in memory for a limited time. The transient storage of pending challenges is also kept in memory, but for a shorter duration. @@ -52,9 +52,9 @@ deregistration of credentials, is also in this layer. In general, anything that would be specific to a particular Relying Party (RP) would go in this layer. - The server layer in turn calls the *library layer*, which is where the - https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-core/[`webauthn-server-core`] + link:../webauthn-server-core/[`webauthn-server-core`] library gets involved. The entry point into the library is the - https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java[RelyingParty] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] class. + This layer implements the Web Authentication @@ -65,14 +65,14 @@ and exposes integration points for storage of challenges and credentials. Some notable integration points are: + ** The library user must provide an implementation of the -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java[CredentialRepository] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] interface to use for looking up stored public keys, user handles and signature counters. ** The library user can optionally provide an instance of the -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-core/src/main/java/com/yubico/webauthn/attestation/MetadataService.java[MetadataService] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.1.0/com/yubico/webauthn/attestation/AttestationTrustSource.html[`AttestationTrustSource`] interface to enable identification and validation of authenticator models. This instance is then used to look up trusted attestation root certificates. The -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-attestation/[`webauthn-server-attestation`] +link:../webauthn-server-attestation/[`webauthn-server-attestation`] sibling library provides implementations of this interface that are pre-seeded with Yubico device metadata. @@ -99,7 +99,7 @@ To build it, run === Standalone Java executable The standalone Java executable has the main class -https://github.com/Yubico/java-webauthn-server/blob/master/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java[`demo.webauthn.EmbeddedServer`]. +link:src/main/java/demo/webauthn/EmbeddedServer.java[`demo.webauthn.EmbeddedServer`]. This server also serves the REST API at `/api/v1/`, and static resources for the GUI under `/`. diff --git a/webauthn-server-demo/build.gradle b/webauthn-server-demo/build.gradle deleted file mode 100644 index ca28ec94a..000000000 --- a/webauthn-server-demo/build.gradle +++ /dev/null @@ -1,64 +0,0 @@ -plugins { - id 'java' - id 'war' - id 'application' - id 'scala' - id 'io.github.cosmicsilence.scalafix' -} - -description = 'WebAuthn demo' - -dependencies { - implementation(platform(rootProject)) - - implementation( - project(':webauthn-server-attestation'), - project(':webauthn-server-core'), - project(':yubico-util'), - - 'com.fasterxml.jackson.core:jackson-databind', - 'com.google.guava:guava', - 'com.upokecenter:cbor', - 'javax.ws.rs:javax.ws.rs-api', - 'org.bouncycastle:bcprov-jdk15on', - 'org.eclipse.jetty:jetty-server', - 'org.eclipse.jetty:jetty-servlet', - 'org.glassfish.jersey.containers:jersey-container-servlet-core', - 'org.slf4j:slf4j-api', - ) - - runtimeOnly( - 'ch.qos.logback:logback-classic:[1.2.3,2)', - 'org.glassfish.jersey.containers:jersey-container-servlet', - 'org.glassfish.jersey.inject:jersey-hk2', - ) - - testImplementation( - project(':webauthn-server-core').sourceSets.test.output, - project(':yubico-util-scala'), - - 'junit:junit', - 'org.mockito:mockito-core', - 'org.scala-lang:scala-library', - 'org.scalacheck:scalacheck_2.13', - 'org.scalatest:scalatest_2.13', - ) - - modules { - module('javax.servlet:servlet-api') { - replacedBy('javax.servlet:javax.servlet-api') - } - } -} - -mainClassName = 'demo.webauthn.EmbeddedServer' - -[installDist, distZip, distTar].each { task -> - def intoDir = (task == installDist) ? "/" : "${project.name}-${project.version}" - task.into(intoDir) { - from 'keystore.jks' - from('src/main/webapp') { - into 'src/main/webapp' - } - } -} diff --git a/webauthn-server-demo/build.gradle.kts b/webauthn-server-demo/build.gradle.kts new file mode 100644 index 000000000..99f5b84b6 --- /dev/null +++ b/webauthn-server-demo/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + java + war + application + scala + id("io.github.cosmicsilence.scalafix") +} + +description = "WebAuthn demo" + +// Can't use test fixtures because they interfere with pitest: https://github.com/gradle/gradle/issues/12168 +evaluationDependsOn(":webauthn-server-core") +val coreTestsOutput = project(":webauthn-server-core").extensions.getByType(SourceSetContainer::class).test.get().output + +dependencies { + implementation(platform(rootProject)) + implementation(platform(project(":test-platform"))) + + implementation(project(":webauthn-server-attestation")) + implementation(project(":webauthn-server-core")) + implementation(project(":yubico-util")) + + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("com.google.guava:guava") + implementation("com.upokecenter:cbor") + implementation("org.bouncycastle:bcprov-jdk18on") + implementation("org.slf4j:slf4j-api") + + implementation("org.eclipse.jetty:jetty-servlet:9.4.9.v20180320") + implementation("org.glassfish.jersey.containers:jersey-container-servlet-core:2.36") + implementation("javax.ws.rs:javax.ws.rs-api:2.1.1") + + runtimeOnly("ch.qos.logback:logback-classic:1.3.0") + runtimeOnly("org.glassfish.jersey.containers:jersey-container-servlet:2.36") + runtimeOnly("org.glassfish.jersey.inject:jersey-hk2:2.36") + + testImplementation(coreTestsOutput) + testImplementation(project(":yubico-util-scala")) + + testImplementation("junit:junit") + testImplementation("org.mockito:mockito-core") + testImplementation("org.scala-lang:scala-library") + testImplementation("org.scalacheck:scalacheck_2.13") + testImplementation("org.scalatest:scalatest_2.13") + testImplementation("org.scalatestplus:junit-4-13_2.13") + testImplementation("org.scalatestplus:scalacheck-1-16_2.13") + + modules { + module("javax.servlet:servlet-api") { + replacedBy("javax.servlet:javax.servlet-api") + } + } +} + +application { + mainClass.set("demo.webauthn.EmbeddedServer") +} + +for (task in listOf(tasks.installDist, tasks.distZip, tasks.distTar)) { + val intoDir = if (task == tasks.installDist) { "/" } else { "${project.name}-${project.version}" } + task { + into(intoDir) { + from("keystore.jks") + from("src/main/webapp") { + into("src/main/webapp") + } + } + } +} diff --git a/webauthn-server-demo/src/main/java/com/yubico/webauthn/U2fVerifier.java b/webauthn-server-demo/src/main/java/com/yubico/webauthn/U2fVerifier.java deleted file mode 100644 index 8f922fadd..000000000 --- a/webauthn-server-demo/src/main/java/com/yubico/webauthn/U2fVerifier.java +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) 2018, Yubico AB -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// -// 1. Redistributions of source code must retain the above copyright notice, this -// list of conditions and the following disclaimer. -// -// 2. Redistributions in binary form must reproduce the above copyright notice, -// this list of conditions and the following disclaimer in the documentation -// and/or other materials provided with the distribution. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -package com.yubico.webauthn; - -import com.fasterxml.jackson.databind.JsonNode; -import com.yubico.internal.util.CertificateParser; -import com.yubico.internal.util.ExceptionUtil; -import com.yubico.internal.util.JacksonCodecs; -import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.exception.Base64UrlException; -import com.yubico.webauthn.extension.appid.AppId; -import demo.webauthn.data.RegistrationRequest; -import demo.webauthn.data.U2fRegistrationResponse; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; - -public class U2fVerifier { - - public static boolean verify( - AppId appId, RegistrationRequest request, U2fRegistrationResponse response) - throws CertificateException, IOException, Base64UrlException { - final ByteArray appIdHash = Crypto.sha256(appId.getId()); - final ByteArray clientDataHash = - Crypto.sha256(response.getCredential().getU2fResponse().getClientDataJSON()); - - final JsonNode clientData = - JacksonCodecs.json() - .readTree(response.getCredential().getU2fResponse().getClientDataJSON().getBytes()); - final String challengeBase64 = clientData.get("challenge").textValue(); - - ExceptionUtil.assure( - request - .getPublicKeyCredentialCreationOptions() - .getChallenge() - .equals(ByteArray.fromBase64Url(challengeBase64)), - "Wrong challenge."); - - InputStream attestationCertAndSignatureStream = - new ByteArrayInputStream( - response.getCredential().getU2fResponse().getAttestationCertAndSignature().getBytes()); - - final X509Certificate attestationCert = - CertificateParser.parseDer(attestationCertAndSignatureStream); - - byte[] signatureBytes = new byte[attestationCertAndSignatureStream.available()]; - attestationCertAndSignatureStream.read(signatureBytes); - final ByteArray signature = new ByteArray(signatureBytes); - - return new U2fRawRegisterResponse( - response.getCredential().getU2fResponse().getPublicKey(), - response.getCredential().getU2fResponse().getKeyHandle(), - attestationCert, - signature) - .verifySignature(appIdHash, clientDataHash); - } -} diff --git a/webauthn-server-demo/src/main/java/com/yubico/webauthn/attestation/matcher/ExtensionMatcher.java b/webauthn-server-demo/src/main/java/com/yubico/webauthn/attestation/matcher/ExtensionMatcher.java index fa6f0b269..56c6fa415 100644 --- a/webauthn-server-demo/src/main/java/com/yubico/webauthn/attestation/matcher/ExtensionMatcher.java +++ b/webauthn-server-demo/src/main/java/com/yubico/webauthn/attestation/matcher/ExtensionMatcher.java @@ -30,6 +30,7 @@ import com.yubico.webauthn.data.exception.HexException; import java.io.IOException; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.security.cert.X509Certificate; import lombok.extern.slf4j.Slf4j; import org.bouncycastle.asn1.ASN1Primitive; @@ -37,7 +38,7 @@ @Slf4j public final class ExtensionMatcher implements DeviceMatcher { - private static final Charset CHARSET = Charset.forName("UTF-8"); + private static final Charset CHARSET = StandardCharsets.UTF_8; public static final String SELECTOR_TYPE = "x509Extension"; diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/Config.java b/webauthn-server-demo/src/main/java/demo/webauthn/Config.java index 94f69722a..bcb884be2 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/Config.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/Config.java @@ -26,13 +26,10 @@ import com.yubico.internal.util.CollectionUtil; import com.yubico.webauthn.data.RelyingPartyIdentity; -import com.yubico.webauthn.extension.appid.AppId; -import com.yubico.webauthn.extension.appid.InvalidAppIdException; import java.net.MalformedURLException; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; -import java.util.Optional; import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,14 +46,11 @@ public class Config { private final Set origins; private final int port; private final RelyingPartyIdentity rpIdentity; - private final Optional appId; - private Config( - Set origins, int port, RelyingPartyIdentity rpIdentity, Optional appId) { + private Config(Set origins, int port, RelyingPartyIdentity rpIdentity) { this.origins = CollectionUtil.immutableSet(origins); this.port = port; this.rpIdentity = rpIdentity; - this.appId = appId; } private static Config instance; @@ -64,11 +58,9 @@ private Config( private static Config getInstance() { if (instance == null) { try { - instance = new Config(computeOrigins(), computePort(), computeRpIdentity(), computeAppId()); + instance = new Config(computeOrigins(), computePort(), computeRpIdentity()); } catch (MalformedURLException e) { throw new RuntimeException(e); - } catch (InvalidAppIdException e) { - throw new RuntimeException(e); } } return instance; @@ -86,10 +78,6 @@ public static RelyingPartyIdentity getRpIdentity() { return getInstance().rpIdentity; } - public static Optional getAppId() { - return getInstance().appId; - } - private static Set computeOrigins() { final String origins = System.getenv("YUBICO_WEBAUTHN_ALLOWED_ORIGINS"); @@ -143,14 +131,4 @@ private static RelyingPartyIdentity computeRpIdentity() throws MalformedURLExcep logger.info("RP identity: {}", result); return result; } - - private static Optional computeAppId() throws InvalidAppIdException { - final String appId = System.getenv("YUBICO_WEBAUTHN_U2F_APPID"); - logger.debug("YUBICO_WEBAUTHN_U2F_APPID: {}", appId); - - AppId result = appId == null ? new AppId("https://localhost:8443") : new AppId(appId); - - logger.debug("U2F AppId: {}", result.getId()); - return Optional.of(result); - } } diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java index 370a0eb89..3c1e9983c 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java @@ -154,8 +154,6 @@ private StartRegistrationResponse(RegistrationRequest request) throws MalformedU private final class StartRegistrationActions { public final URL finish = uriInfo.getAbsolutePathBuilder().path("finish").build().toURL(); - public final URL finishU2f = - uriInfo.getAbsolutePathBuilder().path("finish-u2f").build().toURL(); private StartRegistrationActions() throws MalformedURLException {} } @@ -215,19 +213,6 @@ public Response finishRegistration(@NonNull String responseJson) { responseJson); } - @Path("register/finish-u2f") - @POST - public Response finishU2fRegistration(@NonNull String responseJson) throws ExecutionException { - logger.trace("finishRegistration responseJson: {}", responseJson); - Either, WebAuthnServer.SuccessfulU2fRegistrationResult> result = - server.finishU2fRegistration(responseJson); - return finishResponse( - result, - "U2F registration failed; further error message(s) were unfortunately lost to an internal server error.", - "finishU2fRegistration", - responseJson); - } - private final class StartAuthenticationResponse { public final boolean success = true; public final AssertionRequestWrapper request; @@ -393,7 +378,7 @@ private Response messagesJson(ResponseBuilder response, List messages) { } private String writeJson(Object o) throws JsonProcessingException { - if (uriInfo.getQueryParameters().keySet().contains("pretty")) { + if (uriInfo.getQueryParameters().containsKey("pretty")) { return jsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(o); } else { return jsonMapper.writeValueAsString(o); diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java index 7f535e821..6fed76f0c 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java @@ -36,7 +36,6 @@ import com.yubico.fido.metadata.FidoMetadataDownloaderException; import com.yubico.fido.metadata.UnexpectedLegalHeader; import com.yubico.internal.util.CertificateParser; -import com.yubico.internal.util.ExceptionUtil; import com.yubico.internal.util.JacksonCodecs; import com.yubico.util.Either; import com.yubico.webauthn.AssertionResult; @@ -47,7 +46,6 @@ import com.yubico.webauthn.RelyingParty; import com.yubico.webauthn.StartAssertionOptions; import com.yubico.webauthn.StartRegistrationOptions; -import com.yubico.webauthn.U2fVerifier; import com.yubico.webauthn.attestation.Attestation; import com.yubico.webauthn.attestation.YubicoJsonMetadataService; import com.yubico.webauthn.data.AttestationConveyancePreference; @@ -56,22 +54,18 @@ import com.yubico.webauthn.data.AuthenticatorTransport; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.COSEAlgorithmIdentifier; -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import com.yubico.webauthn.data.RelyingPartyIdentity; import com.yubico.webauthn.data.ResidentKeyRequirement; import com.yubico.webauthn.data.UserIdentity; import com.yubico.webauthn.data.exception.Base64UrlException; import com.yubico.webauthn.exception.AssertionFailedException; import com.yubico.webauthn.exception.RegistrationFailedException; -import com.yubico.webauthn.extension.appid.AppId; import com.yubico.webauthn.extension.appid.InvalidAppIdException; import demo.webauthn.data.AssertionRequestWrapper; import demo.webauthn.data.AssertionResponse; import demo.webauthn.data.CredentialRegistration; import demo.webauthn.data.RegistrationRequest; import demo.webauthn.data.RegistrationResponse; -import demo.webauthn.data.U2fRegistrationResponse; -import demo.webauthn.data.U2fRegistrationResult; import java.io.IOException; import java.security.DigestException; import java.security.InvalidAlgorithmParameterException; @@ -128,8 +122,7 @@ public WebAuthnServer() newCache(), newCache(), Config.getRpIdentity(), - Config.getOrigins(), - Config.getAppId()); + Config.getOrigins()); } public WebAuthnServer( @@ -137,8 +130,7 @@ public WebAuthnServer( Cache registerRequestStorage, Cache assertRequestStorage, RelyingPartyIdentity rpIdentity, - Set origins, - Optional appId) + Set origins) throws InvalidAppIdException, CertificateException, CertPathValidatorException, InvalidAlgorithmParameterException, Base64UrlException, DigestException, FidoMetadataDownloaderException, UnexpectedLegalHeader, IOException, @@ -158,7 +150,6 @@ public WebAuthnServer( .allowOriginSubdomain(false) .allowUntrustedAttestation(true) .validateSignatureCounter(true) - .appId(appId) .build(); } @@ -275,18 +266,6 @@ public SuccessfulRegistrationResult( } } - @Value - public class SuccessfulU2fRegistrationResult { - final boolean success = true; - final RegistrationRequest request; - final U2fRegistrationResponse response; - final CredentialRegistration registration; - boolean attestationTrusted; - Optional attestationCert; - final String username; - final ByteArray sessionToken; - } - @Value public static class AttestationCertInfo { final ByteArray der; @@ -392,86 +371,6 @@ public Either, SuccessfulRegistrationResult> finishRegistration( } } - public Either, SuccessfulU2fRegistrationResult> finishU2fRegistration( - String responseJson) throws ExecutionException { - logger.trace("finishU2fRegistration responseJson: {}", responseJson); - U2fRegistrationResponse response = null; - try { - response = jsonMapper.readValue(responseJson, U2fRegistrationResponse.class); - } catch (IOException e) { - logger.error("JSON error in finishU2fRegistration; responseJson: {}", responseJson, e); - return Either.left( - Arrays.asList( - "Registration failed!", "Failed to decode response object.", e.getMessage())); - } - - RegistrationRequest request = registerRequestStorage.getIfPresent(response.getRequestId()); - registerRequestStorage.invalidate(response.getRequestId()); - - if (request == null) { - logger.debug("fail finishU2fRegistration responseJson: {}", responseJson); - return Either.left( - Arrays.asList("Registration failed!", "No such registration in progress.")); - } else { - - try { - ExceptionUtil.assure( - U2fVerifier.verify(rp.getAppId().get(), request, response), - "Failed to verify signature."); - } catch (Exception e) { - logger.debug("Failed to verify U2F signature.", e); - return Either.left(Arrays.asList("Failed to verify signature.", e.getMessage())); - } - - X509Certificate attestationCert = null; - try { - attestationCert = - CertificateParser.parseDer( - response - .getCredential() - .getU2fResponse() - .getAttestationCertAndSignature() - .getBytes()); - } catch (CertificateException e) { - logger.error( - "Failed to parse attestation certificate: {}", - response.getCredential().getU2fResponse().getAttestationCertAndSignature(), - e); - } - - Optional attestation = metadataService.findMetadata(attestationCert); - - final U2fRegistrationResult result = - U2fRegistrationResult.builder() - .keyId( - PublicKeyCredentialDescriptor.builder() - .id(response.getCredential().getU2fResponse().getKeyHandle()) - .build()) - .attestationTrusted(attestation.isPresent()) - .publicKeyCose( - rawEcdaKeyToCose(response.getCredential().getU2fResponse().getPublicKey())) - .attestationMetadata(attestation) - .build(); - - return Either.right( - new SuccessfulU2fRegistrationResult( - request, - response, - addRegistration( - request.getPublicKeyCredentialCreationOptions().getUser(), - request.getCredentialNickname(), - 0, - result), - result.isAttestationTrusted(), - Optional.of( - new AttestationCertInfo( - response.getCredential().getU2fResponse().getAttestationCertAndSignature())), - request.getUsername(), - sessions.createSession( - request.getPublicKeyCredentialCreationOptions().getUser().getId()))); - } - } - public Either, AssertionRequestWrapper> startAuthentication( Optional username) { logger.trace("startAuthentication username: {}", username); @@ -655,24 +554,6 @@ private CredentialRegistration addRegistration( .flatMap(metadataService::findMetadata)); } - private CredentialRegistration addRegistration( - UserIdentity userIdentity, - Optional nickname, - long signatureCount, - U2fRegistrationResult result) { - return addRegistration( - userIdentity, - nickname, - RegisteredCredential.builder() - .credentialId(result.getKeyId().getId()) - .userHandle(userIdentity.getId()) - .publicKeyCose(result.getPublicKeyCose()) - .signatureCount(signatureCount) - .build(), - Collections.emptySortedSet(), - result.getAttestationMetadata()); - } - private CredentialRegistration addRegistration( UserIdentity userIdentity, Optional nickname, diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fCredential.java b/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fCredential.java deleted file mode 100644 index 769b3666e..000000000 --- a/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fCredential.java +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2018, Yubico AB -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// -// 1. Redistributions of source code must retain the above copyright notice, this -// list of conditions and the following disclaimer. -// -// 2. Redistributions in binary form must reproduce the above copyright notice, -// this list of conditions and the following disclaimer in the documentation -// and/or other materials provided with the distribution. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -package demo.webauthn.data; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.NonNull; -import lombok.Value; - -@Value -public class U2fCredential { - - private final U2fCredentialResponse u2fResponse; - - @JsonCreator - public U2fCredential(@NonNull @JsonProperty("u2fResponse") U2fCredentialResponse u2fResponse) { - this.u2fResponse = u2fResponse; - } -} diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fCredentialResponse.java b/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fCredentialResponse.java deleted file mode 100644 index 1ad3f5fc4..000000000 --- a/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fCredentialResponse.java +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2018, Yubico AB -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// -// 1. Redistributions of source code must retain the above copyright notice, this -// list of conditions and the following disclaimer. -// -// 2. Redistributions in binary form must reproduce the above copyright notice, -// this list of conditions and the following disclaimer in the documentation -// and/or other materials provided with the distribution. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -package demo.webauthn.data; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.yubico.webauthn.data.ByteArray; -import lombok.NonNull; -import lombok.Value; - -@Value -public class U2fCredentialResponse { - - private final ByteArray keyHandle; - private final ByteArray publicKey; - private final ByteArray attestationCertAndSignature; - private final ByteArray clientDataJSON; - - @JsonCreator - public U2fCredentialResponse( - @NonNull @JsonProperty("keyHandle") ByteArray keyHandle, - @NonNull @JsonProperty("publicKey") ByteArray publicKey, - @NonNull @JsonProperty("attestationCertAndSignature") ByteArray attestationCertAndSignature, - @NonNull @JsonProperty("clientDataJSON") ByteArray clientDataJSON) { - this.keyHandle = keyHandle; - this.publicKey = publicKey; - this.attestationCertAndSignature = attestationCertAndSignature; - this.clientDataJSON = clientDataJSON; - } -} diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fRegistrationResponse.java b/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fRegistrationResponse.java deleted file mode 100644 index ef0d612a0..000000000 --- a/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fRegistrationResponse.java +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2018, Yubico AB -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are met: -// -// 1. Redistributions of source code must retain the above copyright notice, this -// list of conditions and the following disclaimer. -// -// 2. Redistributions in binary form must reproduce the above copyright notice, -// this list of conditions and the following disclaimer in the documentation -// and/or other materials provided with the distribution. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -package demo.webauthn.data; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.yubico.webauthn.data.ByteArray; -import java.util.Optional; -import lombok.NonNull; -import lombok.Value; - -@Value -public class U2fRegistrationResponse { - - private final ByteArray requestId; - private final U2fCredential credential; - private final Optional sessionToken; - - @JsonCreator - public U2fRegistrationResponse( - @NonNull @JsonProperty("requestId") ByteArray requestId, - @NonNull @JsonProperty("credential") U2fCredential credential, - @NonNull @JsonProperty("sessionToken") Optional sessionToken) { - this.requestId = requestId; - this.credential = credential; - this.sessionToken = sessionToken; - } -} diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fRegistrationResult.java b/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fRegistrationResult.java deleted file mode 100644 index aaaf0c94d..000000000 --- a/webauthn-server-demo/src/main/java/demo/webauthn/data/U2fRegistrationResult.java +++ /dev/null @@ -1,27 +0,0 @@ -package demo.webauthn.data; - -import com.yubico.webauthn.attestation.Attestation; -import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import lombok.Builder; -import lombok.NonNull; -import lombok.Value; - -@Value -@Builder -public class U2fRegistrationResult { - - @NonNull private final PublicKeyCredentialDescriptor keyId; - - private final boolean attestationTrusted; - - @NonNull private final ByteArray publicKeyCose; - - @NonNull @Builder.Default private final List warnings = Collections.emptyList(); - - @NonNull @Builder.Default - private final Optional attestationMetadata = Optional.empty(); -} diff --git a/webauthn-server-demo/src/main/webapp/index.html b/webauthn-server-demo/src/main/webapp/index.html index 103e35a1d..df79e27d9 100644 --- a/webauthn-server-demo/src/main/webapp/index.html +++ b/webauthn-server-demo/src/main/webapp/index.html @@ -49,10 +49,8 @@ - - @@ -144,7 +142,7 @@ } function showRequest(data) { return showJson('request', data); } function showAuthenticatorResponse(data) { - const clientDataJson = data && (data.response && data.response.clientDataJSON || data.u2fResponse.clientDataJSON); + const clientDataJson = data && (data.response && data.response.clientDataJSON); return showJson('authenticator-response', extend( data, { _clientDataJson: data && JSON.parse(new TextDecoder('utf-8').decode(base64url.toByteArray(clientDataJson))), @@ -214,78 +212,10 @@ ; } -function executeRegisterRequest(request, useU2f) { +function executeRegisterRequest(request) { console.log('executeRegisterRequest', request); - if (useU2f) { - return executeU2fRegisterRequest(request); - } else { - return webauthnJson.create({ publicKey: request.publicKeyCredentialCreationOptions }); - } -} - -async function executeU2fRegisterRequest(request) { - const appId = 'https://localhost:8443'; - console.log('appId', appId); - const result = await u2fRegister( - appId, - [{ - version: 'U2F_V2', - challenge: request.publicKeyCredentialCreationOptions.challenge, - attestation: 'direct', - }], - request.publicKeyCredentialCreationOptions.excludeCredentials.map(cred => ({ - version: 'U2F_V2', - keyHandle: cred.id, - })) - ); - - const registrationDataBase64 = result.registrationData; - const clientDataBase64 = result.clientData; - const registrationDataBytes = base64url.toByteArray(registrationDataBase64); - - const publicKeyBytes = registrationDataBytes.slice(1, 1 + 65); - const L = registrationDataBytes[1 + 65]; - const keyHandleBytes = registrationDataBytes.slice(1 + 65 + 1, 1 + 65 + 1 + L); - - const attestationCertAndTrailingBytes = registrationDataBytes.slice(1 + 65 + 1 + L); - - return { - u2fResponse: { - keyHandle: base64url.fromByteArray(keyHandleBytes), - publicKey: base64url.fromByteArray(publicKeyBytes), - attestationCertAndSignature: base64url.fromByteArray(attestationCertAndTrailingBytes), - clientDataJSON: clientDataBase64, - }, - }; -} - -function u2fRegister(appId, registerRequests, registeredKeys) { - return new Promise((resolve, reject) => { - u2f.register( - appId, - registerRequests, - registeredKeys, - data => { - if (data.errorCode) { - switch (data.errorCode) { - case 2: - reject(new Error('Bad request.')); - break; - - case 4: - reject(new Error('This device is already registered.')); - break; - - default: - reject(new Error(`U2F failed with error: ${data.errorCode}`)); - } - } else { - resolve(data); - } - } - ); - }); + return webauthnJson.create({ publicKey: request.publicKeyCredentialCreationOptions }); } function submitResponse(url, request, response) { @@ -313,7 +243,6 @@ const statusStrings = params.statusStrings; /* { init, authenticatorRequest, serverRequest, success, } */ const executeRequest = params.executeRequest; /* function({ publicKeyCredentialCreationOptions: object } | { publicKeyCredentialRequestOptions: object }): Promise[PublicKeyCredential] */ const handleError = params.handleError; /* function(err): ? */ - const useU2f = params.useU2f; /* boolean */ setStatus('Looking up API paths...'); resetDisplays(); @@ -336,7 +265,6 @@ request, statusStrings, urls, - useU2f, }; const webauthnResponse = await executeRequest(request); @@ -350,7 +278,6 @@ const request = ceremonyState.request; const statusStrings = ceremonyState.statusStrings; const urls = ceremonyState.urls; - const useU2f = ceremonyState.useU2f; setStatus(statusStrings.serverRequest || 'Sending response to server...'); if (callbacks.serverRequest) { @@ -358,7 +285,7 @@ } showAuthenticatorResponse(response); - const data = await submitResponse(useU2f ? urls.finishU2f : urls.finish, request, response); + const data = await submitResponse(urls.finish, request, response); if (data && data.success) { setStatus(statusStrings.success); @@ -377,7 +304,6 @@ const username = document.getElementById('username').value; const displayName = document.getElementById('displayName').value; const credentialNickname = document.getElementById('credentialNickname').value; - const useU2f = document.getElementById('useU2f').checked; var request; @@ -392,9 +318,8 @@ }, executeRequest: req => { request = req; - return executeRegisterRequest(req, useU2f); + return executeRegisterRequest(req); }, - useU2f, }); if (data.registration) { @@ -612,15 +537,6 @@

Test your WebAuthn device

-
-
-
-
- - -
-
-
diff --git a/webauthn-server-demo/src/main/webapp/lib/fetch/LICENSE b/webauthn-server-demo/src/main/webapp/lib/fetch/LICENSE deleted file mode 100644 index 0e319d55d..000000000 --- a/webauthn-server-demo/src/main/webapp/lib/fetch/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -Copyright (c) 2014-2016 GitHub, Inc. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/webauthn-server-demo/src/main/webapp/lib/fetch/fetch-3.0.0.js b/webauthn-server-demo/src/main/webapp/lib/fetch/fetch-3.0.0.js deleted file mode 100644 index 06e4d1dcb..000000000 --- a/webauthn-server-demo/src/main/webapp/lib/fetch/fetch-3.0.0.js +++ /dev/null @@ -1,516 +0,0 @@ -var support = { - searchParams: 'URLSearchParams' in self, - iterable: 'Symbol' in self && 'iterator' in Symbol, - blob: - 'FileReader' in self && - 'Blob' in self && - (function() { - try { - new Blob() - return true - } catch (e) { - return false - } - })(), - formData: 'FormData' in self, - arrayBuffer: 'ArrayBuffer' in self -} - -function isDataView(obj) { - return obj && DataView.prototype.isPrototypeOf(obj) -} - -if (support.arrayBuffer) { - var viewClasses = [ - '[object Int8Array]', - '[object Uint8Array]', - '[object Uint8ClampedArray]', - '[object Int16Array]', - '[object Uint16Array]', - '[object Int32Array]', - '[object Uint32Array]', - '[object Float32Array]', - '[object Float64Array]' - ] - - var isArrayBufferView = - ArrayBuffer.isView || - function(obj) { - return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1 - } -} - -function normalizeName(name) { - if (typeof name !== 'string') { - name = String(name) - } - if (/[^a-z0-9\-#$%&'*+.^_`|~]/i.test(name)) { - throw new TypeError('Invalid character in header field name') - } - return name.toLowerCase() -} - -function normalizeValue(value) { - if (typeof value !== 'string') { - value = String(value) - } - return value -} - -// Build a destructive iterator for the value list -function iteratorFor(items) { - var iterator = { - next: function() { - var value = items.shift() - return {done: value === undefined, value: value} - } - } - - if (support.iterable) { - iterator[Symbol.iterator] = function() { - return iterator - } - } - - return iterator -} - -export function Headers(headers) { - this.map = {} - - if (headers instanceof Headers) { - headers.forEach(function(value, name) { - this.append(name, value) - }, this) - } else if (Array.isArray(headers)) { - headers.forEach(function(header) { - this.append(header[0], header[1]) - }, this) - } else if (headers) { - Object.getOwnPropertyNames(headers).forEach(function(name) { - this.append(name, headers[name]) - }, this) - } -} - -Headers.prototype.append = function(name, value) { - name = normalizeName(name) - value = normalizeValue(value) - var oldValue = this.map[name] - this.map[name] = oldValue ? oldValue + ', ' + value : value -} - -Headers.prototype['delete'] = function(name) { - delete this.map[normalizeName(name)] -} - -Headers.prototype.get = function(name) { - name = normalizeName(name) - return this.has(name) ? this.map[name] : null -} - -Headers.prototype.has = function(name) { - return this.map.hasOwnProperty(normalizeName(name)) -} - -Headers.prototype.set = function(name, value) { - this.map[normalizeName(name)] = normalizeValue(value) -} - -Headers.prototype.forEach = function(callback, thisArg) { - for (var name in this.map) { - if (this.map.hasOwnProperty(name)) { - callback.call(thisArg, this.map[name], name, this) - } - } -} - -Headers.prototype.keys = function() { - var items = [] - this.forEach(function(value, name) { - items.push(name) - }) - return iteratorFor(items) -} - -Headers.prototype.values = function() { - var items = [] - this.forEach(function(value) { - items.push(value) - }) - return iteratorFor(items) -} - -Headers.prototype.entries = function() { - var items = [] - this.forEach(function(value, name) { - items.push([name, value]) - }) - return iteratorFor(items) -} - -if (support.iterable) { - Headers.prototype[Symbol.iterator] = Headers.prototype.entries -} - -function consumed(body) { - if (body.bodyUsed) { - return Promise.reject(new TypeError('Already read')) - } - body.bodyUsed = true -} - -function fileReaderReady(reader) { - return new Promise(function(resolve, reject) { - reader.onload = function() { - resolve(reader.result) - } - reader.onerror = function() { - reject(reader.error) - } - }) -} - -function readBlobAsArrayBuffer(blob) { - var reader = new FileReader() - var promise = fileReaderReady(reader) - reader.readAsArrayBuffer(blob) - return promise -} - -function readBlobAsText(blob) { - var reader = new FileReader() - var promise = fileReaderReady(reader) - reader.readAsText(blob) - return promise -} - -function readArrayBufferAsText(buf) { - var view = new Uint8Array(buf) - var chars = new Array(view.length) - - for (var i = 0; i < view.length; i++) { - chars[i] = String.fromCharCode(view[i]) - } - return chars.join('') -} - -function bufferClone(buf) { - if (buf.slice) { - return buf.slice(0) - } else { - var view = new Uint8Array(buf.byteLength) - view.set(new Uint8Array(buf)) - return view.buffer - } -} - -function Body() { - this.bodyUsed = false - - this._initBody = function(body) { - this._bodyInit = body - if (!body) { - this._bodyText = '' - } else if (typeof body === 'string') { - this._bodyText = body - } else if (support.blob && Blob.prototype.isPrototypeOf(body)) { - this._bodyBlob = body - } else if (support.formData && FormData.prototype.isPrototypeOf(body)) { - this._bodyFormData = body - } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { - this._bodyText = body.toString() - } else if (support.arrayBuffer && support.blob && isDataView(body)) { - this._bodyArrayBuffer = bufferClone(body.buffer) - // IE 10-11 can't handle a DataView body. - this._bodyInit = new Blob([this._bodyArrayBuffer]) - } else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) { - this._bodyArrayBuffer = bufferClone(body) - } else { - this._bodyText = body = Object.prototype.toString.call(body) - } - - if (!this.headers.get('content-type')) { - if (typeof body === 'string') { - this.headers.set('content-type', 'text/plain;charset=UTF-8') - } else if (this._bodyBlob && this._bodyBlob.type) { - this.headers.set('content-type', this._bodyBlob.type) - } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { - this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8') - } - } - } - - if (support.blob) { - this.blob = function() { - var rejected = consumed(this) - if (rejected) { - return rejected - } - - if (this._bodyBlob) { - return Promise.resolve(this._bodyBlob) - } else if (this._bodyArrayBuffer) { - return Promise.resolve(new Blob([this._bodyArrayBuffer])) - } else if (this._bodyFormData) { - throw new Error('could not read FormData body as blob') - } else { - return Promise.resolve(new Blob([this._bodyText])) - } - } - - this.arrayBuffer = function() { - if (this._bodyArrayBuffer) { - return consumed(this) || Promise.resolve(this._bodyArrayBuffer) - } else { - return this.blob().then(readBlobAsArrayBuffer) - } - } - } - - this.text = function() { - var rejected = consumed(this) - if (rejected) { - return rejected - } - - if (this._bodyBlob) { - return readBlobAsText(this._bodyBlob) - } else if (this._bodyArrayBuffer) { - return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer)) - } else if (this._bodyFormData) { - throw new Error('could not read FormData body as text') - } else { - return Promise.resolve(this._bodyText) - } - } - - if (support.formData) { - this.formData = function() { - return this.text().then(decode) - } - } - - this.json = function() { - return this.text().then(JSON.parse) - } - - return this -} - -// HTTP methods whose capitalization should be normalized -var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] - -function normalizeMethod(method) { - var upcased = method.toUpperCase() - return methods.indexOf(upcased) > -1 ? upcased : method -} - -export function Request(input, options) { - options = options || {} - var body = options.body - - if (input instanceof Request) { - if (input.bodyUsed) { - throw new TypeError('Already read') - } - this.url = input.url - this.credentials = input.credentials - if (!options.headers) { - this.headers = new Headers(input.headers) - } - this.method = input.method - this.mode = input.mode - this.signal = input.signal - if (!body && input._bodyInit != null) { - body = input._bodyInit - input.bodyUsed = true - } - } else { - this.url = String(input) - } - - this.credentials = options.credentials || this.credentials || 'same-origin' - if (options.headers || !this.headers) { - this.headers = new Headers(options.headers) - } - this.method = normalizeMethod(options.method || this.method || 'GET') - this.mode = options.mode || this.mode || null - this.signal = options.signal || this.signal - this.referrer = null - - if ((this.method === 'GET' || this.method === 'HEAD') && body) { - throw new TypeError('Body not allowed for GET or HEAD requests') - } - this._initBody(body) -} - -Request.prototype.clone = function() { - return new Request(this, {body: this._bodyInit}) -} - -function decode(body) { - var form = new FormData() - body - .trim() - .split('&') - .forEach(function(bytes) { - if (bytes) { - var split = bytes.split('=') - var name = split.shift().replace(/\+/g, ' ') - var value = split.join('=').replace(/\+/g, ' ') - form.append(decodeURIComponent(name), decodeURIComponent(value)) - } - }) - return form -} - -function parseHeaders(rawHeaders) { - var headers = new Headers() - // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space - // https://tools.ietf.org/html/rfc7230#section-3.2 - var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ') - preProcessedHeaders.split(/\r?\n/).forEach(function(line) { - var parts = line.split(':') - var key = parts.shift().trim() - if (key) { - var value = parts.join(':').trim() - headers.append(key, value) - } - }) - return headers -} - -Body.call(Request.prototype) - -export function Response(bodyInit, options) { - if (!options) { - options = {} - } - - this.type = 'default' - this.status = options.status === undefined ? 200 : options.status - this.ok = this.status >= 200 && this.status < 300 - this.statusText = 'statusText' in options ? options.statusText : 'OK' - this.headers = new Headers(options.headers) - this.url = options.url || '' - this._initBody(bodyInit) -} - -Body.call(Response.prototype) - -Response.prototype.clone = function() { - return new Response(this._bodyInit, { - status: this.status, - statusText: this.statusText, - headers: new Headers(this.headers), - url: this.url - }) -} - -Response.error = function() { - var response = new Response(null, {status: 0, statusText: ''}) - response.type = 'error' - return response -} - -var redirectStatuses = [301, 302, 303, 307, 308] - -Response.redirect = function(url, status) { - if (redirectStatuses.indexOf(status) === -1) { - throw new RangeError('Invalid status code') - } - - return new Response(null, {status: status, headers: {location: url}}) -} - -export var DOMException = self.DOMException -try { - new DOMException() -} catch (err) { - DOMException = function(message, name) { - this.message = message - this.name = name - var error = Error(message) - this.stack = error.stack - } - DOMException.prototype = Object.create(Error.prototype) - DOMException.prototype.constructor = DOMException -} - -export function fetch(input, init) { - return new Promise(function(resolve, reject) { - var request = new Request(input, init) - - if (request.signal && request.signal.aborted) { - return reject(new DOMException('Aborted', 'AbortError')) - } - - var xhr = new XMLHttpRequest() - - function abortXhr() { - xhr.abort() - } - - xhr.onload = function() { - var options = { - status: xhr.status, - statusText: xhr.statusText, - headers: parseHeaders(xhr.getAllResponseHeaders() || '') - } - options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL') - var body = 'response' in xhr ? xhr.response : xhr.responseText - resolve(new Response(body, options)) - } - - xhr.onerror = function() { - reject(new TypeError('Network request failed')) - } - - xhr.ontimeout = function() { - reject(new TypeError('Network request failed')) - } - - xhr.onabort = function() { - reject(new DOMException('Aborted', 'AbortError')) - } - - xhr.open(request.method, request.url, true) - - if (request.credentials === 'include') { - xhr.withCredentials = true - } else if (request.credentials === 'omit') { - xhr.withCredentials = false - } - - if ('responseType' in xhr && support.blob) { - xhr.responseType = 'blob' - } - - request.headers.forEach(function(value, name) { - xhr.setRequestHeader(name, value) - }) - - if (request.signal) { - request.signal.addEventListener('abort', abortXhr) - - xhr.onreadystatechange = function() { - // DONE (success or failure) - if (xhr.readyState === 4) { - request.signal.removeEventListener('abort', abortXhr) - } - } - } - - xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit) - }) -} - -fetch.polyfill = true - -if (!self.fetch) { - self.fetch = fetch - self.Headers = Headers - self.Request = Request - self.Response = Response -} diff --git a/webauthn-server-demo/src/main/webapp/lib/fetch/package.json b/webauthn-server-demo/src/main/webapp/lib/fetch/package.json deleted file mode 100644 index 874b605de..000000000 --- a/webauthn-server-demo/src/main/webapp/lib/fetch/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "whatwg-fetch", - "description": "A window.fetch polyfill.", - "version": "3.0.0", - "main": "./dist/fetch.umd.js", - "module": "./fetch.js", - "repository": "github/fetch", - "license": "MIT", - "devDependencies": { - "abortcontroller-polyfill": "^1.1.9", - "chai": "^4.1.2", - "eslint": "^4.19.1", - "eslint-plugin-github": "^1.0.0", - "karma": "^3.0.0", - "karma-chai": "^0.1.0", - "karma-chrome-launcher": "^2.2.0", - "karma-detect-browsers": "^2.3.2", - "karma-firefox-launcher": "^1.1.0", - "karma-mocha": "^1.3.0", - "karma-safari-launcher": "^1.0.0", - "karma-safaritechpreview-launcher": "0.0.6", - "mocha": "^4.0.1", - "promise-polyfill": "6.0.2", - "rollup": "^0.59.1", - "url-search-params": "0.6.1" - }, - "files": [ - "LICENSE", - "dist/fetch.umd.js", - "dist/fetch.umd.js.flow", - "fetch.js", - "fetch.js.flow" - ], - "scripts": { - "karma": "karma start ./test/karma.config.js --no-single-run --auto-watch", - "prepare": "make dist/fetch.umd.js dist/fetch.umd.js.flow", - "pretest": "make", - "test": "karma start ./test/karma.config.js && karma start ./test/karma-worker.config.js" - } -} diff --git a/webauthn-server-demo/src/main/webapp/lib/u2f-api-1.1.js b/webauthn-server-demo/src/main/webapp/lib/u2f-api-1.1.js deleted file mode 100644 index 17d4a1f36..000000000 --- a/webauthn-server-demo/src/main/webapp/lib/u2f-api-1.1.js +++ /dev/null @@ -1,822 +0,0 @@ -/* eslint-disable */ -//Copyright 2014-2015 Google Inc. All rights reserved. - -//Use of this source code is governed by a BSD-style -//license that can be found in the LICENSE file or at -//https://developers.google.com/open-source/licenses/bsd - -/** - * @fileoverview The U2F api. - */ -"use strict"; - -/** - * Namespace for the U2F api. - * @type {Object} - */ -var u2f = u2f || {}; - -/** - * FIDO U2F Javascript API Version - * @number - */ -var js_api_version; - -/** - * The U2F extension id - * @const {string} - */ -// The Chrome packaged app extension ID. -// Uncomment this if you want to deploy a server instance that uses -// the package Chrome app and does not require installing the U2F Chrome extension. -u2f.EXTENSION_ID = "kmendfapggjehodndflmmgagdbamhnfd"; -// The U2F Chrome extension ID. -// Uncomment this if you want to deploy a server instance that uses -// the U2F Chrome extension to authenticate. -// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; - -/** - * Message types for messsages to/from the extension - * @const - * @enum {string} - */ -u2f.MessageTypes = { - U2F_REGISTER_REQUEST: "u2f_register_request", - U2F_REGISTER_RESPONSE: "u2f_register_response", - U2F_SIGN_REQUEST: "u2f_sign_request", - U2F_SIGN_RESPONSE: "u2f_sign_response", - U2F_GET_API_VERSION_REQUEST: "u2f_get_api_version_request", - U2F_GET_API_VERSION_RESPONSE: "u2f_get_api_version_response" -}; - -/** - * Response status codes - * @const - * @enum {number} - */ -u2f.ErrorCodes = { - OK: 0, - OTHER_ERROR: 1, - BAD_REQUEST: 2, - CONFIGURATION_UNSUPPORTED: 3, - DEVICE_INELIGIBLE: 4, - TIMEOUT: 5 -}; - -/** - * A message for registration requests - * @typedef {{ - * type: u2f.MessageTypes, - * appId: ?string, - * timeoutSeconds: ?number, - * requestId: ?number - * }} - */ -u2f.U2fRequest; - -/** - * A message for registration responses - * @typedef {{ - * type: u2f.MessageTypes, - * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), - * requestId: ?number - * }} - */ -u2f.U2fResponse; - -/** - * An error object for responses - * @typedef {{ - * errorCode: u2f.ErrorCodes, - * errorMessage: ?string - * }} - */ -u2f.Error; - -/** - * Data object for a single sign request. - * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} - */ -u2f.Transport; - -/** - * Data object for a single sign request. - * @typedef {Array} - */ -u2f.Transports; - -/** - * Data object for a single sign request. - * @typedef {{ - * version: string, - * challenge: string, - * keyHandle: string, - * appId: string - * }} - */ -u2f.SignRequest; - -/** - * Data object for a sign response. - * @typedef {{ - * keyHandle: string, - * signatureData: string, - * clientData: string - * }} - */ -u2f.SignResponse; - -/** - * Data object for a registration request. - * @typedef {{ - * version: string, - * challenge: string - * }} - */ -u2f.RegisterRequest; - -/** - * Data object for a registration response. - * @typedef {{ - * version: string, - * keyHandle: string, - * transports: Transports, - * appId: string - * }} - */ -u2f.RegisterResponse; - -/** - * Data object for a registered key. - * @typedef {{ - * version: string, - * keyHandle: string, - * transports: ?Transports, - * appId: ?string - * }} - */ -u2f.RegisteredKey; - -/** - * Data object for a get API register response. - * @typedef {{ - * js_api_version: number - * }} - */ -u2f.GetJsApiVersionResponse; - -//Low level MessagePort API support - -/** - * Sets up a MessagePort to the U2F extension using the - * available mechanisms. - * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback - */ -u2f.getMessagePort = function(callback) { - if (typeof chrome != "undefined" && chrome.runtime) { - // The actual message here does not matter, but we need to get a reply - // for the callback to run. Thus, send an empty signature request - // in order to get a failure response. - var msg = { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - signRequests: [] - }; - chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { - if (!chrome.runtime.lastError) { - // We are on a whitelisted origin and can talk directly - // with the extension. - u2f.getChromeRuntimePort_(callback); - } else { - // chrome.runtime was available, but we couldn't message - // the extension directly, use iframe - u2f.getIframePort_(callback); - } - }); - } else if (u2f.isAndroidChrome_()) { - u2f.getAuthenticatorPort_(callback); - } else if (u2f.isIosChrome_()) { - u2f.getIosPort_(callback); - } else { - // chrome.runtime was not available at all, which is normal - // when this origin doesn't have access to any extensions. - u2f.getIframePort_(callback); - } -}; - -/** - * Detect chrome running on android based on the browser's useragent. - * @private - */ -u2f.isAndroidChrome_ = function() { - var userAgent = navigator.userAgent; - return ( - userAgent.indexOf("Chrome") != -1 && userAgent.indexOf("Android") != -1 - ); -}; - -/** - * Detect chrome running on iOS based on the browser's platform. - * @private - */ -u2f.isIosChrome_ = function() { - return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1; -}; - -/** - * Connects directly to the extension via chrome.runtime.connect. - * @param {function(u2f.WrappedChromeRuntimePort_)} callback - * @private - */ -u2f.getChromeRuntimePort_ = function(callback) { - var port = chrome.runtime.connect(u2f.EXTENSION_ID, { - includeTlsChannelId: true - }); - setTimeout(function() { - callback(new u2f.WrappedChromeRuntimePort_(port)); - }, 0); -}; - -/** - * Return a 'port' abstraction to the Authenticator app. - * @param {function(u2f.WrappedAuthenticatorPort_)} callback - * @private - */ -u2f.getAuthenticatorPort_ = function(callback) { - setTimeout(function() { - callback(new u2f.WrappedAuthenticatorPort_()); - }, 0); -}; - -/** - * Return a 'port' abstraction to the iOS client app. - * @param {function(u2f.WrappedIosPort_)} callback - * @private - */ -u2f.getIosPort_ = function(callback) { - setTimeout(function() { - callback(new u2f.WrappedIosPort_()); - }, 0); -}; - -/** - * A wrapper for chrome.runtime.Port that is compatible with MessagePort. - * @param {Port} port - * @constructor - * @private - */ -u2f.WrappedChromeRuntimePort_ = function(port) { - this.port_ = port; -}; - -/** - * Format and return a sign request compliant with the JS API version supported by the extension. - * @param {Array} signRequests - * @param {number} timeoutSeconds - * @param {number} reqId - * @return {Object} - */ -u2f.formatSignRequest_ = function( - appId, - challenge, - registeredKeys, - timeoutSeconds, - reqId -) { - if (js_api_version === undefined || js_api_version < 1.1) { - // Adapt request to the 1.0 JS API - var signRequests = []; - for (var i = 0; i < registeredKeys.length; i++) { - signRequests[i] = { - version: registeredKeys[i].version, - challenge: challenge, - keyHandle: registeredKeys[i].keyHandle, - appId: appId - }; - } - return { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - signRequests: signRequests, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - } - // JS 1.1 API - return { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - appId: appId, - challenge: challenge, - registeredKeys: registeredKeys, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; -}; - -/** - * Format and return a register request compliant with the JS API version supported by the extension.. - * @param {Array} signRequests - * @param {Array} signRequests - * @param {number} timeoutSeconds - * @param {number} reqId - * @return {Object} - */ -u2f.formatRegisterRequest_ = function( - appId, - registeredKeys, - registerRequests, - timeoutSeconds, - reqId -) { - if (js_api_version === undefined || js_api_version < 1.1) { - // Adapt request to the 1.0 JS API - for (var i = 0; i < registerRequests.length; i++) { - registerRequests[i].appId = appId; - } - var signRequests = []; - for (var i = 0; i < registeredKeys.length; i++) { - signRequests[i] = { - version: registeredKeys[i].version, - challenge: registerRequests[0], - keyHandle: registeredKeys[i].keyHandle, - appId: appId - }; - } - return { - type: u2f.MessageTypes.U2F_REGISTER_REQUEST, - signRequests: signRequests, - registerRequests: registerRequests, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - } - // JS 1.1 API - return { - type: u2f.MessageTypes.U2F_REGISTER_REQUEST, - appId: appId, - registerRequests: registerRequests, - registeredKeys: registeredKeys, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; -}; - -/** - * Posts a message on the underlying channel. - * @param {Object} message - */ -u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { - this.port_.postMessage(message); -}; - -/** - * Emulates the HTML 5 addEventListener interface. Works only for the - * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. - * @param {string} eventName - * @param {function({data: Object})} handler - */ -u2f.WrappedChromeRuntimePort_.prototype.addEventListener = function( - eventName, - handler -) { - var name = eventName.toLowerCase(); - if (name == "message" || name == "onmessage") { - this.port_.onMessage.addListener(function(message) { - // Emulate a minimal MessageEvent object - handler({ data: message }); - }); - } else { - console.error("WrappedChromeRuntimePort only supports onMessage"); - } -}; - -/** - * Wrap the Authenticator app with a MessagePort interface. - * @constructor - * @private - */ -u2f.WrappedAuthenticatorPort_ = function() { - this.requestId_ = -1; - this.requestObject_ = null; -}; - -/** - * Launch the Authenticator intent. - * @param {Object} message - */ -u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { - var intentUrl = - u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + - ";S.request=" + - encodeURIComponent(JSON.stringify(message)) + - ";end"; - document.location = intentUrl; -}; - -/** - * Tells what type of port this is. - * @return {String} port type - */ -u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { - return "WrappedAuthenticatorPort_"; -}; - -/** - * Emulates the HTML 5 addEventListener interface. - * @param {string} eventName - * @param {function({data: Object})} handler - */ -u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function( - eventName, - handler -) { - var name = eventName.toLowerCase(); - if (name == "message") { - var self = this; - /* Register a callback to that executes when - * chrome injects the response. */ - window.addEventListener( - "message", - self.onRequestUpdate_.bind(self, handler), - false - ); - } else { - console.error("WrappedAuthenticatorPort only supports message"); - } -}; - -/** - * Callback invoked when a response is received from the Authenticator. - * @param function({data: Object}) callback - * @param {Object} message message Object - */ -u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = function( - callback, - message -) { - var messageObject = JSON.parse(message.data); - var intentUrl = messageObject["intentURL"]; - - var errorCode = messageObject["errorCode"]; - var responseObject = null; - if (messageObject.hasOwnProperty("data")) { - responseObject = /** @type {Object} */ (JSON.parse(messageObject["data"])); - } - - callback({ data: responseObject }); -}; - -/** - * Base URL for intents to Authenticator. - * @const - * @private - */ -u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = - "intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE"; - -/** - * Wrap the iOS client app with a MessagePort interface. - * @constructor - * @private - */ -u2f.WrappedIosPort_ = function() {}; - -/** - * Launch the iOS client app request - * @param {Object} message - */ -u2f.WrappedIosPort_.prototype.postMessage = function(message) { - var str = JSON.stringify(message); - var url = "u2f://auth?" + encodeURI(str); - location.replace(url); -}; - -/** - * Tells what type of port this is. - * @return {String} port type - */ -u2f.WrappedIosPort_.prototype.getPortType = function() { - return "WrappedIosPort_"; -}; - -/** - * Emulates the HTML 5 addEventListener interface. - * @param {string} eventName - * @param {function({data: Object})} handler - */ -u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { - var name = eventName.toLowerCase(); - if (name !== "message") { - console.error("WrappedIosPort only supports message"); - } -}; - -/** - * Sets up an embedded trampoline iframe, sourced from the extension. - * @param {function(MessagePort)} callback - * @private - */ -u2f.getIframePort_ = function(callback) { - // Create the iframe - var iframeOrigin = "chrome-extension://" + u2f.EXTENSION_ID; - var iframe = document.createElement("iframe"); - iframe.src = iframeOrigin + "/u2f-comms.html"; - iframe.setAttribute("style", "display:none"); - document.body.appendChild(iframe); - - var channel = new MessageChannel(); - var ready = function(message) { - if (message.data == "ready") { - channel.port1.removeEventListener("message", ready); - callback(channel.port1); - } else { - console.error('First event on iframe port was not "ready"'); - } - }; - channel.port1.addEventListener("message", ready); - channel.port1.start(); - - iframe.addEventListener("load", function() { - // Deliver the port to the iframe and initialize - iframe.contentWindow.postMessage("init", iframeOrigin, [channel.port2]); - }); -}; - -//High-level JS API - -/** - * Default extension response timeout in seconds. - * @const - */ -u2f.EXTENSION_TIMEOUT_SEC = 30; - -/** - * A singleton instance for a MessagePort to the extension. - * @type {MessagePort|u2f.WrappedChromeRuntimePort_} - * @private - */ -u2f.port_ = null; - -/** - * Callbacks waiting for a port - * @type {Array} - * @private - */ -u2f.waitingForPort_ = []; - -/** - * A counter for requestIds. - * @type {number} - * @private - */ -u2f.reqCounter_ = 0; - -/** - * A map from requestIds to client callbacks - * @type {Object.} - * @private - */ -u2f.callbackMap_ = {}; - -/** - * Creates or retrieves the MessagePort singleton to use. - * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback - * @private - */ -u2f.getPortSingleton_ = function(callback) { - if (u2f.port_) { - callback(u2f.port_); - } else { - if (u2f.waitingForPort_.length == 0) { - u2f.getMessagePort(function(port) { - u2f.port_ = port; - u2f.port_.addEventListener( - "message", - /** @type {function(Event)} */ (u2f.responseHandler_) - ); - - // Careful, here be async callbacks. Maybe. - while (u2f.waitingForPort_.length) - u2f.waitingForPort_.shift()(u2f.port_); - }); - } - u2f.waitingForPort_.push(callback); - } -}; - -/** - * Handles response messages from the extension. - * @param {MessageEvent.} message - * @private - */ -u2f.responseHandler_ = function(message) { - var response = message.data; - var reqId = response["requestId"]; - if (!reqId || !u2f.callbackMap_[reqId]) { - console.error("Unknown or missing requestId in response."); - return; - } - var cb = u2f.callbackMap_[reqId]; - delete u2f.callbackMap_[reqId]; - cb(response["responseData"]); -}; - -/** - * Dispatches an array of sign requests to available U2F tokens. - * If the JS API version supported by the extension is unknown, it first sends a - * message to the extension to find out the supported API version and then it sends - * the sign request. - * @param {string=} appId - * @param {string=} challenge - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.SignResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.sign = function( - appId, - challenge, - registeredKeys, - callback, - opt_timeoutSeconds -) { - if (js_api_version === undefined) { - // Send a message to get the extension to JS API version, then send the actual sign request. - u2f.getApiVersion(function(response) { - js_api_version = - response["js_api_version"] === undefined - ? 0 - : response["js_api_version"]; - console.log("Extension JS API Version: ", js_api_version); - u2f.sendSignRequest( - appId, - challenge, - registeredKeys, - callback, - opt_timeoutSeconds - ); - }); - } else { - // We know the JS API version. Send the actual sign request in the supported API version. - u2f.sendSignRequest( - appId, - challenge, - registeredKeys, - callback, - opt_timeoutSeconds - ); - } -}; - -/** - * Dispatches an array of sign requests to available U2F tokens. - * @param {string=} appId - * @param {string=} challenge - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.SignResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.sendSignRequest = function( - appId, - challenge, - registeredKeys, - callback, - opt_timeoutSeconds -) { - u2f.getPortSingleton_(function(port) { - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var timeoutSeconds = - typeof opt_timeoutSeconds !== "undefined" - ? opt_timeoutSeconds - : u2f.EXTENSION_TIMEOUT_SEC; - var req = u2f.formatSignRequest_( - appId, - challenge, - registeredKeys, - timeoutSeconds, - reqId - ); - port.postMessage(req); - }); -}; - -/** - * Dispatches register requests to available U2F tokens. An array of sign - * requests identifies already registered tokens. - * If the JS API version supported by the extension is unknown, it first sends a - * message to the extension to find out the supported API version and then it sends - * the register request. - * @param {string=} appId - * @param {Array} registerRequests - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.RegisterResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.register = function( - appId, - registerRequests, - registeredKeys, - callback, - opt_timeoutSeconds -) { - if (js_api_version === undefined) { - // Send a message to get the extension to JS API version, then send the actual register request. - u2f.getApiVersion(function(response) { - js_api_version = - response["js_api_version"] === undefined - ? 0 - : response["js_api_version"]; - console.log("Extension JS API Version: ", js_api_version); - u2f.sendRegisterRequest( - appId, - registerRequests, - registeredKeys, - callback, - opt_timeoutSeconds - ); - }); - } else { - // We know the JS API version. Send the actual register request in the supported API version. - u2f.sendRegisterRequest( - appId, - registerRequests, - registeredKeys, - callback, - opt_timeoutSeconds - ); - } -}; - -/** - * Dispatches register requests to available U2F tokens. An array of sign - * requests identifies already registered tokens. - * @param {string=} appId - * @param {Array} registerRequests - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.RegisterResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.sendRegisterRequest = function( - appId, - registerRequests, - registeredKeys, - callback, - opt_timeoutSeconds -) { - u2f.getPortSingleton_(function(port) { - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var timeoutSeconds = - typeof opt_timeoutSeconds !== "undefined" - ? opt_timeoutSeconds - : u2f.EXTENSION_TIMEOUT_SEC; - var req = u2f.formatRegisterRequest_( - appId, - registeredKeys, - registerRequests, - timeoutSeconds, - reqId - ); - port.postMessage(req); - }); -}; - -/** - * Dispatches a message to the extension to find out the supported - * JS API version. - * If the user is on a mobile phone and is thus using Google Authenticator instead - * of the Chrome extension, don't send the request and simply return 0. - * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback - * @param {number=} opt_timeoutSeconds - */ -u2f.getApiVersion = function(callback, opt_timeoutSeconds) { - u2f.getPortSingleton_(function(port) { - // If we are using Android Google Authenticator or iOS client app, - // do not fire an intent to ask which JS API version to use. - if (port.getPortType) { - var apiVersion; - switch (port.getPortType()) { - case "WrappedIosPort_": - case "WrappedAuthenticatorPort_": - apiVersion = 1.1; - break; - - default: - apiVersion = 0; - break; - } - callback({ js_api_version: apiVersion }); - return; - } - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var req = { - type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, - timeoutSeconds: - typeof opt_timeoutSeconds !== "undefined" - ? opt_timeoutSeconds - : u2f.EXTENSION_TIMEOUT_SEC, - requestId: reqId - }; - port.postMessage(req); - }); -}; diff --git a/webauthn-server-demo/src/test/scala/demo/webauthn/JsonSerializationSpec.scala b/webauthn-server-demo/src/test/scala/demo/webauthn/JsonSerializationSpec.scala index 6ae4d5bf4..9e4b28876 100644 --- a/webauthn-server-demo/src/test/scala/demo/webauthn/JsonSerializationSpec.scala +++ b/webauthn-server-demo/src/test/scala/demo/webauthn/JsonSerializationSpec.scala @@ -29,12 +29,12 @@ import com.yubico.webauthn.RegistrationTestData import com.yubico.webauthn.data.AuthenticatorAttestationResponse import demo.webauthn.data.RegistrationResponse import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner @RunWith(classOf[JUnitRunner]) -class JsonSerializationSpec extends FunSpec with Matchers { +class JsonSerializationSpec extends AnyFunSpec with Matchers { private val jsonMapper = JacksonCodecs.json() diff --git a/webauthn-server-demo/src/test/scala/demo/webauthn/WebAuthnServerSpec.scala b/webauthn-server-demo/src/test/scala/demo/webauthn/WebAuthnServerSpec.scala index 247bdae7e..a185d8c80 100644 --- a/webauthn-server-demo/src/test/scala/demo/webauthn/WebAuthnServerSpec.scala +++ b/webauthn-server-demo/src/test/scala/demo/webauthn/WebAuthnServerSpec.scala @@ -38,13 +38,12 @@ import com.yubico.webauthn.data.Generators.arbitraryAuthenticatorTransport import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.ResidentKeyRequirement -import com.yubico.webauthn.extension.appid.AppId import demo.webauthn.data.AssertionRequestWrapper import demo.webauthn.data.CredentialRegistration import demo.webauthn.data.RegistrationRequest import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @@ -59,7 +58,7 @@ import scala.jdk.OptionConverters.RichOptional @RunWith(classOf[JUnitRunner]) class WebAuthnServerSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { @@ -72,7 +71,6 @@ class WebAuthnServerSpec private val rpId = RelyingPartyIdentity.builder().id("localhost").name("Test party").build() private val origins = Set("localhost").asJava - private val appId = Optional.empty[AppId] describe("WebAuthnServer") { @@ -176,7 +174,6 @@ class WebAuthnServerSpec newCache(), rpId, Set("https://localhost").asJava, - appId, ) val (cred, keypair) = { @@ -292,7 +289,6 @@ class WebAuthnServerSpec assertionRequests, rpId, origins, - appId, ) } } @@ -340,7 +336,6 @@ class WebAuthnServerSpec newCache(), rpId, origins, - appId, ) } @@ -400,7 +395,6 @@ class WebAuthnServerSpec newCache(), rpId, origins, - appId, ) } diff --git a/yubico-util-scala/build.gradle b/yubico-util-scala/build.gradle deleted file mode 100644 index 5ff4568f4..000000000 --- a/yubico-util-scala/build.gradle +++ /dev/null @@ -1,22 +0,0 @@ -plugins { - id 'scala' - id 'io.github.cosmicsilence.scalafix' -} - -description = 'Yubico internal Scala utilities' - -sourceCompatibility = 1.8 -targetCompatibility = 1.8 - -dependencies { - implementation(platform(rootProject)) - - implementation( - 'org.scala-lang:scala-library', - 'org.scalacheck:scalacheck_2.13', - ) - - testImplementation( - 'org.scalatest:scalatest_2.13', - ) -} diff --git a/yubico-util-scala/build.gradle.kts b/yubico-util-scala/build.gradle.kts new file mode 100644 index 000000000..8e5ee2718 --- /dev/null +++ b/yubico-util-scala/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + scala + id("io.github.cosmicsilence.scalafix") +} + +description = "Yubico internal Scala utilities" + +java { + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation(platform(rootProject)) + implementation(platform(project(":test-platform"))) + + implementation("org.bouncycastle:bcprov-jdk18on") + implementation("org.scala-lang:scala-library") + implementation("org.scalacheck:scalacheck_2.13") + implementation("org.scalatest:scalatest_2.13") +} diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestWithEachProvider.scala b/yubico-util-scala/src/main/scala/com/yubico/webauthn/TestWithEachProvider.scala similarity index 72% rename from webauthn-server-core/src/test/scala/com/yubico/webauthn/TestWithEachProvider.scala rename to yubico-util-scala/src/main/scala/com/yubico/webauthn/TestWithEachProvider.scala index 85669d892..7d9d53d0a 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestWithEachProvider.scala +++ b/yubico-util-scala/src/main/scala/com/yubico/webauthn/TestWithEachProvider.scala @@ -1,14 +1,32 @@ package com.yubico.webauthn import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import java.security.Provider import java.security.Security trait TestWithEachProvider extends Matchers { - this: FunSpec => + this: AnyFunSpec => + + /** Run the `body` in a context with the given JCA [[Security]] providers, + * then reset the providers to their state before. + */ + def withProviderContext( + providers: List[Provider] + )( + body: => Any + ): Unit = { + val originalProviders = Security.getProviders.toList + Security.getProviders.foreach(prov => Security.removeProvider(prov.getName)) + providers.foreach(Security.addProvider) + + body + + Security.getProviders.foreach(prov => Security.removeProvider(prov.getName)) + originalProviders.foreach(Security.addProvider) + } def wrapItFunctionWithProviderContext( providerSetName: String, @@ -16,31 +34,20 @@ trait TestWithEachProvider extends Matchers { testSetupFun: (String => (=> Any) => Unit) => Any, ): Any = { - /** Wrapper around the standard [[FunSpec#it]] that sets the JCA + /** Wrapper around the standard [[AnyFunSpec#it]] that sets the JCA * [[Security]] providers before running the test, and then resets the * providers to the original state after the test. * * This is needed because ScalaTest shared tests work by taking fixture * parameters as lexical context, but JCA providers are set in the dynamic - * context. The [[FunSpec#it]] call does not immediately run the test, + * context. The [[AnyFunSpec#it]] call does not immediately run the test, * instead it registers a test to be run later. This helper ensures that * the dynamic context matches the lexical context at the time the test * runs. */ def it(testName: String)(testFun: => Any): Unit = { this.it.apply(testName) { - val originalProviders = Security.getProviders.toList - Security.getProviders.foreach(prov => - Security.removeProvider(prov.getName) - ) - providers.foreach(Security.addProvider) - - testFun - - Security.getProviders.foreach(prov => - Security.removeProvider(prov.getName) - ) - originalProviders.foreach(Security.addProvider) + withProviderContext(providers)(testFun) } } @@ -58,11 +65,11 @@ trait TestWithEachProvider extends Matchers { * and then reset the providers to the original state after the test. * * The caller SHOULD name the callback parameter `it`, in order to shadow the - * standard [[FunSpec#it]] from ScalaTest. + * standard [[AnyFunSpec#it]] from ScalaTest. * * This is needed because ScalaTest shared tests work by taking fixture * parameters as lexical context, but JCA providers are set in the dynamic - * context. The [[FunSpec#it]] call does not immediately run the test, + * context. The [[AnyFunSpec#it]] call does not immediately run the test, * instead it registers a test to be run later. This helper ensures that the * dynamic context matches the lexical context at the time the test runs. */ diff --git a/yubico-util/build.gradle b/yubico-util/build.gradle deleted file mode 100644 index e4249774e..000000000 --- a/yubico-util/build.gradle +++ /dev/null @@ -1,52 +0,0 @@ -plugins { - id 'java-library' - id 'scala' - id 'maven-publish' - id 'signing' - id 'io.github.cosmicsilence.scalafix' -} - -description = 'Yubico internal utilities' - -project.ext.publishMe = true - -sourceCompatibility = 1.8 -targetCompatibility = 1.8 - -dependencies { - api(platform(rootProject)) - - api( - 'com.fasterxml.jackson.core:jackson-databind', - ) - - implementation( - 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor', - 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8', - 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310', - 'com.google.guava:guava', - 'com.upokecenter:cbor', - 'org.slf4j:slf4j-api', - ) - - testImplementation( - project(':yubico-util-scala'), - 'junit:junit', - 'org.scala-lang:scala-library', - 'org.scalacheck:scalacheck_2.13', - 'org.scalatest:scalatest_2.13', - ) -} - - -jar { - manifest { - attributes([ - 'Implementation-Id': 'yubico-util', - 'Implementation-Title': project.description, - 'Implementation-Version': project.version, - 'Implementation-Vendor': 'Yubico', - ]) - } -} - diff --git a/yubico-util/build.gradle.kts b/yubico-util/build.gradle.kts new file mode 100644 index 000000000..83d58d030 --- /dev/null +++ b/yubico-util/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + `java-library` + scala + `maven-publish` + signing + id("info.solidsoft.pitest") + id("io.github.cosmicsilence.scalafix") +} + +description = "Yubico internal utilities" + +val publishMe by extra(true) + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + api(platform(rootProject)) + + api("com.fasterxml.jackson.core:jackson-databind") + + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("com.google.guava:guava") + implementation("com.upokecenter:cbor") + implementation("org.slf4j:slf4j-api") + + testImplementation(platform(project(":test-platform"))) + testImplementation(project(":yubico-util-scala")) + testImplementation("junit:junit") + testImplementation("org.scala-lang:scala-library") + testImplementation("org.scalacheck:scalacheck_2.13") + testImplementation("org.scalatest:scalatest_2.13") + testImplementation("org.scalatestplus:junit-4-13_2.13") + testImplementation("org.scalatestplus:scalacheck-1-16_2.13") +} + + +tasks.jar { + manifest { + attributes(mapOf( + "Implementation-Id" to "yubico-util", + "Implementation-Title" to project.description, + "Implementation-Version" to project.version, + "Implementation-Vendor" to "Yubico", + )) + } +} + +pitest { + pitestVersion.set("1.9.5") + timestampedReports.set(false) + + outputFormats.set(listOf("XML", "HTML")) + + avoidCallsTo.set(listOf( + "java.util.logging", + "org.apache.log4j", + "org.slf4j", + "org.apache.commons.logging", + "com.google.common.io.Closeables", + )) +} diff --git a/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala b/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala index 34070c13d..b834f95b7 100644 --- a/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala +++ b/yubico-util/src/test/scala/com/yubico/internal/util/BinaryUtilSpec.scala @@ -26,14 +26,14 @@ package com.yubico.internal.util import org.junit.runner.RunWith import org.scalacheck.Gen -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @RunWith(classOf[JUnitRunner]) class BinaryUtilSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/yubico-util/src/test/scala/com/yubico/internal/util/CollectionUtilSpec.scala b/yubico-util/src/test/scala/com/yubico/internal/util/CollectionUtilSpec.scala index fcd173b40..520376086 100644 --- a/yubico-util/src/test/scala/com/yubico/internal/util/CollectionUtilSpec.scala +++ b/yubico-util/src/test/scala/com/yubico/internal/util/CollectionUtilSpec.scala @@ -2,14 +2,14 @@ package com.yubico.internal.util import com.yubico.scalacheck.gen.JavaGenerators._ import org.junit.runner.RunWith -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @RunWith(classOf[JUnitRunner]) class CollectionUtilSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks { diff --git a/yubico-util/src/test/scala/com/yubico/internal/util/ComparableUtilSpec.scala b/yubico-util/src/test/scala/com/yubico/internal/util/ComparableUtilSpec.scala index 1592f8bd7..87b561146 100644 --- a/yubico-util/src/test/scala/com/yubico/internal/util/ComparableUtilSpec.scala +++ b/yubico-util/src/test/scala/com/yubico/internal/util/ComparableUtilSpec.scala @@ -4,14 +4,14 @@ import _root_.scala.jdk.CollectionConverters._ import org.junit.runner.RunWith import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen -import org.scalatest.FunSpec -import org.scalatest.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.junit.JUnitRunner import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks @RunWith(classOf[JUnitRunner]) class ComparableUtilSpec - extends FunSpec + extends AnyFunSpec with Matchers with ScalaCheckDrivenPropertyChecks {