diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 031f7e8a5..36029b6e5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,8 @@ updates: # Spotless patch updates are too noisy - dependency-name: "spotless-plugin-gradle" update-types: ["version-update:semver-patch"] + - dependency-name: "com.diffplug.spotless:spotless-plugin-gradle" + update-types: ["version-update:semver-patch"] - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 782041a8a..3026a2648 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,10 +33,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: 17 distribution: ${{ matrix.distribution }} @@ -45,7 +45,7 @@ jobs: run: ./gradlew clean testClasses - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: ${{ matrix.distribution }} @@ -55,7 +55,7 @@ jobs: - name: Archive HTML test report on failure if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-reports-java17-java${{ matrix.java }}-${{ matrix.distribution }}-html path: "*/build/reports/**" @@ -68,14 +68,14 @@ jobs: - name: Archive HTML test report if: ${{ always() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-reports-java${{ matrix.java }}-${{ matrix.distribution }}-html path: "*/build/reports/**" - name: Archive JUnit test report if: ${{ always() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-reports-java${{ matrix.java }}-${{ matrix.distribution }}-xml path: "*/build/test-results/**/*.xml" @@ -100,7 +100,7 @@ jobs: steps: - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: test-reports-java${{ needs.test.outputs.report-java }}-${{ needs.test.outputs.report-dist }}-xml diff --git a/.github/workflows/code-formatting.yml b/.github/workflows/code-formatting.yml index 749569623..5c2e8174b 100644 --- a/.github/workflows/code-formatting.yml +++ b/.github/workflows/code-formatting.yml @@ -21,10 +21,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: ${{ matrix.distribution }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a48696100..328cf31ec 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,16 +21,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: java @@ -39,4 +39,4 @@ jobs: ./gradlew jar - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index fbdaee05c..f7b01c4e3 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -4,8 +4,9 @@ name: Test coverage on: push: branches: - - main - - 'release-*' + - main + - dependabot/gradle/info.solidsoft.gradle.pitest-gradle-pitest-plugin-* + - 'release-*' jobs: test: @@ -18,10 +19,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin @@ -30,7 +31,7 @@ jobs: run: ./gradlew pitestMerge - name: Archive test reports - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pitest-reports-${{ github.sha }} path: "*/build/reports/pitest/**" @@ -46,16 +47,8 @@ jobs: 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 - if: ${{ github.ref == 'refs/heads/main' }} - # 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 + uses: actions/checkout@v4 with: ref: gh-pages clean: false @@ -71,6 +64,14 @@ jobs: prev-commit: ${{ env.PREV_COMMIT }} prev-mutations-file: prev-mutations.xml + - name: Create coverage badge + if: ${{ github.ref == 'refs/heads/main' }} + # 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: Push to GitHub Pages if: ${{ github.ref == 'refs/heads/main' }} run: | diff --git a/.github/workflows/release-verify-signatures.yml b/.github/workflows/release-verify-signatures.yml index e391c06f3..163f320d2 100644 --- a/.github/workflows/release-verify-signatures.yml +++ b/.github/workflows/release-verify-signatures.yml @@ -24,7 +24,7 @@ jobs: 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 + uses: actions/upload-artifact@v4 with: name: keyring-and-signatures retention-days: 1 @@ -44,12 +44,12 @@ jobs: steps: - name: check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} - name: Set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: ${{ matrix.distribution }} @@ -68,7 +68,7 @@ jobs: done - name: Retrieve keyring and signatures - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: keyring-and-signatures @@ -87,7 +87,7 @@ jobs: steps: - name: Retrieve signatures - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: keyring-and-signatures diff --git a/.gitignore b/.gitignore index 896796d4c..2ad24a54e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ out/ *.iws .attach_pid* +# VS Code +.vscode/ + # Mac .DS_Store diff --git a/NEWS b/NEWS index 2f37b91de..22d0fe6eb 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,46 @@ +== Version 2.6.0 == + +`webauthn-server-core`: + +New features: + +* Added method `getParsedPublicKey(): java.security.PublicKey` to + `RegistrationResult` and `RegisteredCredential`. + ** Thanks to Jakob Heher (A-SIT) for the contribution, see + https://github.com/Yubico/java-webauthn-server/pull/299 +* Added enum parsing functions: + ** `AuthenticatorAttachment.fromValue(String): Optional` + ** `PublicKeyCredentialType.fromId(String): Optional` + ** `ResidentKeyRequirement.fromValue(String): Optional` + ** `TokenBindingStatus.fromValue(String): Optional` + ** `UserVerificationRequirement.fromValue(String): Optional` +* Added public builder to `CredentialPropertiesOutput`. +* Added public factory function + `LargeBlobRegistrationOutput.supported(boolean)`. +* Added public factory functions to `LargeBlobAuthenticationOutput`. +* Added `hints` property to `StartRegistrationOptions`, `StartAssertionOptions`, + `PublicKeyCredentialCreationOptions` and `PublicKeyCredentialRequestOptions`, + and class `PublicKeyCredentialHint` to support them, to support the `hints` + parameter introduced in WebAuthn L3: + https://www.w3.org/TR/2023/WD-webauthn-3-20230927/#dom-publickeycredentialcreationoptions-hints +* (Experimental) Added option `isSecurePaymentConfirmation(boolean)` to + `FinishAssertionOptions`. When set, `RelyingParty.finishAssertion()` will + adapt the validation logic for a Secure Payment Confirmation (SPC) response + instead of an ordinary WebAuthn response. See the JavaDoc for details. + ** NOTE: Experimental features may receive breaking changes without a major + version increase. + +`webauthn-server-attestation`: + +New features: + +* `FidoMetadataDownloader` now parses the CRLDistributionPoints extension on the + application level, so the `com.sun.security.enableCRLDP=true` system property + setting is no longer necessary. +* Added helper function `CertificateUtil.parseFidoSernumExtension` for parsing + serial number from enterprise attestation certificates. + + == Version 2.5.4 == `webauthn-server-attestation`: diff --git a/README b/README index 88a5b4109..68b5d4145 100644 --- a/README +++ b/README @@ -3,6 +3,8 @@ java-webauthn-server :toc: :toc-placement: macro :toc-title: +:idprefix: +:idseparator: - 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://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/"] @@ -64,7 +66,7 @@ Maven: com.yubico webauthn-server-core - 2.5.4 + 2.6.0 compile ---------- @@ -72,7 +74,7 @@ Maven: Gradle: ---------- -implementation("com.yubico:webauthn-server-core:2.5.4") +implementation("com.yubico:webauthn-server-core:2.6.0") ---------- NOTE: You may need additional dependencies with JCA providers to support some signature algorithms. @@ -85,7 +87,7 @@ 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/2.5.4/com/yubico/webauthn/package-summary.html[Javadoc], +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/package-summary.html[Javadoc], *with the exception* of features annotated with a `@Deprecated` annotation and a `@deprecated EXPERIMENTAL:` tag in JavaDoc. Such features are considered unstable and may receive breaking changes without a @@ -108,7 +110,7 @@ In addition to the main `webauthn-server-core` module, there is also: == Documentation See the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/package-summary.html[Javadoc] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/package-summary.html[Javadoc] for in-depth API documentation. @@ -118,20 +120,20 @@ Using this library comes in two parts: the server side and the client side. The server side involves: 1. Implement the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] class. 3. Use the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#startRegistration(com.yubico.webauthn.StartRegistrationOptions)[`RelyingParty.startRegistration(...)`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration(...)`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration(...)`] methods to perform registration ceremonies. 4. Use the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#startAssertion(com.yubico.webauthn.StartAssertionOptions)[`RelyingParty.startAssertion(...)`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`] methods to perform authentication ceremonies. 5. Optionally use additional features: passkeys, passwordless multi-factor authentication, credential backup state. @@ -151,7 +153,7 @@ 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/2.5.4/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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/main/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[`InMemoryRegistrationStorage`] @@ -160,11 +162,11 @@ as a simple example. === 2. Instantiate a `RelyingParty` The -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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 -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] implementation (called `MyCredentialRepository` here) as an argument: [source,java] @@ -186,7 +188,7 @@ RelyingParty rp = RelyingParty.builder() A registration ceremony consists of 5 main steps: 1. Generate registration parameters using - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#startRegistration(com.yubico.webauthn.StartRegistrationOptions)[`RelyingParty.startRegistration(...)`]. + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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, @@ -194,7 +196,7 @@ A registration ceremony consists of 5 main steps: 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 - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration(...)`]. + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration(...)`]. 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. @@ -226,15 +228,15 @@ return credentialCreateJson; // Send to client ---------- You will need to keep this -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.html[`PublicKeyCredentialCreationOptions`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration(...)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration(...)`] later. If needed, you can use the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.html#toJson()[`toJson()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.html#toJson()[`toJson()`] and -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.html#fromJson(java.lang.String)[`fromJson(String)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/data/PublicKeyCredentialCreationOptions.html#fromJson(java.lang.String)[`fromJson(String)`] methods to serialize and deserialize the value for storage. Now call the WebAuthn API on the client side: @@ -295,7 +297,7 @@ 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 - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#startAssertion(com.yubico.webauthn.StartAssertionOptions)[`RelyingParty.startAssertion(...)`]. + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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. @@ -303,7 +305,7 @@ Like registration ceremonies, an authentication ceremony consists of 5 main step 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 - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`]. + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`]. 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. @@ -320,15 +322,15 @@ return credentialGetJson; // Send to client ---------- Again, you will need to keep this -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/AssertionRequest.html[`AssertionRequest`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`] later. If needed, you can use the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/AssertionRequest.html#toJson()[`toJson()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/AssertionRequest.html#toJson()[`toJson()`] and -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/AssertionRequest.html#fromJson(java.lang.String)[`fromJson(String)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/AssertionRequest.html#fromJson(java.lang.String)[`fromJson(String)`] methods to serialize and deserialize the value for storage. Now call the WebAuthn API on the client side: @@ -369,7 +371,7 @@ throw new RuntimeException("Authentication failed"); ---------- 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.5.4/com/yubico/webauthn/AssertionResult.html[`AssertionResult`]. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/AssertionResult.html[`AssertionResult`]. Most importantly, you should update the signature counter. That might look something like this: [source,java] @@ -420,9 +422,9 @@ Many passkey-capable authenticators also offer a credential sync mechanism to allow one passkey to be used on multiple devices. Passkeys can be created by setting the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/StartRegistrationOptions.StartRegistrationOptionsBuilder.html#authenticatorSelection(com.yubico.webauthn.data.AuthenticatorSelectionCriteria)[`authenticatorSelection`].link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder.html#residentKey(com.yubico.webauthn.data.ResidentKeyRequirement)[`residentKey`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/StartRegistrationOptions.StartRegistrationOptionsBuilder.html#authenticatorSelection(com.yubico.webauthn.data.AuthenticatorSelectionCriteria)[`authenticatorSelection`].link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder.html#residentKey(com.yubico.webauthn.data.ResidentKeyRequirement)[`residentKey`] option to -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/data/ResidentKeyRequirement.html#REQUIRED[`REQUIRED`]: +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/data/ResidentKeyRequirement.html#REQUIRED[`REQUIRED`]: [source,java] ---------- @@ -444,15 +446,15 @@ AssertionRequest request = rp.startAssertion(StartAssertionOptions.builder().bui Some authenticators might create passkeys even if not required, and setting the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder.html#residentKey(com.yubico.webauthn.data.ResidentKeyRequirement)[`residentKey`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/data/AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder.html#residentKey(com.yubico.webauthn.data.ResidentKeyRequirement)[`residentKey`] option to -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/data/ResidentKeyRequirement.html#PREFERRED[`PREFERRED`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/data/ResidentKeyRequirement.html#PREFERRED[`PREFERRED`] will create a passkey if the authenticator supports it. The -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RegistrationResult.html#isDiscoverable()[`RegistrationResult.isDiscoverable()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RegistrationResult.html#isDiscoverable()[`RegistrationResult.isDiscoverable()`] method can be used to determine whether the created credential is a passkey. This requires the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/data/RegistrationExtensionInputs.RegistrationExtensionInputsBuilder.html#credProps()[`credProps` extension] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/data/RegistrationExtensionInputs.RegistrationExtensionInputsBuilder.html#credProps()[`credProps` extension] to be enabled, which it is by default. @@ -471,7 +473,7 @@ AssertionRequest request = rp.startAssertion(StartAssertionOptions.builder() ---------- Then -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`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 @@ -508,14 +510,14 @@ AssertionRequest request = rp.startAssertion(StartAssertionOptions.builder() ---------- In this case -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration(...)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishRegistration(com.yubico.webauthn.FinishRegistrationOptions)[`RelyingParty.finishRegistration(...)`] and -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#finishAssertion(com.yubico.webauthn.FinishAssertionOptions)[`RelyingParty.finishAssertion(...)`] will NOT enforce user verification, but instead the `isUserVerified()` method of -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`] and -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/AssertionResult.html[`AssertionResult`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/AssertionResult.html[`AssertionResult`] will tell whether user verification was used. For example, you could prompt for a password as the second factor if `isUserVerified()` returns `false`: @@ -555,7 +557,7 @@ In particular you need to: - Add the credential request option `mediation: "conditional"` alongside the `publicKey` option generated by -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html#startAssertion(com.yubico.webauthn.StartAssertionOptions)[`RelyingParty.startAssertion(...)`], +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html#startAssertion(com.yubico.webauthn.StartAssertionOptions)[`RelyingParty.startAssertion(...)`], - Add `autocomplete="username webauthn"` to a username input field on the page, and - Call `navigator.credentials.get()` in the background. @@ -574,9 +576,9 @@ Some authenticators may allow credentials to be backed up and/or synced between This capability and its current state is signaled via the link:https://w3c.github.io/webauthn/#sctn-credential-backup[Credential Backup State] flags, which are available via the `isBackedUp()` and `isBackupEligible()` methods of -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`] and -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/AssertionResult.html[`AssertionResult`]. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/AssertionResult.html[`AssertionResult`]. These can be used as a hint about how vulnerable a user is to authenticator loss. In particular, a user with only one credential which is not backed up may risk getting locked out if they lose their authenticator. @@ -600,14 +602,14 @@ 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/2.5.4/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.6.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/2.5.4/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#appId(com.yubico.webauthn.extension.appid.AppId)[`appId()`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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]. @@ -625,22 +627,22 @@ extensions and configure the `RelyingParty` to accept the given AppId when verif privacy consideration. 4. When your - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] creates a - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RegisteredCredential.html[`RegisteredCredential`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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/2.5.4/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.6.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/2.5.4/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.6.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/2.5.4/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.6.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/2.5.4/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.5.4/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.6.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.6.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 @@ -774,17 +776,17 @@ 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.5.4/com/yubico/webauthn/attestation/AttestationTrustSource.html[`AttestationTrustSource`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/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.6.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.5.4/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/com/yubico/webauthn/attestation/AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder.html#enableRevocationChecking(boolean)[`enableRevocationChecking`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.4/com/yubico/webauthn/attestation/AttestationTrustSource.TrustRootsResult.TrustRootsResultBuilder.html#policyTreeValidator(java.util.function.Predicate)[`policyTreeValidator`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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. @@ -849,3 +851,9 @@ built artifacts. Official Yubico software signing keys are listed on the https://developers.yubico.com/Software_Projects/Software_Signing.html[Yubico Developers site]. + + +[#development] +=== Development + +See the link:https://github.com/Yubico/java-webauthn-server/blob/main/doc/development.md[developer docs]. diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 2efaa001c..c758d36c0 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // Spotless dropped Java 8 support in version 2.33.0 if (JavaVersion.current().isJava11Compatible) { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.25.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:7.0.1") implementation("io.github.cosmicsilence:gradle-scalafix:0.2.2") } } diff --git a/doc/Migrating_from_v1.adoc b/doc/Migrating_from_v1.adoc index b8f04803b..a33051903 100644 --- a/doc/Migrating_from_v1.adoc +++ b/doc/Migrating_from_v1.adoc @@ -1,4 +1,6 @@ = v1.x to v2.0 migration guide +:idprefix: +:idseparator: - The `2.0` release of the `webauthn-server-core` module removes some deprecated features @@ -11,7 +13,7 @@ link:https://github.com/Yubico/java-webauthn-server/issues/new[let us know!] This is the migration guide for the core library. The `webauthn-server-attestation` module has -link:../webauthn-server-attestation/doc/Migrating_from_v1.adoc[its own migration guide]. +link:https://developers.yubico.com/java-webauthn-server/webauthn-server-attestation/doc/Migrating_from_v1.html[its own migration guide]. Here is a high-level outline of what needs to be updated: diff --git a/doc/development.md b/doc/development.md index 0acb00b5d..5be1218bf 100644 --- a/doc/development.md +++ b/doc/development.md @@ -2,11 +2,51 @@ Developer docs === +JDK versions +--- + +The project's official build JDK version is the latest LTS JDK version, +although the project may lag behind the true latest release for a while +until we can upgrade the build definition to match this target. + +The official build JDK version currently in effect is encoded in the +["Reproducible binary"](https://github.com/Yubico/java-webauthn-server/blob/main/.github/workflows/release-verify-signatures.yml) +workflow, +as the JDK version is crucial for successfully reproducing released binaries. +This version is also enforced in the release process in +[`build.gradle`](https://github.com/Yubico/java-webauthn-server/blob/main/build.gradle). + +The [primary build workflow](https://github.com/Yubico/java-webauthn-server/blob/main/.github/workflows/build.yml) +should run on all currently maintaned LTS JDK versions, +and ideally also the latest non-LTS JDK version if Gradle and other build dependencies are compatible. + +A list of JDK versions and maintenance status can be found [here](https://en.wikipedia.org/wiki/Java_version_history). + + +Code formatting +--- + +Use `./gradlew spotlessApply` to run the automatic code formatter. +You can also run it in continuous mode as `./gradlew --continuous spotlessApply` +to reformat whenever a file changes. + +We mean to follow the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html), +but do not enforce it comprehensively (apart from what the automatic formatter does). +Take particular note of the rules: + +- [3.3.1 No wildcard imports](https://google.github.io/styleguide/javaguide.html#s3.3.1-wildcard-imports) +- [5.3 Camel case: defined](https://google.github.io/styleguide/javaguide.html#s5.3-camel-case) + (`XmlHttpRequest` and `requestId`, not `XMLHTTPRequest` and `requestID`) + +In case of disagreement on code style, defer to the style guide. + + Setup for publishing --- -To enable publishing to Maven Central via Sonatype Nexus, set -`yubicoPublish=true` in `$HOME/.gradle/gradle.properties` and add your Sonatype +To enable publishing to Maven Central via Sonatype Nexus, +[generate a user token](https://central.sonatype.org/publish/generate-token/). +Set `yubicoPublish=true` in `$HOME/.gradle/gradle.properties` and add your token username and password. Example: ```properties @@ -16,9 +56,7 @@ ossrhPassword=bmjuyWSIik8P3Nq/ZM2G0Xs0sHEKBg+4q4zTZ8JDDRCr ``` -Code formatting +Publishing a release --- -Use `./gradlew spotlessApply` to run the automatic code formatter. -You can also run it in continuous mode as `./gradlew --continuous spotlessApply` -to reformat whenever a file changes. +See the [release checklist](./releasing.md). diff --git a/doc/releasing.md b/doc/releasing.md index 3c2a46c88..98ec783e7 100644 --- a/doc/releasing.md +++ b/doc/releasing.md @@ -41,7 +41,8 @@ Release candidate versions java: ["17.0.7"] ``` - Commit this change, if any. + Check that this version is available in GitHub Actions. Commit this change, + if any. 5. Tag the head commit with an `X.Y.Z-RCN` tag: @@ -152,6 +153,8 @@ Release versions java: ["17.0.7"] ``` + Check that this version is available in GitHub Actions. + 8. Amend these changes into the merge commit: ``` diff --git a/webauthn-server-attestation/README.adoc b/webauthn-server-attestation/README.adoc index d591e3cec..a13cb159f 100644 --- a/webauthn-server-attestation/README.adoc +++ b/webauthn-server-attestation/README.adoc @@ -2,11 +2,14 @@ :toc: :toc-placement: macro :toc-title: +:idprefix: +:idseparator: - An optional module which extends link:../[`webauthn-server-core`] with a trust root source for verifying https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-attestation[attestation statements], by interfacing with the https://fidoalliance.org/metadata/[FIDO Metadata Service]. +The module also provides helper functions for inspecting properties of attestation certificates. *Table of contents* @@ -15,13 +18,13 @@ toc::[] == Features -This module does four things: +The FIDO MDS integration does four things: - Download, verify and cache metadata BLOBs from the FIDO Metadata Service. - 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/2.5.1/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] class in the base library, to provide trust root certificates for verifying attestation statements during credential registrations. @@ -30,18 +33,18 @@ Notable *non-features* include: - *Scheduled BLOB downloads.* + The -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] or -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html#refreshBlob()[`refreshBlob()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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()` @@ -54,12 +57,12 @@ classes keep no internal mutable state. + The FIDO Metadata Service may from time to time report security issues with particular authenticator models. The -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.1/com/yubico/webauthn/RelyingParty.html[`RelyingParty`], +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`], but this will not affect any credentials already registered. @@ -94,7 +97,7 @@ Maven: com.yubico webauthn-server-attestation - 2.5.1 + 2.6.0 compile ---------- @@ -102,7 +105,7 @@ Maven: Gradle: ---------- -implementation("com.yubico:webauthn-server-attestation:2.5.1") +implementation("com.yubico:webauthn-server-attestation:2.6.0") ---------- @@ -111,7 +114,7 @@ implementation("com.yubico:webauthn-server-attestation:2.5.1") 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.5.1/com/yubico/fido/metadata/package-summary.html[Javadoc]. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/package-summary.html[Javadoc]. 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. @@ -123,23 +126,23 @@ Breaking changes to these will NOT be reflected in version numbers. Using this module consists of 5 major steps: 1. Create a - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] instance to download and cache metadata BLOBs, and a - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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, -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] and -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html#refreshBlob()[`refreshBlob()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`], +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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 @@ -164,18 +167,18 @@ FidoMetadataService mds = FidoMetadataService.builder() ---------- 2. Set the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] as the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/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.6.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.5.1/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] instance, and set - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#attestationConveyancePreference(com.yubico.webauthn.data.AttestationConveyancePreference)[`attestationConveyancePreference(AttestationConveyancePreference.DIRECT)`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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 - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`] on `RelyingParty` to require trusted attestation for new registrations. + [source,java] @@ -190,9 +193,9 @@ RelyingParty rp = RelyingParty.builder() ---------- 3. After performing registrations, inspect the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/com/yubico/webauthn/RegistrationResult.html#isAttestationTrusted()[`isAttestationTrusted()`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RegistrationResult.html#isAttestationTrusted()[`isAttestationTrusted()`] result in - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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. + @@ -209,7 +212,7 @@ if (result.isAttestationTrusted()) { ---------- 4. If needed, use the `findEntries` methods of - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] to retrieve additional authenticator metadata for new registrations. + [source,java] @@ -220,34 +223,23 @@ RegistrationResult result = rp.finishRegistration(/* ... */); Set metadata = mds.findEntries(result); ---------- - 5. If you use the SUN provider for the `PKIX` certificate path validation algorithm, which many deployments do by default: - set the `com.sun.security.enableCRLDP` system property to `true`. - This is required for the SUN `PKIX` provider to support the CRL Distribution Points extension, - which is needed 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#GUID-EB250086-0AC1-4D60-AE2A-FC7461374746__SECTION-139-623E860E[Java PKI Programmers Guide] -for details. -+ -This step may not be necessary if you use a different provider for the `PKIX` certificate path validation algorithm. - == Selecting trusted authenticators The -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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 -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/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.6.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.5.1/com/yubico/webauthn/RelyingParty.html[`RelyingParty`], +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.1/com/yubico/webauthn/RegistrationResult.html#isAttestationTrusted()[`.isAttestationTrusted()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RegistrationResult.html#isAttestationTrusted()[`.isAttestationTrusted()`] result in -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/com/yubico/webauthn/RegistrationResult.html[`RegistrationResult`]. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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.5.1/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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, @@ -310,17 +302,17 @@ 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 -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.FidoMetadataServiceBuilder.html#prefilter(java.util.function.Predicate)[`.prefilter(Predicate)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/fido/metadata/FidoMetadataService.FidoMetadataServiceBuilder.html#filter(java.util.function.Predicate)[`.filter(Predicate)`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`]. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`]. The filters are predicate functions; each metadata entry will be included in the data source 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 -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.Filters.html#allOf(java.util.function.Predicate\...)[`FidoMetadataService.Filters.allOf()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataService.Filters.html#allOf(java.util.function.Predicate\...)[`FidoMetadataService.Filters.allOf()`] combinator to merge several predicates into one. [NOTE] @@ -330,10 +322,10 @@ 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 -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.Filters.html#allOf(java.util.function.Predicate\...)[`FidoMetadataService.Filters.allOf()`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/fido/metadata/FidoMetadataService.Filters.html[`FidoMetadataService.Filters`]. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataService.Filters.html[`FidoMetadataService.Filters`]. ===== @@ -354,9 +346,9 @@ 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 -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] with -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.1/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`]. +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.RelyingPartyBuilder.html#allowUntrustedAttestation(boolean)[`.allowUntrustedAttestation(false)`]. == Alignment with FIDO MDS spec @@ -366,17 +358,17 @@ link:https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.h 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 - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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 the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html#loadCachedBlob()[`loadCachedBlob()`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html#refreshBlob()[`refreshBlob()`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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. + @@ -388,7 +380,7 @@ 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 - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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. @@ -400,7 +392,7 @@ There are also some other requirements throughout the spec, which may not be obv states that "The Relying party MUST reject the Metadata Statement if the `authenticatorVersion` has not increased" in an `UPDATE_AVAILABLE` status report. Thus, - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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. @@ -410,16 +402,41 @@ 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 "FIDO Servers MUST silently ignore all unknown AuthenticatorStatus values". Thus any unknown status values will be parsed as - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/AuthenticatorStatus.html#UNKNOWN[`AuthenticatorStatus.UNKNOWN`], + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/AuthenticatorStatus.html#UNKNOWN[`AuthenticatorStatus.UNKNOWN`], and - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/MetadataBLOBPayloadEntry.html[`MetadataBLOBPayloadEntry`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/MetadataBLOBPayloadEntry.html[`MetadataBLOBPayloadEntry`] will silently ignore any status report with that status. == Overriding certificate path validation The -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.1/com/yubico/fido/metadata/FidoMetadataDownloader.html[`FidoMetadataDownloader`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.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. + + +== Using enterprise attestation + +link:https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dom-attestationconveyancepreference-enterprise[Enterprise attestation] +is the idea of having attestation statements contain a unique identifier such as a device serial number. +For example, this identifier could be used by an employer provisioning security keys for their employees. +By recording which employee has which security key serial numbers, +the employer can automatically trust the employee upon successful WebAuthn registration +without having to first authenticate the employee by other means. + +Because enterprise attestation by design introduces powerful user tracking, +it is only allowed in certain contexts and is otherwise blocked by the client. +See the +link:https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-feature-descriptions-enterp-attstn[CTAP2 section on Enterprise Attestation] +for guidance on how to enable enterprise attestation - +this typically involves a special agreement with an authenticator or client vendor. + +At time of writing, there is only one standardized way to convey an enterprise attestation identifer: + +- An X.509 certificate extension with OID `1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum)` + MAY indicate a unique octet string such as a serial number + see + https://w3c.github.io/webauthn/#sctn-enterprise-packed-attestation-cert-requirements[Web Authentication Level 3 §8.2.2. Certificate Requirements for Enterprise Packed Attestation Statements]. + The `CertificateUtil` class provides `parseFidoSernumExtension` helper function for parsing this extension if present. diff --git a/webauthn-server-attestation/build.gradle.kts b/webauthn-server-attestation/build.gradle.kts index 1748835d2..590e42ad7 100644 --- a/webauthn-server-attestation/build.gradle.kts +++ b/webauthn-server-attestation/build.gradle.kts @@ -48,7 +48,12 @@ dependencies { testImplementation("org.scalatestplus:junit-4-13_2.13") testImplementation("org.scalatestplus:scalacheck-1-16_2.13") - testImplementation("org.slf4j:slf4j-api") + testImplementation("org.slf4j:slf4j-api") { + version { + strictly("[1.7.25,1.8-a)") // Pre-1.8 version required by slf4j-test + } + } + testRuntimeOnly("uk.org.lidalia:slf4j-test") } val integrationTest = task("integrationTest") { @@ -58,9 +63,6 @@ val integrationTest = task("integrationTest") { 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) diff --git a/webauthn-server-attestation/doc/Migrating_from_v1.adoc b/webauthn-server-attestation/doc/Migrating_from_v1.adoc index add3d7e9c..850f4a6e1 100644 --- a/webauthn-server-attestation/doc/Migrating_from_v1.adoc +++ b/webauthn-server-attestation/doc/Migrating_from_v1.adoc @@ -1,4 +1,6 @@ = v1.x to v2.1 migration guide +:idprefix: +:idseparator: - The `2.0` release of the `webauthn-server-attestation` module makes lots of breaking changes compared to the `1.x` versions. 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 937a0db8c..a2d01fc09 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 @@ -1,5 +1,7 @@ package com.yubico.fido.metadata +import com.yubico.internal.util.CertificateParser +import com.yubico.webauthn.data.ByteArray import org.junit.runner.RunWith import org.scalatest.BeforeAndAfter import org.scalatest.funspec.AnyFunSpec @@ -8,7 +10,8 @@ import org.scalatest.tags.Network import org.scalatest.tags.Slow import org.scalatestplus.junit.JUnitRunner -import java.util.Optional +import scala.jdk.CollectionConverters.ListHasAsScala +import scala.jdk.OptionConverters.RichOption import scala.util.Success import scala.util.Try @@ -21,6 +24,9 @@ class FidoMetadataDownloaderIntegrationTest with BeforeAndAfter { describe("FidoMetadataDownloader with default settings") { + // Cache downloaded items to avoid cause unnecessary load on remote servers + var trustRootCache: Option[ByteArray] = None + var blobCache: Option[ByteArray] = None val downloader = FidoMetadataDownloader .builder() @@ -28,17 +34,46 @@ class FidoMetadataDownloaderIntegrationTest "Retrieval and use of this BLOB indicates acceptance of the appropriate agreement located at https://fidoalliance.org/metadata/metadata-legal-terms/" ) .useDefaultTrustRoot() - .useTrustRootCache(() => Optional.empty(), _ => {}) + .useTrustRootCache( + () => trustRootCache.toJava, + trustRoot => { trustRootCache = Some(trustRoot) }, + ) .useDefaultBlob() - .useBlobCache(() => Optional.empty(), _ => {}) + .useBlobCache( + () => blobCache.toJava, + blob => { blobCache = Some(blob) }, + ) .build() it("downloads and verifies the root cert and BLOB successfully.") { - // This test requires the system property com.sun.security.enableCRLDP=true val blob = Try(downloader.loadCachedBlob) blob shouldBe a[Success[_]] blob.get should not be null } + + it( + "does not encounter any CRLDistributionPoints entries in unknown format." + ) { + val blob = Try(downloader.loadCachedBlob) + blob shouldBe a[Success[_]] + val trustRootCert = + CertificateParser.parseDer(trustRootCache.get.getBytes) + val certChain = downloader + .fetchHeaderCertChain( + trustRootCert, + FidoMetadataDownloader.parseBlob(blobCache.get).getBlob.getHeader, + ) + .asScala :+ trustRootCert + for { cert <- certChain } { + withClue( + s"Unknown CRLDistributionPoints structure in cert [${cert.getSubjectX500Principal}] : ${new ByteArray(cert.getEncoded)}" + ) { + CertificateParser + .parseCrlDistributionPointsExtension(cert) + .isAnyDistributionPointUnsupported should be(false) + } + } + } } } diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AAGUID.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AAGUID.java index e5adeff30..e11dac599 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AAGUID.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/AAGUID.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import com.yubico.internal.util.BinaryUtil; import com.yubico.internal.util.ExceptionUtil; import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.exception.HexException; import java.util.regex.Matcher; import java.util.regex.Pattern; import lombok.AccessLevel; @@ -105,12 +105,14 @@ private static ByteArray parse(String value) { Matcher matcher = AAGUID_PATTERN.matcher(value); if (matcher.find()) { try { - return ByteArray.fromHex(matcher.group(1)) - .concat(ByteArray.fromHex(matcher.group(2))) - .concat(ByteArray.fromHex(matcher.group(3))) - .concat(ByteArray.fromHex(matcher.group(4))) - .concat(ByteArray.fromHex(matcher.group(5))); - } catch (HexException e) { + return new ByteArray( + BinaryUtil.concat( + BinaryUtil.fromHex(matcher.group(1)), + BinaryUtil.fromHex(matcher.group(2)), + BinaryUtil.fromHex(matcher.group(3)), + BinaryUtil.fromHex(matcher.group(4)), + BinaryUtil.fromHex(matcher.group(5)))); + } catch (Exception 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/FidoMetadataDownloader.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java index cf3dfd5cd..4ac9e6641 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 @@ -29,6 +29,7 @@ import com.yubico.fido.metadata.FidoMetadataDownloaderException.Reason; import com.yubico.internal.util.BinaryUtil; import com.yubico.internal.util.CertificateParser; +import com.yubico.internal.util.OptionalUtil; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.exception.Base64UrlException; import com.yubico.webauthn.data.exception.HexException; @@ -54,6 +55,7 @@ import java.security.Signature; import java.security.SignatureException; import java.security.cert.CRL; +import java.security.cert.CRLException; import java.security.cert.CertPath; import java.security.cert.CertPathValidator; import java.security.cert.CertPathValidatorException; @@ -543,9 +545,9 @@ public FidoMetadataDownloaderBuilder clock(@NonNull Clock clock) { /** * Use the provided CRLs. * - *

CRLs will also be downloaded from distribution points if the - * com.sun.security.enableCRLDP system property is set to true (assuming the - * use of the {@link CertPathValidator} implementation from the SUN provider). + *

CRLs will also be downloaded from distribution points for any certificates with a + * CRLDistributionPoints extension, if the extension can be successfully interpreted. A warning + * message will be logged CRLDistributionPoints parsing fails. * * @throws InvalidAlgorithmParameterException if {@link CertStore#getInstance(String, * CertStoreParameters)} does. @@ -561,9 +563,9 @@ public FidoMetadataDownloaderBuilder useCrls(@NonNull Collection crls) /** * Use CRLs in the provided {@link CertStore}. * - *

CRLs will also be downloaded from distribution points if the - * com.sun.security.enableCRLDP system property is set to true (assuming the - * use of the {@link CertPathValidator} implementation from the SUN provider). + *

CRLs will also be downloaded from distribution points for any certificates with a + * CRLDistributionPoints extension, if the extension can be successfully interpreted. A warning + * message will be logged CRLDistributionPoints parsing fails. * * @see #useCrls(Collection) */ @@ -691,7 +693,7 @@ public FidoMetadataDownloaderBuilder verifyDownloadsOnly(final boolean verifyDow * @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. + * or the "Collection" type {@link CertStore} 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 @@ -794,7 +796,7 @@ public MetadataBLOB loadCachedBlob() * @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. + * or the "Collection" type {@link CertStore} 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 @@ -966,7 +968,8 @@ private X509Certificate retrieveTrustRootCert() * @throws IOException on failure to parse the BLOB contents. * @throws InvalidAlgorithmParameterException if certificate path validation fails. * @throws InvalidKeyException if signature verification fails. - * @throws NoSuchAlgorithmException if signature verification fails. + * @throws NoSuchAlgorithmException if signature verification fails, or if the SHA-256 algorithm + * or the "Collection" type {@link CertStore} is not available. * @throws SignatureException if signature verification fails. * @throws FidoMetadataDownloaderException if the explicitly configured BLOB (if any) has a bad * signature. @@ -1097,34 +1100,7 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo InvalidAlgorithmParameterException, FidoMetadataDownloaderException { final MetadataBLOBHeader header = parseResult.blob.getHeader(); - - final List certChain; - if (header.getX5u().isPresent()) { - final URL x5u = header.getX5u().get(); - if (blobUrl != null - && (!(x5u.getHost().equals(blobUrl.getHost()) - && x5u.getProtocol().equals(blobUrl.getProtocol()) - && x5u.getPort() == blobUrl.getPort()))) { - throw new IllegalArgumentException( - String.format( - "x5u in BLOB header must have same origin as the URL the BLOB was downloaded from. Expected origin of: %s ; found: %s", - blobUrl, x5u)); - } - List certs = new ArrayList<>(); - for (String pem : - new String(download(x5u).getBytes(), StandardCharsets.UTF_8) - .trim() - .split("\\n+-----END CERTIFICATE-----\\n+-----BEGIN CERTIFICATE-----\\n+")) { - X509Certificate x509Certificate = CertificateParser.parsePem(pem); - certs.add(x509Certificate); - } - certChain = certs; - } else if (header.getX5c().isPresent()) { - certChain = header.getX5c().get(); - } else { - certChain = Collections.singletonList(trustRootCertificate); - } - + final List certChain = fetchHeaderCertChain(trustRootCertificate, header); final X509Certificate leafCert = certChain.get(0); final Signature signature; @@ -1158,13 +1134,18 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo if (certStore != null) { pathParams.addCertStore(certStore); } + + // Parse CRLDistributionPoints ourselves so users don't have to set the + // `com.sun.security.enableCRLDP=true` system property + fetchCrlDistributionPoints(certChain, certFactory).ifPresent(pathParams::addCertStore); + pathParams.setDate(Date.from(clock.instant())); cpv.validate(blobCertPath, pathParams); return parseResult.blob; } - private static ParseResult parseBlob(ByteArray jwt) throws IOException, Base64UrlException { + static ParseResult parseBlob(ByteArray jwt) throws IOException, Base64UrlException { Scanner s = new Scanner(new ByteArrayInputStream(jwt.getBytes())).useDelimiter("\\."); final ByteArray jwtHeader = ByteArray.fromBase64Url(s.next()); final ByteArray jwtPayload = ByteArray.fromBase64Url(s.next()); @@ -1203,10 +1184,105 @@ private static ByteArray verifyHash(ByteArray contents, Set acceptedC } @Value - private static class ParseResult { + static class ParseResult { private MetadataBLOB blob; private ByteArray jwtHeader; private ByteArray jwtPayload; private ByteArray jwtSignature; } + + /** Parse the header cert chain and download any certificates as necessary. */ + List fetchHeaderCertChain( + X509Certificate trustRootCertificate, MetadataBLOBHeader header) + throws IOException, CertificateException { + if (header.getX5u().isPresent()) { + final URL x5u = header.getX5u().get(); + if (blobUrl != null + && (!(x5u.getHost().equals(blobUrl.getHost()) + && x5u.getProtocol().equals(blobUrl.getProtocol()) + && x5u.getPort() == blobUrl.getPort()))) { + throw new IllegalArgumentException( + String.format( + "x5u in BLOB header must have same origin as the URL the BLOB was downloaded from. Expected origin of: %s ; found: %s", + blobUrl, x5u)); + } + List certs = new ArrayList<>(); + for (String pem : + new String(download(x5u).getBytes(), StandardCharsets.UTF_8) + .trim() + .split("\\n+-----END CERTIFICATE-----\\n+-----BEGIN CERTIFICATE-----\\n+")) { + X509Certificate x509Certificate = CertificateParser.parsePem(pem); + certs.add(x509Certificate); + } + return certs; + } else if (header.getX5c().isPresent()) { + return header.getX5c().get(); + } else { + return Collections.singletonList(trustRootCertificate); + } + } + + /** + * Parse the CRLDistributionPoints extension of each certificate, fetch each distribution point + * and assemble them into a {@link CertStore} ready to be injected into {@link + * PKIXParameters#addCertStore(CertStore)} to provide CRLs for the verification procedure. + * + *

We do this ourselves so that users don't have to set the + * com.sun.security.enableCRLDP=true system property. This is required by the default SUN + * provider in order to enable CRLDistributionPoints resolution. + * + *

Any CRLDistributionPoints entries in unknown format are ignored and log a warning. + */ + private Optional fetchCrlDistributionPoints( + List certChain, CertificateFactory certFactory) + throws InvalidAlgorithmParameterException, NoSuchAlgorithmException { + final List crlDistributionPointUrls = + certChain.stream() + .flatMap( + cert -> { + log.debug( + "Attempting to parse CRLDistributionPoints extension of cert: {}", + cert.getSubjectX500Principal()); + try { + return CertificateParser.parseCrlDistributionPointsExtension(cert) + .getDistributionPoints() + .stream(); + } catch (Exception e) { + log.warn( + "Failed to parse CRLDistributionPoints extension of cert: {}", + cert.getSubjectX500Principal(), + e); + return Stream.empty(); + } + }) + .collect(Collectors.toList()); + + if (crlDistributionPointUrls.isEmpty()) { + return Optional.empty(); + + } else { + final List crldpCrls = + crlDistributionPointUrls.stream() + .map( + crldpUrl -> { + log.debug("Attempting to download CRL distribution point: {}", crldpUrl); + try { + return Optional.of( + certFactory.generateCRL( + new ByteArrayInputStream(download(crldpUrl).getBytes()))); + } catch (CRLException e) { + log.warn("Failed to import CRL from distribution point: {}", crldpUrl, e); + return Optional.empty(); + } catch (Exception e) { + log.warn("Failed to download CRL distribution point: {}", crldpUrl, e); + return Optional.empty(); + } + }) + .flatMap(OptionalUtil::stream) + .collect(Collectors.toList()); + + return Optional.of( + CertStore.getInstance("Collection", new CollectionCertStoreParameters(crldpCrls))); + } + } } diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java new file mode 100644 index 000000000..a91216f53 --- /dev/null +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -0,0 +1,87 @@ +// Copyright (c) 2024, 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.attestation; + +import com.yubico.internal.util.BinaryUtil; +import com.yubico.webauthn.RegistrationResult; +import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.data.ByteArray; +import java.nio.ByteBuffer; +import java.security.cert.X509Certificate; +import java.util.Optional; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class CertificateUtil { + public static final String ID_FIDO_GEN_CE_SERNUM = "1.3.6.1.4.1.45724.1.1.2"; + + private static byte[] parseSerNum(byte[] bytes) { + try { + byte[] extensionValueContents = BinaryUtil.parseDerOctetString(bytes, 0).result; + byte[] sernumContents = BinaryUtil.parseDerOctetString(extensionValueContents, 0).result; + return sernumContents; + } catch (Exception e) { + throw new IllegalArgumentException( + "X.509 extension 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum) is not valid.", e); + } + } + + /** + * Attempt to parse the FIDO enterprise attestation serial number extension from the given + * certificate. + * + *

NOTE: This function does NOT verify that the returned serial number is authentic and + * trustworthy. See: + * + *

    + *
  • {@link RelyingParty.RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource)} + *
  • {@link RegistrationResult#isAttestationTrusted()} + *
  • {@link RelyingParty.RelyingPartyBuilder#allowUntrustedAttestation(boolean)} + *
+ * + *

Note that the serial number is an opaque byte array with no defined structure in general. + * For example, the byte array may or may not represent a big-endian integer depending on the + * authenticator vendor. + * + *

The extension has OID 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum). + * + * @param cert the attestation certificate to parse the serial number from. + * @return The serial number, if present and validly encoded. Empty if the extension is not + * present in the certificate. + * @throws IllegalArgumentException if the extension is present but not validly encoded. + * @see RelyingParty.RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource) + * @see RegistrationResult#isAttestationTrusted() + * @see RelyingParty.RelyingPartyBuilder#allowUntrustedAttestation(boolean) + * @see WebAuthn + * Level 3 §8.2.2. Certificate Requirements for Enterprise Packed Attestation Statements + * @see ByteBuffer#getLong() + */ + public static Optional parseFidoSernumExtension(X509Certificate cert) { + return Optional.ofNullable(cert.getExtensionValue(ID_FIDO_GEN_CE_SERNUM)) + .map(CertificateUtil::parseSerNum) + .map(ByteArray::new); + } +} diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala new file mode 100644 index 000000000..099d5bc83 --- /dev/null +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -0,0 +1,119 @@ +// Copyright (c) 2024, 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.attestation + +import com.yubico.internal.util.BinaryUtil +import com.yubico.internal.util.CertificateParser +import com.yubico.webauthn.TestAuthenticator +import com.yubico.webauthn.data.ByteArray +import com.yubico.webauthn.data.Generators.arbitraryByteArray +import com.yubico.webauthn.data.Generators.shrinkByteArray +import org.bouncycastle.asn1.DEROctetString +import org.junit.runner.RunWith +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.junit.JUnitRunner +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +import java.security.cert.X509Certificate +import scala.jdk.OptionConverters.RichOptional + +@RunWith(classOf[JUnitRunner]) +class CertificateUtilSpec + extends AnyFunSpec + with Matchers + with ScalaCheckDrivenPropertyChecks { + describe("parseFidoSerNumExtension") { + val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2" + + it("correctly parses the id-fido-gen-ce-sernum extension.") { + forAll( + // 500-byte long serial numbers are not realistic, but would be valid DER data. + sizeRange(500) + ) { + // Using Array[Byte] here causes an (almost) infinite loop in the shrinker in case of failure. + // See: https://github.com/typelevel/scalacheck/issues/968#issuecomment-2594018791 + sernum: ByteArray => + val (cert, _): (X509Certificate, _) = TestAuthenticator + .generateAttestationCertificate( + extensions = List( + ( + idFidoGenCeSernum, + false, + new DEROctetString(sernum.getBytes), + ) + ) + ) + + val result = + CertificateUtil + .parseFidoSernumExtension(cert) + .toScala + result should equal(Some(sernum)) + } + } + + it("returns empty when cert has no id-fido-gen-ce-sernum extension.") { + val (cert, _): (X509Certificate, _) = + TestAuthenticator.generateAttestationCertificate(extensions = Nil) + val result = + CertificateUtil + .parseFidoSernumExtension(cert) + .toScala + result should be(None) + } + + it("correctly parses the serial number from a real YubiKey enterprise attestation certificate.") { + val cert = CertificateParser.parsePem("""-----BEGIN CERTIFICATE----- + |MIIC8zCCAdugAwIBAgIJAKr/KiUzkKrgMA0GCSqGSIb3DQEBCwUAMC8xLTArBgNV + |BAMMJFl1YmljbyBGSURPIFJvb3QgQ0EgU2VyaWFsIDQ1MDIwMzU1NjAgFw0yNDA1 + |MDEwMDAwMDBaGA8yMDYwMDQzMDAwMDAwMFowcDELMAkGA1UEBhMCU0UxEjAQBgNV + |BAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlv + |bjEpMCcGA1UEAwwgWXViaWNvIEZpZG8gRUUgKFNlcmlhbD0yODI5OTAwMykwWTAT + |BgcqhkjOPQIBBggqhkjOPQMBBwNCAATImNkI1cwqkW5B3qNrY3pc8zBLhvGyfyfS + |WCLrODSe8xaRPcZoXYGGwZ0Ua/Hp5nxyD+w1hjS9O9gx8mSDvp+zo4GZMIGWMBMG + |CisGAQQBgsQKDQEEBQQDBQcBMBUGCysGAQQBguUcAQECBAYEBAGvzvswIgYJKwYB + |BAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjcwEwYLKwYBBAGC5RwCAQEEBAMC + |AiQwIQYLKwYBBAGC5RwBAQQEEgQQuQ59wTFuT+6iWlamZqZw/jAMBgNVHRMBAf8E + |AjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAFEMXw1HUDC/TfMFxp2ZrmgQLa5fmzs2Jh + |C22TUAuY26CYT5dmMUsS5aJd96MtC5gKS57h1auGr2Y4FMxQS9FJHzXAzAtYJfKh + |j1uS2BSTXf9GULdFKcWvvv50kJ2VmXLge3UgHDBJ8LwrDlZFyISeMZ8jSbmrNu2c + |8uNBBSfqdor+5H91L1brC9yYneHdxYk6YiEvDBxWjiMa9DQuySh/4a21nasgt0cB + |prEbfFOLRDm7GDsRTPyefZjZ84yi4Ao+15x+7DM0UwudEVtjOWB2BJtJyxIkXXNF + |iWFZaxezq0Xt2Kl2sYnMR97ynw/U4TzZDjgb56pN81oKz8Od9B/u + |-----END CERTIFICATE-----""".stripMargin) + + val result = + CertificateUtil + .parseFidoSernumExtension(cert) + .toScala + + result should equal(Some(ByteArray.fromHex("01AFCEFB"))) + + // For YubiKeys, the sernum octet string represents a big-endian integer + BinaryUtil.getUint32(result.get.getBytes) should be(28299003) + } + } +} diff --git a/webauthn-server-core/README b/webauthn-server-core/README index 4da98cd32..6af518e66 100644 --- a/webauthn-server-core/README +++ b/webauthn-server-core/README @@ -1,4 +1,6 @@ = Web Authentication server library +:idprefix: +:idseparator: - Implementation of a Web Authentication Relying Party (RP). diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java index 990ba08c2..5bc316747 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/CredentialRepository.java @@ -30,7 +30,7 @@ import java.util.Set; /** - * An abstraction of the database lookups needed by this library. + * An abstraction of database lookups needed by this library. * *

This is used by {@link RelyingParty} to look up credentials, usernames and user handles from * usernames, user handles and credential IDs. @@ -42,6 +42,8 @@ public interface CredentialRepository { * *

After a successful registration ceremony, the {@link RegistrationResult#getKeyId()} method * returns a value suitable for inclusion in this set. + * + *

Implementations of this method MUST NOT return null. */ Set getCredentialIdsForUsername(String username); @@ -51,6 +53,8 @@ public interface CredentialRepository { * *

Used to look up the user handle based on the username, for authentication ceremonies where * the username is already given. + * + *

Implementations of this method MUST NOT return null. */ Optional getUserHandleForUsername(String username); @@ -60,6 +64,8 @@ public interface CredentialRepository { * *

Used to look up the username based on the user handle, for username-less authentication * ceremonies. + * + *

Implementations of this method MUST NOT return null. */ Optional getUsernameForUserHandle(ByteArray userHandle); @@ -69,6 +75,8 @@ public interface CredentialRepository { * *

The returned {@link RegisteredCredential} is not expected to be long-lived. It may be read * directly from a database or assembled from other components. + * + *

Implementations of this method MUST NOT return null. */ Optional lookup(ByteArray credentialId, ByteArray userHandle); @@ -79,6 +87,8 @@ public interface CredentialRepository { *

This is used to refuse registration of duplicate credential IDs. Therefore, under normal * circumstances this method should only return zero or one credential (this is an expected * consequence, not an interface requirement). + * + *

Implementations of this method MUST NOT return null. */ Set lookupAll(ByteArray credentialId); } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionOptions.java index fa126741b..04a8f3b65 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionOptions.java @@ -27,8 +27,10 @@ import com.yubico.webauthn.data.AuthenticatorAssertionResponse; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.CollectedClientData; import com.yubico.webauthn.data.PublicKeyCredential; import java.util.Optional; +import java.util.Set; import lombok.Builder; import lombok.NonNull; import lombok.Value; @@ -59,6 +61,41 @@ public class FinishAssertionOptions { */ private final ByteArray callerTokenBindingId; + /** + * EXPERIMENTAL FEATURE: + * + *

If set to false (the default), the "type" property in the collected + * client data of the assertion will be verified to equal "webauthn.get". + * + *

If set to true, it will instead be verified to equal "payment.get" + * . + * + *

NOTE: If you're using Secure Payment + * Confirmation (SPC), you likely also need to relax the origin validation logic. Right now + * this library only supports matching against a finite {@link Set} of acceptable origins. If + * necessary, your application may validate the origin externally (see {@link + * PublicKeyCredential#getResponse()}, {@link AuthenticatorAssertionResponse#getClientData()} and + * {@link CollectedClientData#getOrigin()}) and construct a new {@link RelyingParty} instance for + * each SPC response, setting the {@link RelyingParty.RelyingPartyBuilder#origins(Set) origins} + * setting on that instance to contain the pre-validated origin value. + * + *

Better support for relaxing origin validation may be added as the feature matures. + * + * @deprecated EXPERIMENTAL: This is an experimental feature. It is likely to change or be deleted + * before reaching a mature release. + * @see Secure + * Payment Confirmation + * @see 5.8.1. + * Client Data Used in WebAuthn Signatures (dictionary CollectedClientData) + * @see RelyingParty.RelyingPartyBuilder#origins(Set) + * @see CollectedClientData + * @see CollectedClientData#getOrigin() + */ + @Deprecated @Builder.Default private final boolean isSecurePaymentConfirmation = false; + /** * The token binding ID of the * connection to the client, if any. 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 c39a87780..79cf442db 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 @@ -42,15 +42,16 @@ import java.security.spec.InvalidKeySpecException; import java.util.Optional; import java.util.Set; -import lombok.Builder; +import lombok.AllArgsConstructor; import lombok.Value; import lombok.extern.slf4j.Slf4j; -@Builder @Slf4j +@AllArgsConstructor final class FinishAssertionSteps { private static final String CLIENT_DATA_TYPE = "webauthn.get"; + private static final String SPC_CLIENT_DATA_TYPE = "payment.get"; private final AssertionRequest request; private final PublicKeyCredential @@ -59,11 +60,28 @@ final class FinishAssertionSteps { private final Set origins; private final String rpId; private final CredentialRepository credentialRepository; + private final boolean allowOriginPort; + private final boolean allowOriginSubdomain; + private final boolean validateSignatureCounter; + private final boolean isSecurePaymentConfirmation; + + FinishAssertionSteps(RelyingParty rp, FinishAssertionOptions options) { + this( + options.getRequest(), + options.getResponse(), + options.getCallerTokenBindingId(), + rp.getOrigins(), + rp.getIdentity().getId(), + rp.getCredentialRepository(), + rp.isAllowOriginPort(), + rp.isAllowOriginSubdomain(), + rp.isValidateSignatureCounter(), + options.isSecurePaymentConfirmation()); + } - @Builder.Default private final boolean allowOriginPort = false; - @Builder.Default private final boolean allowOriginSubdomain = false; - @Builder.Default private final boolean allowUnrequestedExtensions = false; - @Builder.Default private final boolean validateSignatureCounter = true; + private Optional getUsernameForUserHandle(final ByteArray userHandle) { + return credentialRepository.getUsernameForUserHandle(userHandle); + } public Step5 begin() { return new Step5(); @@ -279,10 +297,12 @@ class Step11 implements Step { @Override public void validate() { + final String expectedType = + isSecurePaymentConfirmation ? SPC_CLIENT_DATA_TYPE : CLIENT_DATA_TYPE; assertTrue( - CLIENT_DATA_TYPE.equals(clientData.getType()), + expectedType.equals(clientData.getType()), "The \"type\" in the client data must be exactly \"%s\", was: %s", - CLIENT_DATA_TYPE, + expectedType, clientData.getType()); } @@ -323,7 +343,8 @@ public void validate() { final String responseOrigin = response.getResponse().getClientData().getOrigin(); assertTrue( OriginMatcher.isAllowed(responseOrigin, origins, allowOriginPort, allowOriginSubdomain), - "Incorrect origin: " + responseOrigin); + "Incorrect origin, please see the RelyingParty.origins setting: %s", + responseOrigin); } @Override 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 4f8e20cd2..60d1350bf 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 @@ -63,12 +63,12 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import lombok.Builder; +import lombok.AllArgsConstructor; import lombok.Value; import lombok.extern.slf4j.Slf4j; -@Builder @Slf4j +@AllArgsConstructor final class FinishRegistrationSteps { private static final String CLIENT_DATA_TYPE = "webauthn.create"; @@ -86,10 +86,23 @@ final class FinishRegistrationSteps { private final Optional attestationTrustSource; private final CredentialRepository credentialRepository; private final Clock clock; - - @Builder.Default private final boolean allowOriginPort = false; - @Builder.Default private final boolean allowOriginSubdomain = false; - @Builder.Default private final boolean allowUnrequestedExtensions = false; + private final boolean allowOriginPort; + private final boolean allowOriginSubdomain; + + FinishRegistrationSteps(RelyingParty rp, FinishRegistrationOptions options) { + this( + options.getRequest(), + options.getResponse(), + options.getCallerTokenBindingId(), + rp.getOrigins(), + rp.getIdentity().getId(), + rp.isAllowUntrustedAttestation(), + rp.getAttestationTrustSource(), + rp.getCredentialRepository(), + rp.getClock(), + rp.isAllowOriginPort(), + rp.isAllowOriginSubdomain()); + } public Step6 begin() { return new Step6(); @@ -186,7 +199,8 @@ public void validate() { final String responseOrigin = clientData.getOrigin(); assertTrue( OriginMatcher.isAllowed(responseOrigin, origins, allowOriginPort, allowOriginSubdomain), - "Incorrect origin: " + responseOrigin); + "Incorrect origin, please see the RelyingParty.origins setting: %s", + responseOrigin); } @Override 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 a33ac9793..21246e5b2 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 @@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.yubico.webauthn.data.AttestedCredentialData; import com.yubico.webauthn.data.AuthenticatorAssertionResponse; @@ -34,6 +35,10 @@ import com.yubico.webauthn.data.COSEAlgorithmIdentifier; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import com.yubico.webauthn.data.UserIdentity; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; import java.util.Optional; import lombok.AccessLevel; import lombok.Builder; @@ -84,6 +89,19 @@ public final class RegisteredCredential { */ @NonNull private final ByteArray publicKeyCose; + /** + * The public key of the credential, parsed as a {@link PublicKey} object. + * + * @see #getPublicKeyCose() + * @see RegistrationResult#getParsedPublicKey() + */ + @NonNull + @JsonIgnore + public PublicKey getParsedPublicKey() + throws InvalidKeySpecException, NoSuchAlgorithmException, IOException { + return WebAuthnCodecs.importCosePublicKey(getPublicKeyCose()); + } + /** * The stored signature * count of the credential. 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 b113b3072..499003730 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 @@ -41,9 +41,13 @@ import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -285,6 +289,19 @@ public ByteArray getPublicKeyCose() { .getCredentialPublicKey(); } + /** + * The public key of the created credential, parsed as a {@link PublicKey} object. + * + * @see #getPublicKeyCose() + * @see RegisteredCredential#getParsedPublicKey() + */ + @NonNull + @JsonIgnore + public PublicKey getParsedPublicKey() + throws InvalidKeySpecException, NoSuchAlgorithmException, IOException { + return WebAuthnCodecs.importCosePublicKey(getPublicKeyCose()); + } + /** * The client 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 7c0daf12d..ba90555bd 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 @@ -29,14 +29,9 @@ import com.yubico.webauthn.attestation.AttestationTrustSource; import com.yubico.webauthn.data.AssertionExtensionInputs; import com.yubico.webauthn.data.AttestationConveyancePreference; -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.ClientAssertionExtensionOutputs; -import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; import com.yubico.webauthn.data.CollectedClientData; -import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions.PublicKeyCredentialCreationOptionsBuilder; import com.yubico.webauthn.data.PublicKeyCredentialParameters; @@ -286,6 +281,11 @@ public class RelyingParty { * If true, the origin matching rule is relaxed to allow any subdomain, of any depth, * of the values of {@link RelyingPartyBuilder#origins(Set) origins}. * + *

Please see Security + * Considerations: Code injection attacks for discussion of the risks in setting this to + * true. + * *

The default is false. * *

Examples with origins: ["https://example.org", "https://acme.com:8443"] @@ -320,6 +320,9 @@ public class RelyingParty { *

  • https://acme.com * * + * + * @see §13.4.8. + * Code injection attacks */ @Builder.Default private final boolean allowOriginSubdomain = false; @@ -412,7 +415,7 @@ private static ByteArray generateChallenge() { * * @return a new {@link List} containing only the algorithms supported in the current JCA context. */ - private static List filterAvailableAlgorithms( + static List filterAvailableAlgorithms( List pubKeyCredParams) { return Collections.unmodifiableList( pubKeyCredParams.stream() @@ -490,7 +493,8 @@ public PublicKeyCredentialCreationOptions startRegistration( .appidExclude(appId) .credProps() .build())) - .timeout(startRegistrationOptions.getTimeout()); + .timeout(startRegistrationOptions.getTimeout()) + .hints(startRegistrationOptions.getHints()); attestationConveyancePreference.ifPresent(builder::attestation); return builder.build(); } @@ -498,11 +502,7 @@ public PublicKeyCredentialCreationOptions startRegistration( public RegistrationResult finishRegistration(FinishRegistrationOptions finishRegistrationOptions) throws RegistrationFailedException { try { - return _finishRegistration( - finishRegistrationOptions.getRequest(), - finishRegistrationOptions.getResponse(), - finishRegistrationOptions.getCallerTokenBindingId()) - .run(); + return _finishRegistration(finishRegistrationOptions).run(); } catch (IllegalArgumentException e) { throw new RegistrationFailedException(e); } @@ -515,24 +515,8 @@ public RegistrationResult finishRegistration(FinishRegistrationOptions finishReg * It is a separate method to facilitate testing; users should call {@link * #finishRegistration(FinishRegistrationOptions)} instead of this method. */ - FinishRegistrationSteps _finishRegistration( - PublicKeyCredentialCreationOptions request, - PublicKeyCredential - response, - Optional callerTokenBindingId) { - return FinishRegistrationSteps.builder() - .request(request) - .response(response) - .callerTokenBindingId(callerTokenBindingId) - .credentialRepository(credentialRepository) - .origins(origins) - .rpId(identity.getId()) - .allowOriginPort(allowOriginPort) - .allowOriginSubdomain(allowOriginSubdomain) - .allowUntrustedAttestation(allowUntrustedAttestation) - .attestationTrustSource(attestationTrustSource) - .clock(clock) - .build(); + FinishRegistrationSteps _finishRegistration(FinishRegistrationOptions options) { + return new FinishRegistrationSteps(this, options); } public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptions) { @@ -554,7 +538,8 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio startAssertionOptions .getExtensions() .merge(startAssertionOptions.getExtensions().toBuilder().appid(appId).build())) - .timeout(startAssertionOptions.getTimeout()); + .timeout(startAssertionOptions.getTimeout()) + .hints(startAssertionOptions.getHints()); startAssertionOptions.getUserVerification().ifPresent(pkcro::userVerification); @@ -576,11 +561,7 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio public AssertionResult finishAssertion(FinishAssertionOptions finishAssertionOptions) throws AssertionFailedException { try { - return _finishAssertion( - finishAssertionOptions.getRequest(), - finishAssertionOptions.getResponse(), - finishAssertionOptions.getCallerTokenBindingId()) - .run(); + return _finishAssertion(finishAssertionOptions).run(); } catch (IllegalArgumentException e) { throw new AssertionFailedException(e); } @@ -593,22 +574,8 @@ public AssertionResult finishAssertion(FinishAssertionOptions finishAssertionOpt * a separate method to facilitate testing; users should call {@link * #finishAssertion(FinishAssertionOptions)} instead of this method. */ - FinishAssertionSteps _finishAssertion( - AssertionRequest request, - PublicKeyCredential response, - Optional callerTokenBindingId // = None.asJava - ) { - return FinishAssertionSteps.builder() - .request(request) - .response(response) - .callerTokenBindingId(callerTokenBindingId) - .origins(origins) - .rpId(identity.getId()) - .credentialRepository(credentialRepository) - .allowOriginPort(allowOriginPort) - .allowOriginSubdomain(allowOriginSubdomain) - .validateSignatureCounter(validateSignatureCounter) - .build(); + FinishAssertionSteps _finishAssertion(FinishAssertionOptions options) { + return new FinishAssertionSteps(this, options); } public static RelyingPartyBuilder.MandatoryStages builder() { diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java index 461f31228..02f2cdba9 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java @@ -26,8 +26,13 @@ import com.yubico.webauthn.data.AssertionExtensionInputs; import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import com.yubico.webauthn.data.PublicKeyCredentialHint; import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; import com.yubico.webauthn.data.UserVerificationRequirement; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Optional; import lombok.Builder; import lombok.NonNull; @@ -79,6 +84,55 @@ public class StartAssertionOptions { */ private final Long timeout; + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialRequestOptions} so they can be used in the argument to + * navigator.credentials.get() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see PublicKeyCredentialRequestOptions#getHints() + * @see StartAssertionOptionsBuilder#hints(List) + * @see StartAssertionOptionsBuilder#hints(String...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + private final List hints; + + private StartAssertionOptions( + String username, + ByteArray userHandle, + @NonNull AssertionExtensionInputs extensions, + UserVerificationRequirement userVerification, + Long timeout, + List hints) { + this.username = username; + this.userHandle = userHandle; + this.extensions = extensions; + this.userVerification = userVerification; + this.timeout = timeout; + this.hints = hints == null ? Collections.emptyList() : Collections.unmodifiableList(hints); + } + /** * The username of the user to authenticate, if the user has already been identified. * @@ -370,5 +424,122 @@ public StartAssertionOptionsBuilder timeout(long timeout) { private StartAssertionOptionsBuilder timeout(Long timeout) { return this.timeout(Optional.ofNullable(timeout)); } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialRequestOptions} so they can be used in the argument + * to navigator.credentials.get() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see PublicKeyCredentialRequestOptions#getHints() + * @see StartAssertionOptions#getHints() + * @see StartAssertionOptionsBuilder#hints(List) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public StartAssertionOptionsBuilder hints(@NonNull String... hints) { + this.hints = Arrays.asList(hints); + return this; + } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialRequestOptions} so they can be used in the argument + * to navigator.credentials.get() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see PublicKeyCredentialRequestOptions#getHints() + * @see StartAssertionOptions#getHints() + * @see StartAssertionOptionsBuilder#hints(List) + * @see StartAssertionOptionsBuilder#hints(String...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public StartAssertionOptionsBuilder hints(@NonNull PublicKeyCredentialHint... hints) { + return this.hints( + Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); + } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialRequestOptions} so they can be used in the argument + * to navigator.credentials.get() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see PublicKeyCredentialRequestOptions#getHints() + * @see StartAssertionOptions#getHints() + * @see StartAssertionOptionsBuilder#hints(String...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public StartAssertionOptionsBuilder hints(@NonNull List hints) { + this.hints = hints; + return this; + } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java index e78184fb5..7d9d18ab9 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java @@ -26,8 +26,12 @@ import com.yubico.webauthn.data.AuthenticatorSelectionCriteria; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; +import com.yubico.webauthn.data.PublicKeyCredentialHint; import com.yubico.webauthn.data.RegistrationExtensionInputs; import com.yubico.webauthn.data.UserIdentity; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Optional; import lombok.Builder; import lombok.NonNull; @@ -64,6 +68,52 @@ public class StartRegistrationOptions { */ private final Long timeout; + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this occurs, + * the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialCreationOptions} so they can be used in the argument + * to navigator.credentials.create() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptionsBuilder#hints(List) + * @see StartRegistrationOptionsBuilder#hints(String...) + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + private final List hints; + + private StartRegistrationOptions( + @NonNull UserIdentity user, + AuthenticatorSelectionCriteria authenticatorSelection, + @NonNull RegistrationExtensionInputs extensions, + Long timeout, + List hints) { + this.user = user; + this.authenticatorSelection = authenticatorSelection; + this.extensions = extensions; + this.timeout = timeout; + this.hints = hints == null ? Collections.emptyList() : Collections.unmodifiableList(hints); + } + /** * Constraints on what kind of authenticator the user is allowed to use to create the credential, * and on features that authenticator must or should support. @@ -157,5 +207,119 @@ public StartRegistrationOptionsBuilder timeout(@NonNull Optional timeout) public StartRegistrationOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialCreationOptions} so they can be used in the argument + * to navigator.credentials.create() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see StartRegistrationOptionsBuilder#hints(List) + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public StartRegistrationOptionsBuilder hints(@NonNull String... hints) { + this.hints = Arrays.asList(hints); + return this; + } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialCreationOptions} so they can be used in the argument + * to navigator.credentials.create() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see StartRegistrationOptionsBuilder#hints(List) + * @see StartRegistrationOptionsBuilder#hints(String...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public StartRegistrationOptionsBuilder hints(@NonNull PublicKeyCredentialHint... hints) { + return this.hints( + Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); + } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through to the {@link PublicKeyCredentialCreationOptions} so they can be used in the argument + * to navigator.credentials.create() on the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see StartRegistrationOptionsBuilder#hints(String...) + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public StartRegistrationOptionsBuilder hints(@NonNull List hints) { + this.hints = hints; + return this; + } } } 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 24dffeb4c..df223757b 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 @@ -26,6 +26,7 @@ import com.google.common.primitives.Bytes; import com.upokecenter.cbor.CBORObject; +import com.yubico.internal.util.BinaryUtil; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.COSEAlgorithmIdentifier; import java.io.IOException; @@ -40,7 +41,6 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import java.util.stream.Stream; final class WebAuthnCodecs { @@ -173,71 +173,43 @@ private static PublicKey importCoseRsaPublicKey(CBORObject cose) private static PublicKey importCoseEcdsaPublicKey(CBORObject cose) throws NoSuchAlgorithmException, InvalidKeySpecException { final int crv = cose.get(CBORObject.FromObject(-1)).AsInt32Value(); - final ByteArray x = new ByteArray(cose.get(CBORObject.FromObject(-2)).GetByteString()); - final ByteArray y = new ByteArray(cose.get(CBORObject.FromObject(-3)).GetByteString()); + final byte[] x = cose.get(CBORObject.FromObject(-2)).GetByteString(); + final byte[] y = cose.get(CBORObject.FromObject(-3)).GetByteString(); - final ByteArray curveOid; + final byte[] curveOid; switch (crv) { case 1: - curveOid = P256_CURVE_OID; + curveOid = P256_CURVE_OID.getBytes(); break; case 2: - curveOid = P384_CURVE_OID; + curveOid = P384_CURVE_OID.getBytes(); break; case 3: - curveOid = P512_CURVE_OID; + curveOid = P512_CURVE_OID.getBytes(); break; default: throw new IllegalArgumentException("Unknown COSE EC2 curve: " + crv); } - final ByteArray algId = - encodeDerSequence(encodeDerObjectId(EC_PUBLIC_KEY_OID), encodeDerObjectId(curveOid)); + final byte[] algId = + BinaryUtil.encodeDerSequence( + BinaryUtil.encodeDerObjectId(EC_PUBLIC_KEY_OID.getBytes()), + BinaryUtil.encodeDerObjectId(curveOid)); - final ByteArray rawKey = - encodeDerBitStringWithZeroUnused( - new ByteArray(new byte[] {0x04}) // Raw EC public key with x and y - .concat(x) - .concat(y)); + final byte[] rawKey = + BinaryUtil.encodeDerBitStringWithZeroUnused( + BinaryUtil.concat( + new byte[] {0x04}, // Raw EC public key with x and y + x, + y)); - final ByteArray x509Key = encodeDerSequence(algId, rawKey); + final byte[] x509Key = BinaryUtil.encodeDerSequence(algId, rawKey); KeyFactory kFact = KeyFactory.getInstance("EC"); - return kFact.generatePublic(new X509EncodedKeySpec(x509Key.getBytes())); - } - - private static ByteArray encodeDerLength(final int length) { - if (length <= 127) { - return new ByteArray(new byte[] {(byte) length}); - } else if (length <= 0xffff) { - if (length <= 255) { - return new ByteArray(new byte[] {-127, (byte) length}); - } else { - return new ByteArray(new byte[] {-126, (byte) (length >> 8), (byte) (length & 0x00ff)}); - } - } else { - throw new UnsupportedOperationException("Too long: " + length); - } - } - - private static ByteArray encodeDerObjectId(final ByteArray oid) { - return new ByteArray(new byte[] {0x06, (byte) oid.size()}).concat(oid); - } - - private static ByteArray encodeDerBitStringWithZeroUnused(final ByteArray content) { - return new ByteArray(new byte[] {0x03}) - .concat(encodeDerLength(1 + content.size())) - .concat(new ByteArray(new byte[] {0})) - .concat(content); - } - - private static ByteArray encodeDerSequence(final ByteArray... items) { - final ByteArray content = - Stream.of(items).reduce(ByteArray::concat).orElseGet(() -> new ByteArray(new byte[0])); - return new ByteArray(new byte[] {0x30}).concat(encodeDerLength(content.size())).concat(content); + return kFact.generatePublic(new X509EncodedKeySpec(x509Key)); } private static PublicKey importCoseEdDsaPublicKey(CBORObject cose) @@ -253,12 +225,13 @@ private static PublicKey importCoseEdDsaPublicKey(CBORObject cose) private static PublicKey importCoseEd25519PublicKey(CBORObject cose) throws InvalidKeySpecException, NoSuchAlgorithmException { - final ByteArray rawKey = new ByteArray(cose.get(CBORObject.FromObject(-2)).GetByteString()); - final ByteArray x509Key = - encodeDerSequence(ED25519_ALG_ID, encodeDerBitStringWithZeroUnused(rawKey)); + final byte[] rawKey = cose.get(CBORObject.FromObject(-2)).GetByteString(); + final byte[] x509Key = + BinaryUtil.encodeDerSequence( + ED25519_ALG_ID.getBytes(), BinaryUtil.encodeDerBitStringWithZeroUnused(rawKey)); KeyFactory kFact = KeyFactory.getInstance("EdDSA"); - return kFact.generatePublic(new X509EncodedKeySpec(x509Key.getBytes())); + return kFact.generatePublic(new X509EncodedKeySpec(x509Key)); } static String getJavaAlgorithmName(COSEAlgorithmIdentifier alg) { diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttachment.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttachment.java index d5d338b42..ad223ed04 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttachment.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttachment.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Optional; import java.util.stream.Stream; import lombok.AllArgsConstructor; import lombok.Getter; @@ -72,8 +73,23 @@ public enum AuthenticatorAttachment { @JsonValue @Getter @NonNull private final String value; + /** + * Attempt to parse a string as an {@link AuthenticatorAttachment}. + * + * @param value a {@link String} equal to the {@link #getValue() value} of a constant in {@link + * AuthenticatorAttachment} + * @return The {@link AuthenticatorAttachment} instance whose {@link #getValue() value} equals + * value, if any. + * @see §5.4.5. + * Authenticator Attachment Enumeration (enum AuthenticatorAttachment) + */ + public static Optional fromValue(@NonNull String value) { + return Stream.of(values()).filter(v -> v.value.equals(value)).findAny(); + } + @JsonCreator private static AuthenticatorAttachment fromJsonString(@NonNull String value) { - return Stream.of(values()).filter(v -> v.value.equals(value)).findAny().orElse(null); + return fromValue(value).orElse(null); } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java index 99c1f2283..d25d0f901 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/Extensions.java @@ -15,6 +15,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import lombok.Builder; import lombok.NonNull; import lombok.Value; import lombok.experimental.UtilityClass; @@ -64,13 +65,14 @@ public static class CredentialProperties { * Credential Properties Extension (credProps) */ @Value + @Builder @JsonIgnoreProperties(ignoreUnknown = true) public static class CredentialPropertiesOutput { @JsonProperty("rk") private final Boolean rk; @JsonCreator - CredentialPropertiesOutput(@JsonProperty("rk") Boolean rk) { + private CredentialPropertiesOutput(@JsonProperty("rk") Boolean rk) { this.rk = rk; } @@ -216,6 +218,9 @@ public static Set values() { * Extension inputs for the Large blob storage extension (largeBlob) in * authentication ceremonies. * + *

    Use the {@link #read()} and {@link #write(ByteArray)} factory functions to construct this + * type. + * * @see §10.5. * Large blob storage extension (largeBlob) @@ -311,6 +316,8 @@ public Optional getWrite() { * Extension outputs for the Large blob storage extension (largeBlob) in * registration ceremonies. * + *

    Use the {@link #supported(boolean)} factory function to construct this type. + * * @see §10.5. * Large blob storage extension (largeBlob) @@ -328,9 +335,21 @@ public static class LargeBlobRegistrationOutput { @JsonProperty private final boolean supported; @JsonCreator - LargeBlobRegistrationOutput(@JsonProperty("supported") boolean supported) { + private LargeBlobRegistrationOutput(@JsonProperty("supported") boolean supported) { this.supported = supported; } + + /** + * Create a Large blob storage extension output with the supported output set to + * the given value. + * + * @see + * dictionary AuthenticationExtensionsLargeBlobOutputs + */ + public static LargeBlobRegistrationOutput supported(boolean supported) { + return new LargeBlobRegistrationOutput(supported); + } } /** @@ -347,12 +366,43 @@ public static class LargeBlobAuthenticationOutput { @JsonProperty private final Boolean written; @JsonCreator - LargeBlobAuthenticationOutput( + private LargeBlobAuthenticationOutput( @JsonProperty("blob") ByteArray blob, @JsonProperty("written") Boolean written) { this.blob = blob; this.written = written; } + /** + * Create a Large blob storage extension output with the blob output set to the + * given value. + * + *

    This corresponds to the extension input {@link LargeBlobAuthenticationInput#read() + * LargeBlobAuthenticationInput.read()}. + * + * @see + * dictionary AuthenticationExtensionsLargeBlobOutputs + */ + public static LargeBlobAuthenticationOutput read(final ByteArray blob) { + return new LargeBlobAuthenticationOutput(blob, null); + } + + /** + * Create a Large blob storage extension output with the written output set to + * the given value. + * + *

    This corresponds to the extension input {@link + * LargeBlobAuthenticationInput#write(ByteArray) + * LargeBlobAuthenticationInput.write(ByteArray)}. + * + * @see + * dictionary AuthenticationExtensionsLargeBlobOutputs + */ + public static LargeBlobAuthenticationOutput write(final boolean write) { + return new LargeBlobAuthenticationOutput(null, write); + } + /** * The opaque byte string that was associated with the credential identified by {@link * PublicKeyCredential#getId()}. Only valid if {@link LargeBlobAuthenticationInput#getRead()} 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 a5f252c31..2ed1c22de 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 @@ -33,9 +33,11 @@ import com.yubico.internal.util.JacksonCodecs; import com.yubico.webauthn.FinishRegistrationOptions; import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.StartRegistrationOptions; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.Signature; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -94,6 +96,40 @@ public class PublicKeyCredentialCreationOptions { */ private final Long timeout; + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this occurs, + * the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.create() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see PublicKeyCredentialCreationOptionsBuilder#hints(List) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(String...) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + private final List hints; + /** * Intended for use by Relying Parties that wish to limit the creation of multiple credentials for * the same account on a single authenticator. The client is requested to return an error if the @@ -136,6 +172,7 @@ private PublicKeyCredentialCreationOptions( @NonNull @JsonProperty("pubKeyCredParams") List pubKeyCredParams, @JsonProperty("timeout") Long timeout, + @JsonProperty("hints") List hints, @JsonProperty("excludeCredentials") Set excludeCredentials, @JsonProperty("authenticatorSelection") AuthenticatorSelectionCriteria authenticatorSelection, @JsonProperty("attestation") AttestationConveyancePreference attestation, @@ -145,6 +182,7 @@ private PublicKeyCredentialCreationOptions( this.challenge = challenge; this.pubKeyCredParams = filterAvailableAlgorithms(pubKeyCredParams); this.timeout = timeout; + this.hints = hints == null ? Collections.emptyList() : Collections.unmodifiableList(hints); this.excludeCredentials = excludeCredentials == null ? null @@ -200,7 +238,7 @@ public String toJson() throws JsonProcessingException { } /** - * Decode an {@link PublicKeyCredentialCreationOptions} from JSON. The inverse of {@link + * Decode a {@link PublicKeyCredentialCreationOptions} from JSON. The inverse of {@link * #toJson()}. * *

    If the JSON was generated by the {@link #toJson()} method, then {@link #fromJson(String)} in @@ -317,6 +355,118 @@ public PublicKeyCredentialCreationOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.create() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see PublicKeyCredentialCreationOptions#getHints() + * @see PublicKeyCredentialCreationOptionsBuilder#hints(List) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public PublicKeyCredentialCreationOptionsBuilder hints(@NonNull String... hints) { + this.hints = Arrays.asList(hints); + return this; + } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.create() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see PublicKeyCredentialCreationOptions#getHints() + * @see PublicKeyCredentialCreationOptionsBuilder#hints(List) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(String...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public PublicKeyCredentialCreationOptionsBuilder hints( + @NonNull PublicKeyCredentialHint... hints) { + return this.hints( + Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); + } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this registration operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of registering with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of registering a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict preferences in {@link #getAuthenticatorSelection()}. When this + * occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.create() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartRegistrationOptions#getHints() + * @see PublicKeyCredentialCreationOptions#getHints() + * @see PublicKeyCredentialCreationOptionsBuilder#hints(String...) + * @see PublicKeyCredentialCreationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialCreationOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public PublicKeyCredentialCreationOptionsBuilder hints(@NonNull List hints) { + this.hints = hints; + return this; + } + /** * Intended for use by Relying Parties that wish to limit the creation of multiple credentials * for the same account on a single authenticator. The client is requested to return an error if diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java new file mode 100644 index 000000000..c063bc0d3 --- /dev/null +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialHint.java @@ -0,0 +1,185 @@ +// 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.data; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.yubico.webauthn.RelyingParty.RelyingPartyBuilder; +import com.yubico.webauthn.StartAssertionOptions; +import com.yubico.webauthn.StartAssertionOptions.StartAssertionOptionsBuilder; +import com.yubico.webauthn.StartRegistrationOptions; +import com.yubico.webauthn.StartRegistrationOptions.StartRegistrationOptionsBuilder; +import com.yubico.webauthn.attestation.AttestationTrustSource; +import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NonNull; +import lombok.Value; + +/** + * Hints to guide the user agent in interacting with the user. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of using an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the option + * of using a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + * @see StartRegistrationOptions#getHints() + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialCreationOptions.hints + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class PublicKeyCredentialHint { + + @JsonValue @NonNull private final String value; + + /** + * Indicates that the application believes that users will satisfy this request with a physical + * security key. + * + *

    For example, an enterprise application may set this hint if they have issued security keys + * to their employees and will only accept those authenticators for registration and + * authentication. In that case, the application should probably also set {@link + * RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource) attestationTrustSource} and + * set {@link RelyingPartyBuilder#allowUntrustedAttestation(boolean) allowUntrustedAttestation} to + * false. See also the + * webauthn-server-attestation module. + * + *

    For compatibility with older user agents, when this hint is used in {@link + * StartRegistrationOptions}, the + * {@link StartRegistrationOptionsBuilder#authenticatorSelection(AuthenticatorSelectionCriteria) authenticatorSelection}.{@link AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder#authenticatorAttachment(AuthenticatorAttachment) authenticatorAttachment} + * parameter SHOULD be set to {@link AuthenticatorAttachment#CROSS_PLATFORM}. + * + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see + * security-key in §5.8.7. User-agent Hints Enumeration (enum + * PublicKeyCredentialHints) + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public static final PublicKeyCredentialHint SECURITY_KEY = + new PublicKeyCredentialHint("security-key"); + + /** + * Indicates that the application believes that users will satisfy this request with an + * authenticator built into the client device. + * + *

    For compatibility with older user agents, when this hint is used in {@link + * StartRegistrationOptions}, the + * {@link StartRegistrationOptionsBuilder#authenticatorSelection(AuthenticatorSelectionCriteria) authenticatorSelection}.{@link AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder#authenticatorAttachment(AuthenticatorAttachment) authenticatorAttachment} + * parameter SHOULD be set to {@link AuthenticatorAttachment#PLATFORM}. + * + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see + * client-device in §5.8.7. User-agent Hints Enumeration (enum + * PublicKeyCredentialHints) + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public static final PublicKeyCredentialHint CLIENT_DEVICE = + new PublicKeyCredentialHint("client-device"); + + /** + * Indicates that the application believes that users will satisfy this request with + * general-purpose authenticators such as smartphones. For example, a consumer application may + * believe that only a small fraction of their customers possesses dedicated security keys. This + * option also implies that the local platform authenticator should not be promoted in the UI. + * + *

    For compatibility with older user agents, when this hint is used in {@link + * StartRegistrationOptions}, the + * {@link StartRegistrationOptionsBuilder#authenticatorSelection(AuthenticatorSelectionCriteria) authenticatorSelection}.{@link AuthenticatorSelectionCriteria.AuthenticatorSelectionCriteriaBuilder#authenticatorAttachment(AuthenticatorAttachment) authenticatorAttachment} + * parameter SHOULD be set to {@link AuthenticatorAttachment#CROSS_PLATFORM}. + * + * @see StartRegistrationOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see StartAssertionOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see + * hybrid in §5.8.7. User-agent Hints Enumeration (enum PublicKeyCredentialHints) + * + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public static final PublicKeyCredentialHint HYBRID = new PublicKeyCredentialHint("hybrid"); + + /** + * @return An array containing all predefined values of {@link PublicKeyCredentialHint} known by + * this implementation. + */ + public static PublicKeyCredentialHint[] values() { + return new PublicKeyCredentialHint[] {SECURITY_KEY, CLIENT_DEVICE, HYBRID}; + } + + /** + * @return If value is the same as that of any of {@link #SECURITY_KEY}, {@link + * #CLIENT_DEVICE} or {@link #HYBRID}, returns that constant instance. Otherwise returns a new + * instance containing value. + * @see #valueOf(String) + */ + @JsonCreator + public static PublicKeyCredentialHint of(@NonNull String value) { + return Stream.of(values()) + .filter(v -> v.getValue().equals(value)) + .findAny() + .orElseGet(() -> new PublicKeyCredentialHint(value)); + } + + /** + * @return If name equals "SECURITY_KEY", "CLIENT_DEVICE" + * or "HYBRID", returns the constant by that name. + * @throws IllegalArgumentException if name is anything else. + * @see #of(String) + */ + public static PublicKeyCredentialHint valueOf(String name) { + switch (name) { + case "SECURITY_KEY": + return SECURITY_KEY; + case "CLIENT_DEVICE": + return CLIENT_DEVICE; + case "HYBRID": + return HYBRID; + default: + throw new IllegalArgumentException( + "No constant com.yubico.webauthn.data.PublicKeyCredentialHint." + name); + } + } +} 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 4834d81a4..da37870cc 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 @@ -31,6 +31,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.yubico.internal.util.CollectionUtil; import com.yubico.internal.util.JacksonCodecs; +import com.yubico.webauthn.StartAssertionOptions; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Optional; import lombok.Builder; @@ -66,6 +69,40 @@ public class PublicKeyCredentialRequestOptions { */ private final Long timeout; + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.get() on the + * client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialRequestOptionsBuilder#hints(List) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(String...) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + private final List hints; + /** * Specifies the relying party identifier claimed by the caller. * @@ -112,12 +149,14 @@ public class PublicKeyCredentialRequestOptions { private PublicKeyCredentialRequestOptions( @NonNull @JsonProperty("challenge") ByteArray challenge, @JsonProperty("timeout") Long timeout, + @JsonProperty("hints") List hints, @JsonProperty("rpId") String rpId, @JsonProperty("allowCredentials") List allowCredentials, @JsonProperty("userVerification") UserVerificationRequirement userVerification, @NonNull @JsonProperty("extensions") AssertionExtensionInputs extensions) { this.challenge = challenge; this.timeout = timeout; + this.hints = hints == null ? Collections.emptyList() : Collections.unmodifiableList(hints); this.rpId = rpId; this.allowCredentials = allowCredentials == null ? null : CollectionUtil.immutableList(allowCredentials); @@ -213,6 +252,124 @@ public PublicKeyCredentialRequestOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.get() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialRequestOptions#getHints() + * @see PublicKeyCredentialRequestOptionsBuilder#hints(List) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public PublicKeyCredentialRequestOptionsBuilder hints(@NonNull String... hints) { + this.hints = Arrays.asList(hints); + return this; + } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.get() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialRequestOptions#getHints() + * @see PublicKeyCredentialRequestOptionsBuilder#hints(List) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(String...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public PublicKeyCredentialRequestOptionsBuilder hints( + @NonNull PublicKeyCredentialHint... hints) { + return this.hints( + Arrays.stream(hints).map(PublicKeyCredentialHint::getValue).toArray(String[]::new)); + } + + /** + * Zero or more hints, in descending order of preference, to guide the user agent in interacting + * with the user during this authentication operation. + * + *

    Setting this property multiple times overwrites any value set previously. + * + *

    For example, the {@link PublicKeyCredentialHint#SECURITY_KEY} hint may be used to ask the + * client to emphasize the option of authenticating with an external security key, or the {@link + * PublicKeyCredentialHint#CLIENT_DEVICE} hint may be used to ask the client to emphasize the + * option of authenticating a built-in passkey provider. + * + *

    These hints are not requirements, and do not bind the user-agent, but may guide it in + * providing the best experience by using contextual information about the request. + * + *

    Hints MAY contradict information contained in {@link + * PublicKeyCredentialDescriptor#getTransports()}. When this occurs, the hints take precedence. + * + *

    This library does not take these hints into account in any way, other than passing them + * through so they can be used in the argument to navigator.credentials.get() on + * the client side. + * + *

    The default is empty. + * + * @see PublicKeyCredentialHint + * @see StartAssertionOptions#getHints() + * @see PublicKeyCredentialRequestOptions#getHints() + * @see PublicKeyCredentialRequestOptionsBuilder#hints(String...) + * @see PublicKeyCredentialRequestOptionsBuilder#hints(PublicKeyCredentialHint...) + * @see PublicKeyCredentialRequestOptions.hints + * @see §5.8.7. + * User-agent Hints Enumeration (enum PublicKeyCredentialHints) + */ + public PublicKeyCredentialRequestOptionsBuilder hints(@NonNull List hints) { + this.hints = hints; + return this; + } + /** * Specifies the relying party identifier claimed by the caller. * diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialType.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialType.java index 7a480eea2..7e08a0dba 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialType.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/PublicKeyCredentialType.java @@ -51,13 +51,24 @@ public enum PublicKeyCredentialType { @JsonValue @Getter @NonNull private final String id; - private static Optional fromString(@NonNull String id) { + /** + * Attempt to parse a string as a {@link PublicKeyCredentialType}. + * + * @param id a {@link String} equal to the {@link #getId() id} of a constant in {@link + * PublicKeyCredentialType} + * @return The {@link AuthenticatorAttachment} instance whose {@link #getId() id} equals id + * , if any. + * @see §5.10.2. + * Credential Type Enumeration (enum PublicKeyCredentialType) + */ + public static Optional fromId(@NonNull String id) { return Stream.of(values()).filter(v -> v.id.equals(id)).findAny(); } @JsonCreator private static PublicKeyCredentialType fromJsonString(@NonNull String id) { - return fromString(id) + return fromId(id) .orElseThrow( () -> new IllegalArgumentException( diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ResidentKeyRequirement.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ResidentKeyRequirement.java index b27912d25..a2664854e 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ResidentKeyRequirement.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/ResidentKeyRequirement.java @@ -105,13 +105,24 @@ public enum ResidentKeyRequirement { @JsonValue @Getter @NonNull private final String value; - private static Optional fromString(@NonNull String value) { + /** + * Attempt to parse a string as a {@link ResidentKeyRequirement}. + * + * @param value a {@link String} equal to the {@link #getValue() value} of a constant in {@link + * ResidentKeyRequirement} + * @return The {@link ResidentKeyRequirement} instance whose {@link #getValue() value} equals + * value, if any. + * @see §5.4.6. + * Resident Key Requirement Enumeration (enum ResidentKeyRequirement) + */ + public static Optional fromValue(@NonNull String value) { return Stream.of(values()).filter(v -> v.value.equals(value)).findAny(); } @JsonCreator private static ResidentKeyRequirement fromJsonString(@NonNull String value) { - return fromString(value) + return fromValue(value) .orElseThrow( () -> new IllegalArgumentException( diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/TokenBindingStatus.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/TokenBindingStatus.java index 1e499751b..a9feec1d6 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/TokenBindingStatus.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/TokenBindingStatus.java @@ -58,13 +58,24 @@ public enum TokenBindingStatus { @JsonValue @Getter @NonNull private final String value; - private static Optional fromString(@NonNull String value) { + /** + * Attempt to parse a string as a {@link TokenBindingStatus}. + * + * @param value a {@link String} equal to the {@link #getValue() value} of a constant in {@link + * TokenBindingStatus} + * @return The {@link TokenBindingStatus} instance whose {@link #getValue() value} equals + * value, if any. + * @see enum + * TokenBindingStatus + */ + public static Optional fromValue(@NonNull String value) { return Arrays.stream(values()).filter(v -> v.value.equals(value)).findAny(); } @JsonCreator static TokenBindingStatus fromJsonString(@NonNull String value) { - return fromString(value) + return fromValue(value) .orElseThrow( () -> new IllegalArgumentException( diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/UserVerificationRequirement.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/UserVerificationRequirement.java index 642f71bf3..e975fed0b 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/UserVerificationRequirement.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/UserVerificationRequirement.java @@ -66,13 +66,24 @@ public enum UserVerificationRequirement { @JsonValue @Getter @NonNull private final String value; - private static Optional fromString(@NonNull String value) { + /** + * Attempt to parse a string as a {@link UserVerificationRequirement}. + * + * @param value a {@link String} equal to the {@link #getValue() value} of a constant in {@link + * UserVerificationRequirement} + * @return The {@link UserVerificationRequirement} instance whose {@link #getValue() value} equals + * value, if any. + * @see §5.10.6. + * User Verification Requirement Enumeration (enum UserVerificationRequirement) + */ + public static Optional fromValue(@NonNull String value) { return Stream.of(values()).filter(v -> v.value.equals(value)).findAny(); } @JsonCreator private static UserVerificationRequirement fromJsonString(@NonNull String value) { - return fromString(value) + return fromValue(value) .orElseThrow( () -> new IllegalArgumentException( diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala index bcad72216..c9655c7db 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala @@ -10,6 +10,7 @@ import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredential +import com.yubico.webauthn.data.PublicKeyCredentialHint import com.yubico.webauthn.data.UserVerificationRequirement import org.bouncycastle.asn1.x500.X500Name import org.scalacheck.Arbitrary @@ -97,12 +98,22 @@ object Generators { for { extensions <- arbitrary[Option[AssertionExtensionInputs]] timeout <- Gen.option(Gen.posNum[Long]) + hints <- + arbitrary[Option[Either[List[String], List[PublicKeyCredentialHint]]]] usernameOrUserHandle <- arbitrary[Option[Either[String, ByteArray]]] userVerification <- arbitrary[Option[UserVerificationRequirement]] } yield { val b = StartAssertionOptions.builder() extensions.foreach(b.extensions) timeout.foreach(b.timeout) + hints.foreach { + case Left(h) => { + b.hints(h.asJava) + } + case Right(h) => { + b.hints(h: _*) + } + } usernameOrUserHandle.foreach { case Left(username) => b.username(username) case Right(userHandle) => b.userHandle(userHandle) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala index 07b0b7301..5c1755947 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala @@ -27,7 +27,9 @@ package com.yubico.webauthn import com.fasterxml.jackson.core.`type`.TypeReference import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.node.TextNode import com.upokecenter.cbor.CBORObject +import com.yubico.internal.util.BinaryUtil import com.yubico.internal.util.JacksonCodecs import com.yubico.webauthn.data.AssertionExtensionInputs import com.yubico.webauthn.data.AuthenticatorAssertionResponse @@ -38,6 +40,7 @@ import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.CollectedClientData import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationInput +import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredential @@ -45,7 +48,6 @@ import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialDescriptor import com.yubico.webauthn.data.PublicKeyCredentialParameters import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions -import com.yubico.webauthn.data.ReexportHelpers import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity import com.yubico.webauthn.data.UserVerificationRequirement @@ -179,6 +181,7 @@ class RelyingPartyAssertionSpec credentialId: ByteArray = Defaults.credentialId, credentialKey: KeyPair = Defaults.credentialKey, credentialRepository: Option[CredentialRepository] = None, + isSecurePaymentConfirmation: Option[Boolean] = None, origins: Option[Set[String]] = None, requestedExtensions: AssertionExtensionInputs = Defaults.requestedExtensions, @@ -277,9 +280,19 @@ class RelyingPartyAssertionSpec origins.map(_.asJava).foreach(builder.origins _) + val fao = FinishAssertionOptions + .builder() + .request(request) + .response(response) + .callerTokenBindingId(callerTokenBindingId.toJava) + + isSecurePaymentConfirmation foreach { isSpc => + fao.isSecurePaymentConfirmation(isSpc) + } + builder .build() - ._finishAssertion(request, response, callerTokenBindingId.toJava) + ._finishAssertion(fao.build()) } testWithEachProvider { it => @@ -935,14 +948,18 @@ class RelyingPartyAssertionSpec step.validations shouldBe a[Success[_]] } - def assertFails(typeString: String): Unit = { + def assertFails( + typeString: String, + isSecurePaymentConfirmation: Option[Boolean] = None, + ): Unit = { val steps = finishAssertion( clientDataJson = JacksonCodecs.json.writeValueAsString( JacksonCodecs.json .readTree(Defaults.clientDataJson) .asInstanceOf[ObjectNode] .set("type", jsonFactory.textNode(typeString)) - ) + ), + isSecurePaymentConfirmation = isSecurePaymentConfirmation, ) val step: FinishAssertionSteps#Step11 = steps.begin.next.next.next.next.next @@ -967,6 +984,72 @@ class RelyingPartyAssertionSpec it("""The string "webauthn.create" fails.""") { assertFails("webauthn.create") } + + it("""The string "payment.get" fails.""") { + assertFails("payment.get") + } + + describe("If the isSecurePaymentConfirmation option is set,") { + it("the default test case fails.") { + val steps = + finishAssertion(isSecurePaymentConfirmation = Some(true)) + val step: FinishAssertionSteps#Step11 = + steps.begin.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + } + + it("""the default test case succeeds if type is overwritten with the value "payment.get".""") { + val json = JacksonCodecs.json() + val steps = finishAssertion( + isSecurePaymentConfirmation = Some(true), + clientDataJson = json.writeValueAsString( + json + .readTree(Defaults.clientDataJson) + .asInstanceOf[ObjectNode] + .set[ObjectNode]("type", new TextNode("payment.get")) + ), + ) + val step: FinishAssertionSteps#Step11 = + steps.begin.next.next.next.next.next + + step.validations shouldBe a[Success[_]] + } + + it("""any value other than "payment.get" fails.""") { + forAll { (typeString: String) => + whenever(typeString != "payment.get") { + assertFails( + typeString, + isSecurePaymentConfirmation = Some(true), + ) + } + } + forAll(Gen.alphaNumStr) { (typeString: String) => + whenever(typeString != "payment.get") { + assertFails( + typeString, + isSecurePaymentConfirmation = Some(true), + ) + } + } + } + + it("""the string "webauthn.create" fails.""") { + assertFails( + "webauthn.create", + isSecurePaymentConfirmation = Some(true), + ) + } + + it("""the string "webauthn.get" fails.""") { + assertFails( + "webauthn.get", + isSecurePaymentConfirmation = Some(true), + ) + } + } } it("12. Verify that the value of C.challenge equals the base64url encoding of options.challenge.") { @@ -1428,12 +1511,15 @@ class RelyingPartyAssertionSpec } { - def checks[Next <: FinishAssertionSteps.Step[ - _ - ], Step <: FinishAssertionSteps.Step[Next]]( + def checks[ + Next <: FinishAssertionSteps.Step[_], + Step <: FinishAssertionSteps.Step[Next], + ]( stepsToStep: FinishAssertionSteps => Step ) = { - def check[Ret](stepsToStep: FinishAssertionSteps => Step)( + def check[Ret]( + stepsToStep: FinishAssertionSteps => Step + )( chk: Step => Ret )(uvr: UserVerificationRequirement, authData: ByteArray): Ret = { val steps = finishAssertion( @@ -1585,7 +1671,9 @@ class RelyingPartyAssertionSpec .builder() .credentialId(Defaults.credentialId) .userHandle(Defaults.userHandle) - .publicKeyCose(getPublicKeyBytes(Defaults.credentialKey)) + .publicKeyCose( + getPublicKeyBytes(Defaults.credentialKey) + ) .backupEligible(false) .backupState(false) .build(), @@ -2310,13 +2398,14 @@ class RelyingPartyAssertionSpec it("a U2F-formatted public key.") { val testData = RealExamples.YubiKeyNeo.asRegistrationTestData - val x = ByteArray.fromHex( + val x = BinaryUtil.fromHex( "39C94FBBDDC694A925E6F8657C66916CFE84CD0222EDFCF281B21F5CDC347923" ) - val y = ByteArray.fromHex( + val y = BinaryUtil.fromHex( "D6B0D2021CFE1724A6FE81E3568C4FFAE339298216A30AFC18C0B975F2E2A891" ) - val u2fPubkey = ByteArray.fromHex("04").concat(x).concat(y) + val u2fPubkey = + new ByteArray(BinaryUtil.concat(BinaryUtil.fromHex("04"), x, y)) val cred1 = RegisteredCredential .builder() @@ -2409,8 +2498,7 @@ class RelyingPartyAssertionSpec ClientAssertionExtensionOutputs .builder() .largeBlob( - ReexportHelpers - .newLargeBlobAuthenticationOutput(None, Some(true)) + LargeBlobAuthenticationOutput.write(true) ) .build() ) @@ -2451,10 +2539,8 @@ class RelyingPartyAssertionSpec ClientAssertionExtensionOutputs .builder() .largeBlob( - ReexportHelpers.newLargeBlobAuthenticationOutput( - Some(ByteArray.fromHex("00010203")), - None, - ) + LargeBlobAuthenticationOutput + .read(ByteArray.fromHex("00010203")) ) .build() ) 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 a22647995..297ec21f0 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 @@ -52,14 +52,14 @@ import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.COSEAlgorithmIdentifier import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.CollectedClientData +import com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationInput.LargeBlobSupport +import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationOutput import com.yubico.webauthn.data.Extensions.Uvm.UvmEntry import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialParameters -import com.yubico.webauthn.data.ReexportHelpers -import com.yubico.webauthn.data.ReexportHelpers.newCredentialPropertiesOutput import com.yubico.webauthn.data.RegistrationExtensionInputs import com.yubico.webauthn.data.RelyingPartyIdentity import com.yubico.webauthn.data.UserIdentity @@ -175,17 +175,22 @@ class RelyingPartyRegistrationSpec origins.map(_.asJava).foreach(builder.origins _) - builder - .build() - ._finishRegistration( + val fro = FinishRegistrationOptions + .builder() + .request( pubkeyCredParams .map(pkcp => testData.request.toBuilder.pubKeyCredParams(pkcp.asJava).build() ) - .getOrElse(testData.request), - testData.response, - callerTokenBindingId.toJava, + .getOrElse(testData.request) ) + .response(testData.response) + .callerTokenBindingId(callerTokenBindingId.toJava) + .build() + + builder + .build() + ._finishRegistration(fro) } val emptyTrustSource = new AttestationTrustSource { @@ -261,7 +266,6 @@ class RelyingPartyRegistrationSpec "org.example.foo": "bar", "credProps": { "rk": false, - "authenticatorDisplayName": "My passkey", "unknownProperty": ["unknown-value"] } } @@ -1740,18 +1744,15 @@ class RelyingPartyRegistrationSpec key, COSEAlgorithmIdentifier.RS256, ) - new ByteArray( + BinaryUtil.concat( java.util.Arrays.copyOfRange( authDataBytes, 0, 32 + 1 + 4 + 16 + 2, - ) + ), + authData.getAttestedCredentialData.get.getCredentialId.getBytes, + reencodedKey.getBytes, ) - .concat( - authData.getAttestedCredentialData.get.getCredentialId - ) - .concat(reencodedKey) - .getBytes } def modifyAttobjPubkeyAlg(attObjBytes: ByteArray) @@ -2050,13 +2051,6 @@ class RelyingPartyRegistrationSpec IllegalArgumentException ] - val goodResult = Try( - verifier.verifyX5cRequirements(badCert, testDataBase.aaguid) - ) - - goodResult shouldBe a[Failure[_]] - goodResult.failed.get shouldBe an[IllegalArgumentException] - verifier.verifyX5cRequirements( testDataBase.packedAttestationCert, testDataBase.aaguid, @@ -4236,7 +4230,7 @@ class RelyingPartyRegistrationSpec ClientRegistrationExtensionOutputs .builder() .credProps( - newCredentialPropertiesOutput(true) + CredentialPropertiesOutput.builder().rk(true).build() ) .build() ) @@ -4259,7 +4253,7 @@ class RelyingPartyRegistrationSpec ClientRegistrationExtensionOutputs .builder() .credProps( - newCredentialPropertiesOutput(false) + CredentialPropertiesOutput.builder().rk(false).build() ) .build() ) @@ -4306,7 +4300,7 @@ class RelyingPartyRegistrationSpec ClientRegistrationExtensionOutputs .builder() .largeBlob( - ReexportHelpers.newLargeBlobRegistrationOutput(true) + LargeBlobRegistrationOutput.supported(true) ) .build() ) 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 7b491b189..a56688eb7 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 @@ -36,6 +36,7 @@ import com.yubico.webauthn.data.Generators.Extensions.registrationExtensionInput import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions import com.yubico.webauthn.data.PublicKeyCredentialDescriptor +import com.yubico.webauthn.data.PublicKeyCredentialHint import com.yubico.webauthn.data.PublicKeyCredentialParameters import com.yubico.webauthn.data.RegistrationExtensionInputs import com.yubico.webauthn.data.RelyingPartyIdentity @@ -87,26 +88,6 @@ class RelyingPartyStartOperationSpec ): java.util.Set[RegisteredCredential] = ??? } - def relyingParty( - appId: Option[AppId] = None, - attestationConveyancePreference: Option[AttestationConveyancePreference] = - None, - credentials: Set[PublicKeyCredentialDescriptor] = Set.empty, - userId: UserIdentity, - ): RelyingParty = { - var builder = RelyingParty - .builder() - .identity(rpId) - .credentialRepository(credRepo(credentials, userId)) - .preferredPubkeyParams(List(PublicKeyCredentialParameters.ES256).asJava) - .origins(Set.empty.asJava) - appId.foreach { appid => builder = builder.appId(appid) } - attestationConveyancePreference.foreach { acp => - builder = builder.attestationConveyancePreference(acp) - } - builder.build() - } - val rpId = RelyingPartyIdentity .builder() .id("localhost") @@ -120,212 +101,271 @@ class RelyingPartyStartOperationSpec .id(new ByteArray(Array(0, 1, 2, 3))) .build() - describe("RelyingParty.startRegistration") { - - it("sets excludeCredentials automatically.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials, userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .build() - ) - - result.getExcludeCredentials.toScala.map(_.asScala) should equal( - Some(credentials) - ) + describe("RelyingParty") { + def relyingParty( + appId: Option[AppId] = None, + attestationConveyancePreference: Option[ + AttestationConveyancePreference + ] = None, + credentials: Set[PublicKeyCredentialDescriptor] = Set.empty, + userId: UserIdentity, + ): RelyingParty = { + var builder = RelyingParty + .builder() + .identity(rpId) + .credentialRepository(credRepo(credentials, userId)) + .preferredPubkeyParams(List(PublicKeyCredentialParameters.ES256).asJava) + .origins(Set.empty.asJava) + appId.foreach { appid => builder = builder.appId(appid) } + attestationConveyancePreference.foreach { acp => + builder = builder.attestationConveyancePreference(acp) } + builder.build() } - it("sets challenge randomly.") { - val rp = relyingParty(userId = userId) + describe("startRegistration") { - val request1 = rp.startRegistration( - StartRegistrationOptions.builder().user(userId).build() - ) - val request2 = rp.startRegistration( - StartRegistrationOptions.builder().user(userId).build() - ) + it("sets excludeCredentials automatically.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) - request1.getChallenge should not equal request2.getChallenge - request1.getChallenge.size should be >= 32 - request2.getChallenge.size should be >= 32 - } + result.getExcludeCredentials.toScala.map(_.asScala) should equal( + Some(credentials) + ) + } + } - it("allows setting authenticatorSelection.") { - val authnrSel = AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) - .residentKey(ResidentKeyRequirement.REQUIRED) - .build() + it("sets challenge randomly.") { + val rp = relyingParty(userId = userId) - val pkcco = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection(authnrSel) - .build() - ) - pkcco.getAuthenticatorSelection.toScala should equal(Some(authnrSel)) - } + val request1 = rp.startRegistration( + StartRegistrationOptions.builder().user(userId).build() + ) + val request2 = rp.startRegistration( + StartRegistrationOptions.builder().user(userId).build() + ) - it("allows setting authenticatorSelection with an Optional value.") { - val authnrSel = AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) - .residentKey(ResidentKeyRequirement.REQUIRED) - .build() + request1.getChallenge should not equal request2.getChallenge + request1.getChallenge.size should be >= 32 + request2.getChallenge.size should be >= 32 + } - val pkccoWith = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection(Optional.of(authnrSel)) - .build() - ) - val pkccoWithout = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions + it("allows setting authenticatorSelection.") { + val authnrSel = AuthenticatorSelectionCriteria .builder() - .user(userId) - .authenticatorSelection( - Optional.empty[AuthenticatorSelectionCriteria] - ) + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .residentKey(ResidentKeyRequirement.REQUIRED) .build() - ) - pkccoWith.getAuthenticatorSelection.toScala should equal(Some(authnrSel)) - pkccoWithout.getAuthenticatorSelection.toScala should equal(None) - } - it("uses the RelyingParty setting for attestationConveyancePreference.") { - forAll { acp: Option[AttestationConveyancePreference] => - val pkcco = - relyingParty(attestationConveyancePreference = acp, userId = userId) - .startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .build() - ) - pkcco.getAttestation should equal( - acp getOrElse AttestationConveyancePreference.NONE + val pkcco = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection(authnrSel) + .build() ) + pkcco.getAuthenticatorSelection.toScala should equal(Some(authnrSel)) } - } - it("allows setting the timeout to empty.") { - val pkcco = relyingParty(userId = userId).startRegistration( - StartRegistrationOptions + it("allows setting authenticatorSelection with an Optional value.") { + val authnrSel = AuthenticatorSelectionCriteria .builder() - .user(userId) - .timeout(Optional.empty[java.lang.Long]) + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .residentKey(ResidentKeyRequirement.REQUIRED) .build() - ) - pkcco.getTimeout.toScala shouldBe empty - } - - it("allows setting the timeout to a positive value.") { - val rp = relyingParty(userId = userId) - forAll(Gen.posNum[Long]) { timeout: Long => - val pkcco = rp.startRegistration( + val pkccoWith = relyingParty(userId = userId).startRegistration( StartRegistrationOptions .builder() .user(userId) - .timeout(timeout) + .authenticatorSelection(Optional.of(authnrSel)) .build() ) - - pkcco.getTimeout.toScala should equal(Some(timeout)) + val pkccoWithout = relyingParty(userId = userId).startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + Optional.empty[AuthenticatorSelectionCriteria] + ) + .build() + ) + pkccoWith.getAuthenticatorSelection.toScala should equal( + Some(authnrSel) + ) + pkccoWithout.getAuthenticatorSelection.toScala should equal(None) } - } - it("does not allow setting the timeout to zero or negative.") { - an[IllegalArgumentException] should be thrownBy { - StartRegistrationOptions - .builder() - .user(userId) - .timeout(0) + it("uses the RelyingParty setting for attestationConveyancePreference.") { + forAll { acp: Option[AttestationConveyancePreference] => + val pkcco = + relyingParty(attestationConveyancePreference = acp, userId = userId) + .startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + pkcco.getAttestation should equal( + acp getOrElse AttestationConveyancePreference.NONE + ) + } } - an[IllegalArgumentException] should be thrownBy { - StartRegistrationOptions - .builder() - .user(userId) - .timeout(Optional.of[java.lang.Long](0L)) + describe("allows setting the hints") { + val rp = relyingParty(userId = userId) + + it("to string values in the spec or not.") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints("hej", "security-key", "hoj", "client-device", "hybrid") + .build() + ) + pkcco.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + PublicKeyCredentialHint.HYBRID.getValue, + ) + ) + } + + it("to PublicKeyCredentialHint values in the spec or not.") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .hints( + PublicKeyCredentialHint.of("hej"), + PublicKeyCredentialHint.HYBRID, + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.of("hoj"), + PublicKeyCredentialHint.CLIENT_DEVICE, + ) + .build() + ) + pkcco.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.HYBRID.getValue, + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + ) + ) + } + + it("or not, defaulting to the empty list.") { + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) + pkcco.getHints.asScala should equal(List()) + } } - forAll(Gen.negNum[Long]) { timeout: Long => - an[IllegalArgumentException] should be thrownBy { + it("allows setting the timeout to empty.") { + val pkcco = relyingParty(userId = userId).startRegistration( StartRegistrationOptions .builder() .user(userId) - .timeout(timeout) + .timeout(Optional.empty[java.lang.Long]) + .build() + ) + pkcco.getTimeout.toScala shouldBe empty + } + + it("allows setting the timeout to a positive value.") { + val rp = relyingParty(userId = userId) + + forAll(Gen.posNum[Long]) { timeout: Long => + val pkcco = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .timeout(timeout) + .build() + ) + + pkcco.getTimeout.toScala should equal(Some(timeout)) } + } + it("does not allow setting the timeout to zero or negative.") { an[IllegalArgumentException] should be thrownBy { StartRegistrationOptions .builder() .user(userId) - .timeout(Optional.of[java.lang.Long](timeout)) + .timeout(0) } - } - } - it( - "sets the appidExclude extension if the RP instance is given an AppId." - ) { - forAll { appId: AppId => - val rp = relyingParty(appId = Some(appId), userId = userId) - val result = rp.startRegistration( + an[IllegalArgumentException] should be thrownBy { StartRegistrationOptions .builder() .user(userId) - .build() - ) + .timeout(Optional.of[java.lang.Long](0L)) + } - result.getExtensions.getAppidExclude.toScala should equal(Some(appId)) + forAll(Gen.negNum[Long]) { timeout: Long => + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(timeout) + } + + an[IllegalArgumentException] should be thrownBy { + StartRegistrationOptions + .builder() + .user(userId) + .timeout(Optional.of[java.lang.Long](timeout)) + } + } } - } - it("does not set the appidExclude extension if the RP instance is not given an AppId.") { - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .build() - ) + it( + "sets the appidExclude extension if the RP instance is given an AppId." + ) { + forAll { appId: AppId => + val rp = relyingParty(appId = Some(appId), userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) - result.getExtensions.getAppidExclude.toScala should equal(None) - } + result.getExtensions.getAppidExclude.toScala should equal(Some(appId)) + } + } - it("does not override the appidExclude extension with an empty value if already non-null in StartRegistrationOptions.") { - forAll { requestAppId: AppId => - val rp = relyingParty(appId = None, userId = userId) + it("does not set the appidExclude extension if the RP instance is not given an AppId.") { + val rp = relyingParty(userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) - .extensions( - RegistrationExtensionInputs - .builder() - .appidExclude(requestAppId) - .build() - ) .build() ) - result.getExtensions.getAppidExclude.toScala should equal( - Some(requestAppId) - ) + result.getExtensions.getAppidExclude.toScala should equal(None) } - } - it("does not override the appidExclude extension if already non-null in StartRegistrationOptions.") { - forAll { (requestAppId: AppId, rpAppId: AppId) => - whenever(requestAppId != rpAppId) { - val rp = relyingParty(appId = Some(rpAppId), userId = userId) + it("does not override the appidExclude extension with an empty value if already non-null in StartRegistrationOptions.") { + forAll { requestAppId: AppId => + val rp = relyingParty(appId = None, userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() @@ -344,305 +384,245 @@ class RelyingPartyStartOperationSpec ) } } - } - it("by default sets the credProps extension.") { - forAll(registrationExtensionInputs(credPropsGen = None)) { - extensions: RegistrationExtensionInputs => - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .extensions(extensions) - .build() - ) + it("does not override the appidExclude extension if already non-null in StartRegistrationOptions.") { + forAll { (requestAppId: AppId, rpAppId: AppId) => + whenever(requestAppId != rpAppId) { + val rp = relyingParty(appId = Some(rpAppId), userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions( + RegistrationExtensionInputs + .builder() + .appidExclude(requestAppId) + .build() + ) + .build() + ) - result.getExtensions.getCredProps should be(true) + result.getExtensions.getAppidExclude.toScala should equal( + Some(requestAppId) + ) + } + } } - } - it("does not override the credProps extension if explicitly set to false in StartRegistrationOptions.") { - forAll(registrationExtensionInputs(credPropsGen = Some(false))) { - extensions: RegistrationExtensionInputs => - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .extensions(extensions) - .build() - ) + it("by default sets the credProps extension.") { + forAll(registrationExtensionInputs(credPropsGen = None)) { + extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions(extensions) + .build() + ) - result.getExtensions.getCredProps should be(false) + result.getExtensions.getCredProps should be(true) + } } - } - it("by default does not set the uvm extension.") { - val rp = relyingParty(userId = userId) - val result = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .build() - ) - result.getExtensions.getUvm should be(false) - } + it("does not override the credProps extension if explicitly set to false in StartRegistrationOptions.") { + forAll(registrationExtensionInputs(credPropsGen = Some(false))) { + extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .extensions(extensions) + .build() + ) + + result.getExtensions.getCredProps should be(false) + } + } - it("sets the uvm extension if enabled in StartRegistrationOptions.") { - forAll { extensions: RegistrationExtensionInputs => + it("by default does not set the uvm extension.") { val rp = relyingParty(userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() .user(userId) - .extensions(extensions.toBuilder.uvm().build()) .build() ) - - result.getExtensions.getUvm should be(true) + result.getExtensions.getUvm should be(false) } - } - - it("respects the residentKey setting.") { - val rp = relyingParty(userId = userId) - val pkccoDiscouraged = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria + it("sets the uvm extension if enabled in StartRegistrationOptions.") { + forAll { extensions: RegistrationExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startRegistration( + StartRegistrationOptions .builder() - .residentKey(ResidentKeyRequirement.DISCOURAGED) + .user(userId) + .extensions(extensions.toBuilder.uvm().build()) .build() ) - .build() - ) - val pkccoPreferred = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria - .builder() - .residentKey(ResidentKeyRequirement.PREFERRED) - .build() - ) - .build() - ) + result.getExtensions.getUvm should be(true) + } + } - val pkccoRequired = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria - .builder() - .residentKey(ResidentKeyRequirement.REQUIRED) - .build() - ) - .build() - ) + it("respects the residentKey setting.") { + val rp = relyingParty(userId = userId) - val pkccoUnspecified = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria.builder().build() - ) - .build() - ) - - def jsonRequireResidentKey( - pkcco: PublicKeyCredentialCreationOptions - ): Option[Boolean] = - Option( - JacksonCodecs - .json() - .readTree(pkcco.toCredentialsCreateJson) - .get("publicKey") - .get("authenticatorSelection") - .get("requireResidentKey") - ).map(_.booleanValue) - - pkccoDiscouraged.getAuthenticatorSelection.get.getResidentKey.toScala should be( - Some(ResidentKeyRequirement.DISCOURAGED) - ) - jsonRequireResidentKey(pkccoDiscouraged) should be(Some(false)) - - pkccoPreferred.getAuthenticatorSelection.get.getResidentKey.toScala should be( - Some(ResidentKeyRequirement.PREFERRED) - ) - jsonRequireResidentKey(pkccoPreferred) should be(Some(false)) - - pkccoRequired.getAuthenticatorSelection.get.getResidentKey.toScala should be( - Some(ResidentKeyRequirement.REQUIRED) - ) - jsonRequireResidentKey(pkccoRequired) should be(Some(true)) - - pkccoUnspecified.getAuthenticatorSelection.get.getResidentKey.toScala should be( - None - ) - jsonRequireResidentKey(pkccoUnspecified) should be(None) - } + val pkccoDiscouraged = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.DISCOURAGED) + .build() + ) + .build() + ) - it("respects the authenticatorAttachment parameter.") { - val rp = relyingParty(userId = userId) + val pkccoPreferred = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.PREFERRED) + .build() + ) + .build() + ) - val pkcco = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) - .build() - ) - .build() - ) - val pkccoWith = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment( - Optional.of(AuthenticatorAttachment.PLATFORM) - ) - .build() - ) - .build() - ) - val pkccoWithout = rp.startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .authenticatorSelection( - AuthenticatorSelectionCriteria - .builder() - .authenticatorAttachment(Optional.empty[AuthenticatorAttachment]) - .build() - ) - .build() - ) - - pkcco.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( - Some(AuthenticatorAttachment.CROSS_PLATFORM) - ) - pkccoWith.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( - Some(AuthenticatorAttachment.PLATFORM) - ) - pkccoWithout.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( - None - ) - } - } + val pkccoRequired = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.REQUIRED) + .build() + ) + .build() + ) - describe("RelyingParty.startAssertion") { + val pkccoUnspecified = rp.startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria.builder().build() + ) + .build() + ) - it("sets allowCredentials to empty if not given a username nor a user handle.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials, userId = userId) - val result = rp.startAssertion(StartAssertionOptions.builder().build()) + def jsonRequireResidentKey( + pkcco: PublicKeyCredentialCreationOptions + ): Option[Boolean] = + Option( + JacksonCodecs + .json() + .readTree(pkcco.toCredentialsCreateJson) + .get("publicKey") + .get("authenticatorSelection") + .get("requireResidentKey") + ).map(_.booleanValue) + + pkccoDiscouraged.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.DISCOURAGED) + ) + jsonRequireResidentKey(pkccoDiscouraged) should be(Some(false)) - result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala shouldBe empty - } - } + pkccoPreferred.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.PREFERRED) + ) + jsonRequireResidentKey(pkccoPreferred) should be(Some(false)) - it("sets allowCredentials automatically if given a username.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials, userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .username(userId.getName) - .build() + pkccoRequired.getAuthenticatorSelection.get.getResidentKey.toScala should be( + Some(ResidentKeyRequirement.REQUIRED) ) + jsonRequireResidentKey(pkccoRequired) should be(Some(true)) - result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala - .map(_.asScala.toSet) should equal(Some(credentials)) + pkccoUnspecified.getAuthenticatorSelection.get.getResidentKey.toScala should be( + None + ) + jsonRequireResidentKey(pkccoUnspecified) should be(None) } - } - it("sets allowCredentials automatically if given a user handle.") { - forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials, userId = userId) - val result = rp.startAssertion( - StartAssertionOptions + it("respects the authenticatorAttachment parameter.") { + val rp = relyingParty(userId = userId) + + val pkcco = rp.startRegistration( + StartRegistrationOptions .builder() - .userHandle(userId.getId) + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM) + .build() + ) .build() ) - - result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala - .map(_.asScala.toSet) should equal(Some(credentials)) - } - } - - it("passes username through to AssertionRequest.") { - forAll { username: String => - val testCaseUserId = userId.toBuilder.name(username).build() - val rp = relyingParty(userId = testCaseUserId) - val result = rp.startAssertion( - StartAssertionOptions + val pkccoWith = rp.startRegistration( + StartRegistrationOptions .builder() - .username(testCaseUserId.getName) + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment( + Optional.of(AuthenticatorAttachment.PLATFORM) + ) + .build() + ) .build() ) - result.getUsername.asScala should equal(Some(testCaseUserId.getName)) - } - } - - it("passes user handle through to AssertionRequest.") { - forAll { userHandle: ByteArray => - val testCaseUserId = userId.toBuilder.id(userHandle).build() - val rp = relyingParty(userId = testCaseUserId) - val result = rp.startAssertion( - StartAssertionOptions + val pkccoWithout = rp.startRegistration( + StartRegistrationOptions .builder() - .userHandle(testCaseUserId.getId) + .user(userId) + .authenticatorSelection( + AuthenticatorSelectionCriteria + .builder() + .authenticatorAttachment( + Optional.empty[AuthenticatorAttachment] + ) + .build() + ) .build() ) - result.getUserHandle.asScala should equal(Some(testCaseUserId.getId)) + + pkcco.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + Some(AuthenticatorAttachment.CROSS_PLATFORM) + ) + pkccoWith.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + Some(AuthenticatorAttachment.PLATFORM) + ) + pkccoWithout.getAuthenticatorSelection.get.getAuthenticatorAttachment.toScala should be( + None + ) } } - it("includes transports in allowCredentials when available.") { - forAll( - Gen.nonEmptyContainerOf[Set, AuthenticatorTransport]( - arbitrary[AuthenticatorTransport] - ), - arbitrary[PublicKeyCredentialDescriptor], - arbitrary[PublicKeyCredentialDescriptor], - arbitrary[PublicKeyCredentialDescriptor], - ) { - ( - cred1Transports: Set[AuthenticatorTransport], - cred1: PublicKeyCredentialDescriptor, - cred2: PublicKeyCredentialDescriptor, - cred3: PublicKeyCredentialDescriptor, - ) => - val rp = relyingParty( - credentials = Set( - cred1.toBuilder.transports(cred1Transports.asJava).build(), - cred2.toBuilder - .transports( - Optional.of(Set.empty[AuthenticatorTransport].asJava) - ) - .build(), - cred3.toBuilder - .transports( - Optional.empty[java.util.Set[AuthenticatorTransport]] - ) - .build(), - ), - userId = userId, - ) + describe("startAssertion") { + + it("sets allowCredentials to empty if not given a username nor a user handle.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = + rp.startAssertion(StartAssertionOptions.builder().build()) + + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala shouldBe empty + } + } + + it("sets allowCredentials automatically if given a username.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() @@ -650,85 +630,150 @@ class RelyingPartyStartOperationSpec .build() ) - val requestCreds = - result.getPublicKeyCredentialRequestOptions.getAllowCredentials.get.asScala - requestCreds.head.getTransports.toScala should equal( - Some(cred1Transports.asJava) + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala + .map(_.asScala.toSet) should equal(Some(credentials)) + } + } + + it("sets allowCredentials automatically if given a user handle.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .userHandle(userId.getId) + .build() ) - requestCreds(1).getTransports.toScala should equal( - Some(Set.empty.asJava) + + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.toScala + .map(_.asScala.toSet) should equal(Some(credentials)) + } + } + + it("passes username through to AssertionRequest.") { + forAll { username: String => + val testCaseUserId = userId.toBuilder.name(username).build() + val rp = relyingParty(userId = testCaseUserId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(testCaseUserId.getName) + .build() ) - requestCreds(2).getTransports.toScala should equal(None) + result.getUsername.asScala should equal(Some(testCaseUserId.getName)) + } + } + + it("passes user handle through to AssertionRequest.") { + forAll { userHandle: ByteArray => + val testCaseUserId = userId.toBuilder.id(userHandle).build() + val rp = relyingParty(userId = testCaseUserId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .userHandle(testCaseUserId.getId) + .build() + ) + result.getUserHandle.asScala should equal(Some(testCaseUserId.getId)) + } } - } - it("sets challenge randomly.") { - val rp = relyingParty(userId = userId) + it("includes transports in allowCredentials when available.") { + forAll( + Gen.nonEmptyContainerOf[Set, AuthenticatorTransport]( + arbitrary[AuthenticatorTransport] + ), + arbitrary[PublicKeyCredentialDescriptor], + arbitrary[PublicKeyCredentialDescriptor], + arbitrary[PublicKeyCredentialDescriptor], + ) { + ( + cred1Transports: Set[AuthenticatorTransport], + cred1: PublicKeyCredentialDescriptor, + cred2: PublicKeyCredentialDescriptor, + cred3: PublicKeyCredentialDescriptor, + ) => + val rp = relyingParty( + credentials = Set( + cred1.toBuilder.transports(cred1Transports.asJava).build(), + cred2.toBuilder + .transports( + Optional.of(Set.empty[AuthenticatorTransport].asJava) + ) + .build(), + cred3.toBuilder + .transports( + Optional.empty[java.util.Set[AuthenticatorTransport]] + ) + .build(), + ), + userId = userId, + ) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .build() + ) - val request1 = rp.startAssertion(StartAssertionOptions.builder().build()) - val request2 = rp.startAssertion(StartAssertionOptions.builder().build()) + val requestCreds = + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.get.asScala + requestCreds.head.getTransports.toScala should equal( + Some(cred1Transports.asJava) + ) + requestCreds(1).getTransports.toScala should equal( + Some(Set.empty.asJava) + ) + requestCreds(2).getTransports.toScala should equal(None) + } + } - request1.getPublicKeyCredentialRequestOptions.getChallenge should not equal request2.getPublicKeyCredentialRequestOptions.getChallenge - request1.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 - request2.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 - } + it("sets challenge randomly.") { + val rp = relyingParty(userId = userId) - it("sets the appid extension if the RP instance is given an AppId.") { - forAll { appId: AppId => - val rp = relyingParty(appId = Some(appId), userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .username(userId.getName) - .build() - ) + val request1 = + rp.startAssertion(StartAssertionOptions.builder().build()) + val request2 = + rp.startAssertion(StartAssertionOptions.builder().build()) - result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( - Some(appId) - ) + request1.getPublicKeyCredentialRequestOptions.getChallenge should not equal request2.getPublicKeyCredentialRequestOptions.getChallenge + request1.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 + request2.getPublicKeyCredentialRequestOptions.getChallenge.size should be >= 32 } - } - it("does not set the appid extension if the RP instance is not given an AppId.") { - val rp = relyingParty(userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .username(userId.getName) - .build() - ) + it("sets the appid extension if the RP instance is given an AppId.") { + forAll { appId: AppId => + val rp = relyingParty(appId = Some(appId), userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .build() + ) - result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( - None - ) - } + result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( + Some(appId) + ) + } + } - it("does not override the appid extension with an empty value if already non-null in StartAssertionOptions.") { - forAll { requestAppId: AppId => - val rp = relyingParty(appId = None, userId = userId) + it("does not set the appid extension if the RP instance is not given an AppId.") { + val rp = relyingParty(userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() .username(userId.getName) - .extensions( - AssertionExtensionInputs - .builder() - .appid(requestAppId) - .build() - ) .build() ) result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( - Some(requestAppId) + None ) } - } - it("does not override the appid extension if already non-null in StartAssertionOptions.") { - forAll { (requestAppId: AppId, rpAppId: AppId) => - whenever(requestAppId != rpAppId) { - val rp = relyingParty(appId = Some(rpAppId), userId = userId) + it("does not override the appid extension with an empty value if already non-null in StartAssertionOptions.") { + forAll { requestAppId: AppId => + val rp = relyingParty(appId = None, userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() @@ -747,89 +792,166 @@ class RelyingPartyStartOperationSpec ) } } - } - it("allows setting the timeout to empty.") { - val req = relyingParty(userId = userId).startAssertion( - StartAssertionOptions - .builder() - .timeout(Optional.empty[java.lang.Long]) - .build() - ) - req.getPublicKeyCredentialRequestOptions.getTimeout.toScala shouldBe empty - } + it("does not override the appid extension if already non-null in StartAssertionOptions.") { + forAll { (requestAppId: AppId, rpAppId: AppId) => + whenever(requestAppId != rpAppId) { + val rp = relyingParty(appId = Some(rpAppId), userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .username(userId.getName) + .extensions( + AssertionExtensionInputs + .builder() + .appid(requestAppId) + .build() + ) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getExtensions.getAppid.toScala should equal( + Some(requestAppId) + ) + } + } + } + + describe("allows setting the hints") { + val rp = relyingParty(userId = userId) + + it("to string values in the spec or not.") { + val pkcro = rp.startAssertion( + StartAssertionOptions + .builder() + .hints("hej", "security-key", "hoj", "client-device", "hybrid") + .build() + ) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + PublicKeyCredentialHint.HYBRID.getValue, + ) + ) + } + + it("to PublicKeyCredentialHint values in the spec or not.") { + val pkcro = rp.startAssertion( + StartAssertionOptions + .builder() + .hints( + PublicKeyCredentialHint.of("hej"), + PublicKeyCredentialHint.HYBRID, + PublicKeyCredentialHint.SECURITY_KEY, + PublicKeyCredentialHint.of("hoj"), + PublicKeyCredentialHint.CLIENT_DEVICE, + ) + .build() + ) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List( + "hej", + PublicKeyCredentialHint.HYBRID.getValue, + PublicKeyCredentialHint.SECURITY_KEY.getValue, + "hoj", + PublicKeyCredentialHint.CLIENT_DEVICE.getValue, + ) + ) + } - it("allows setting the timeout to a positive value.") { - val rp = relyingParty(userId = userId) + it("or not, defaulting to the empty list.") { + val pkcro = rp.startAssertion(StartAssertionOptions.builder().build()) + pkcro.getPublicKeyCredentialRequestOptions.getHints.asScala should equal( + List() + ) + } + } - forAll(Gen.posNum[Long]) { timeout: Long => - val req = rp.startAssertion( + it("allows setting the timeout to empty.") { + val req = relyingParty(userId = userId).startAssertion( StartAssertionOptions .builder() - .timeout(timeout) + .timeout(Optional.empty[java.lang.Long]) .build() ) - - req.getPublicKeyCredentialRequestOptions.getTimeout.toScala should equal( - Some(timeout) - ) + req.getPublicKeyCredentialRequestOptions.getTimeout.toScala shouldBe empty } - } - it("does not allow setting the timeout to zero or negative.") { - an[IllegalArgumentException] should be thrownBy { - StartAssertionOptions - .builder() - .timeout(0) - } + it("allows setting the timeout to a positive value.") { + val rp = relyingParty(userId = userId) - an[IllegalArgumentException] should be thrownBy { - StartAssertionOptions - .builder() - .timeout(Optional.of[java.lang.Long](0L)) + forAll(Gen.posNum[Long]) { timeout: Long => + val req = rp.startAssertion( + StartAssertionOptions + .builder() + .timeout(timeout) + .build() + ) + + req.getPublicKeyCredentialRequestOptions.getTimeout.toScala should equal( + Some(timeout) + ) + } } - forAll(Gen.negNum[Long]) { timeout: Long => + it("does not allow setting the timeout to zero or negative.") { an[IllegalArgumentException] should be thrownBy { StartAssertionOptions .builder() - .timeout(timeout) + .timeout(0) } an[IllegalArgumentException] should be thrownBy { StartAssertionOptions .builder() - .timeout(Optional.of[java.lang.Long](timeout)) + .timeout(Optional.of[java.lang.Long](0L)) } - } - } - it("by default does not set the uvm extension.") { - val rp = relyingParty(userId = userId) - val result = rp.startAssertion( - StartAssertionOptions - .builder() - .build() - ) - result.getPublicKeyCredentialRequestOptions.getExtensions.getUvm should be( - false - ) - } + forAll(Gen.negNum[Long]) { timeout: Long => + an[IllegalArgumentException] should be thrownBy { + StartAssertionOptions + .builder() + .timeout(timeout) + } + + an[IllegalArgumentException] should be thrownBy { + StartAssertionOptions + .builder() + .timeout(Optional.of[java.lang.Long](timeout)) + } + } + } - it("sets the uvm extension if enabled in StartRegistrationOptions.") { - forAll { extensions: AssertionExtensionInputs => + it("by default does not set the uvm extension.") { val rp = relyingParty(userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() - .extensions(extensions.toBuilder.uvm().build()) .build() ) - result.getPublicKeyCredentialRequestOptions.getExtensions.getUvm should be( - true + false ) } + + it("sets the uvm extension if enabled in StartRegistrationOptions.") { + forAll { extensions: AssertionExtensionInputs => + val rp = relyingParty(userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .extensions(extensions.toBuilder.uvm().build()) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getExtensions.getUvm should be( + true + ) + } + } } } 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 034e2338d..2d53b1ffb 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 @@ -130,7 +130,7 @@ class RelyingPartyUserIdentificationSpec extends AnyFunSpec with Matchers { .build() } - describe("The assertion ceremony") { + describe("The assertion ceremony with RelyingParty") { val rp = RelyingParty .builder() @@ -234,17 +234,14 @@ class RelyingPartyUserIdentificationSpec extends AnyFunSpec with Matchers { userHandle = Some(Defaults.userHandle) ) - val result = Try( - rp.finishAssertion( - FinishAssertionOptions - .builder() - .request(deterministicRequest) - .response(response) - .build() - ) + val result = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(deterministicRequest) + .response(response) + .build() ) - - result shouldBe a[Success[_]] + result.isSuccess should be(true) } it("fails for the default test case if no username was given and no userHandle returned.") { @@ -272,5 +269,4 @@ class RelyingPartyUserIdentificationSpec extends AnyFunSpec with Matchers { } } - } 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 83f3b5606..5c5f115d2 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 @@ -930,26 +930,21 @@ object TestAuthenticator { 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 + BinaryUtil.concat( + BinaryUtil.encodeUint16(symmetric getOrElse 0x0010), + BinaryUtil.encodeUint16(scheme getOrElse TpmRsaScheme.RSASSA), + // key_bits + BinaryUtil.encodeUint16(RsaKeySizeBits), + // exponent + BinaryUtil.encodeUint32( + new BigInteger(1, cose.get(-2).GetByteString()).longValue() + ), + ), + BinaryUtil.concat( + BinaryUtil.encodeUint16(cose.get(-1).GetByteString().length), + // modulus + cose.get(-1).GetByteString(), + ), ) } case 2 => { // EC @@ -957,78 +952,70 @@ object TestAuthenticator { .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 - case COSEAlgorithmIdentifier.RS1 | - COSEAlgorithmIdentifier.RS256 | - COSEAlgorithmIdentifier.RS384 | - COSEAlgorithmIdentifier.RS512 | - COSEAlgorithmIdentifier.EdDSA => - ??? - })) - ) - .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)), + BinaryUtil.concat( + BinaryUtil.encodeUint16(symmetric getOrElse 0x0010), + BinaryUtil.encodeUint16(scheme getOrElse 0x0010), + BinaryUtil.encodeUint16(coseKeyAlg match { + case COSEAlgorithmIdentifier.ES256 => 0x0003 + case COSEAlgorithmIdentifier.ES384 => 0x0004 + case COSEAlgorithmIdentifier.ES512 => 0x0005 + case COSEAlgorithmIdentifier.RS1 | COSEAlgorithmIdentifier.RS256 | + COSEAlgorithmIdentifier.RS384 | + COSEAlgorithmIdentifier.RS512 | + COSEAlgorithmIdentifier.EdDSA => + ??? + }), + // kdf_scheme: ??? (unused?) + BinaryUtil.encodeUint16(0x0010), + ), + BinaryUtil.concat( + BinaryUtil.encodeUint16(pubkey.getW.getAffineX.toByteArray.length), + pubkey.getW.getAffineX.toByteArray, + BinaryUtil.encodeUint16( + pubkey.getW.getAffineY.toByteArray.length + ), + 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) - ) + val pubArea = new ByteArray( + BinaryUtil.concat( + BinaryUtil.encodeUint16(signAlg), + BinaryUtil.encodeUint16(hashId), + BinaryUtil.encodeUint32(attributes getOrElse Attributes.SIGN_ENCRYPT), + // authPolicy is ignored by TpmAttestationStatementVerifier + BinaryUtil.encodeUint16(0), + parameters, + unique, ) - .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 qualifiedSigner = BinaryUtil.fromHex("") + val clockInfo = BinaryUtil.fromHex("0000000000000000111111112222222233") + val firmwareVersion = BinaryUtil.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)) + val attestedQualifiedName = BinaryUtil.fromHex("") + + val certInfo = new ByteArray( + BinaryUtil.concat( + magic.getBytes, + `type`.getBytes, + BinaryUtil.encodeUint16(qualifiedSigner.length), + qualifiedSigner, + BinaryUtil.encodeUint16(extraData.size), + extraData.getBytes, + clockInfo, + firmwareVersion, + BinaryUtil.encodeUint16(attestedName.size), + attestedName.getBytes, + BinaryUtil.encodeUint16(attestedQualifiedName.length), + attestedQualifiedName, ) - .concat(attestedQualifiedName) + ) val sig = sign(certInfo, cert.key, cert.alg) 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 c1efa2775..a22d19e54 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 @@ -125,6 +125,6 @@ class WebAuthnCodecsSpec } } - } + } } 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 6ded9bce3..30080c42c 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 @@ -327,14 +327,14 @@ class ExtensionsSpec Set("largeBlob") ) registrationCred.getClientExtensionResults.getLargeBlob.toScala should equal( - Some(new LargeBlobRegistrationOutput(true)) + Some(LargeBlobRegistrationOutput.supported(true)) ) assertionCred.getClientExtensionResults.getExtensionIds.asScala should equal( Set("appid", "largeBlob") ) assertionCred.getClientExtensionResults.getLargeBlob.toScala should equal( - Some(new LargeBlobAuthenticationOutput(null, true)) + Some(LargeBlobAuthenticationOutput.write(true)) ) } @@ -347,7 +347,7 @@ class ExtensionsSpec Set("largeBlob") ) registrationCred.getClientExtensionResults.getLargeBlob.toScala should equal( - Some(new LargeBlobRegistrationOutput(true)) + Some(LargeBlobRegistrationOutput.supported(true)) ) assertionCred.getClientExtensionResults.getExtensionIds.asScala should equal( @@ -355,9 +355,8 @@ class ExtensionsSpec ) assertionCred.getClientExtensionResults.getLargeBlob.toScala should equal( Some( - new LargeBlobAuthenticationOutput( - new ByteArray("Hello, World!".getBytes(StandardCharsets.UTF_8)), - null, + LargeBlobAuthenticationOutput.read( + new ByteArray("Hello, World!".getBytes(StandardCharsets.UTF_8)) ) ) ) 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 a9609f9b7..787d90159 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 @@ -52,6 +52,7 @@ import com.yubico.webauthn.extension.uvm.Generators.userVerificationMethod import org.scalacheck.Arbitrary import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen +import org.scalacheck.Shrink import java.net.URL import java.security.interfaces.ECPublicKey @@ -349,6 +350,35 @@ object Generators { implicit val arbitraryByteArray: Arbitrary[ByteArray] = Arbitrary( arbitrary[Array[Byte]].map(new ByteArray(_)) ) + implicit val shrinkByteArray: Shrink[ByteArray] = Shrink({ b => + // Attempt to remove as much as possible at a time: first the back half, then the back 1/4, then the back 1/8, etc. + val prefixes = Stream.unfold(0) { len => + val nextLen = (len + b.size()) / 2 + if (nextLen == len || nextLen == b.size()) { + None + } else { + Some((new ByteArray(b.getBytes.slice(0, nextLen)), nextLen)) + } + } + + // Same but removing from the front instead. + val suffixes = Stream.unfold(0) { len => + val nextLen = (len + b.size()) / 2 + if (nextLen == len || nextLen == b.size()) { + None + } else { + Some( + ( + new ByteArray(b.getBytes.slice(b.size() - nextLen, b.size())), + nextLen, + ) + ) + } + } + + prefixes concat suffixes + }) + def byteArray(maxSize: Int): Gen[ByteArray] = Gen.listOfN(maxSize, arbitrary[Byte]).map(ba => new ByteArray(ba.toArray)) @@ -867,8 +897,12 @@ object Generators { object CredProps { def credentialPropertiesOutput: Gen[CredentialPropertiesOutput] = for { - rk <- arbitrary[Boolean] - } yield new CredentialPropertiesOutput(rk) + rk <- arbitrary[Option[Boolean]] + } yield { + val b = CredentialPropertiesOutput.builder() + rk.foreach(b.rk(_)) + b.build() + } } object LargeBlob { @@ -883,7 +917,7 @@ object Generators { def largeBlobRegistrationOutput: Gen[LargeBlobRegistrationOutput] = for { supported <- arbitrary[Boolean] - } yield new LargeBlobRegistrationOutput(supported) + } yield LargeBlobRegistrationOutput.supported(supported) def largeBlobAuthenticationInput: Gen[LargeBlobAuthenticationInput] = halfsized( @@ -898,8 +932,8 @@ object Generators { blob <- arbitrary[ByteArray] written <- arbitrary[Boolean] result <- Gen.oneOf( - new LargeBlobAuthenticationOutput(blob, null), - new LargeBlobAuthenticationOutput(null, written), + LargeBlobAuthenticationOutput.read(blob), + LargeBlobAuthenticationOutput.write(written), ) } yield result) } @@ -1065,19 +1099,32 @@ object Generators { arbitrary[java.util.List[PublicKeyCredentialParameters]] rp <- arbitrary[RelyingPartyIdentity] timeout <- arbitrary[Optional[java.lang.Long]] + hints <- + arbitrary[Option[Either[Either[List[String], Array[String]], List[ + PublicKeyCredentialHint + ]]]] user <- arbitrary[UserIdentity] - } yield PublicKeyCredentialCreationOptions - .builder() - .rp(rp) - .user(user) - .challenge(challenge) - .pubKeyCredParams(pubKeyCredParams) - .attestation(attestation) - .authenticatorSelection(authenticatorSelection) - .excludeCredentials(excludeCredentials) - .extensions(extensions) - .timeout(timeout) - .build() + } yield { + val b = PublicKeyCredentialCreationOptions + .builder() + .rp(rp) + .user(user) + .challenge(challenge) + .pubKeyCredParams(pubKeyCredParams) + .attestation(attestation) + .authenticatorSelection(authenticatorSelection) + .excludeCredentials(excludeCredentials) + .extensions(extensions) + .timeout(timeout) + + hints.foreach { + case Left(Left(h: List[String])) => b.hints(h.asJava) + case Left(Right(h: Array[String])) => b.hints(h: _*) + case Right(h: List[PublicKeyCredentialHint]) => b.hints(h: _*) + } + + b.build() + } ) ) @@ -1097,6 +1144,14 @@ object Generators { ) ) + implicit val arbitraryPublicKeyCredentialHint + : Arbitrary[PublicKeyCredentialHint] = Arbitrary( + Gen.oneOf( + Gen.oneOf(PublicKeyCredentialHint.values()), + arbitrary[String].map(PublicKeyCredentialHint.of), + ) + ) + implicit val arbitraryPublicKeyCredentialParameters : Arbitrary[PublicKeyCredentialParameters] = Arbitrary( halfsized( @@ -1121,16 +1176,29 @@ object Generators { extensions <- arbitrary[AssertionExtensionInputs] rpId <- arbitrary[Optional[String]] timeout <- arbitrary[Optional[java.lang.Long]] + hints <- + arbitrary[Option[Either[Either[List[String], Array[String]], List[ + PublicKeyCredentialHint + ]]]] userVerification <- arbitrary[UserVerificationRequirement] - } yield PublicKeyCredentialRequestOptions - .builder() - .challenge(challenge) - .allowCredentials(allowCredentials) - .extensions(extensions) - .rpId(rpId) - .timeout(timeout) - .userVerification(userVerification) - .build() + } yield { + val b = PublicKeyCredentialRequestOptions + .builder() + .challenge(challenge) + .allowCredentials(allowCredentials) + .extensions(extensions) + .rpId(rpId) + .timeout(timeout) + .userVerification(userVerification) + + hints.foreach { + case Left(Left(h: List[String])) => b.hints(h.asJava) + case Left(Right(h: Array[String])) => b.hints(h: _*) + case Right(h: List[PublicKeyCredentialHint]) => b.hints(h: _*) + } + + b.build() + } ) ) 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 3fe9a73c5..5b54714e6 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 @@ -72,7 +72,7 @@ class JsonIoSpec def test[A](tpe: TypeReference[A])(implicit a: Arbitrary[A]): Unit = { val cn = tpe.getType.getTypeName describe(s"${cn}") { - it("is identical after multiple serialization round-trips..") { + it("is identical after multiple serialization round-trips.") { forAll(minSuccessful(10)) { value: A => val encoded: String = json.writeValueAsString(value) diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala deleted file mode 100644 index ce67d8b72..000000000 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/ReexportHelpers.scala +++ /dev/null @@ -1,27 +0,0 @@ -package com.yubico.webauthn.data - -import com.yubico.webauthn.data.Extensions.CredentialProperties.CredentialPropertiesOutput -import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobAuthenticationOutput -import com.yubico.webauthn.data.Extensions.LargeBlob.LargeBlobRegistrationOutput - -/** Public re-exports of things in the com.yubico.webauthn.data package, so that - * tests can access them but dependent projects cannot (unless they do this - * same workaround hack). - */ -object ReexportHelpers { - - def newCredentialPropertiesOutput(rk: Boolean): CredentialPropertiesOutput = - new CredentialPropertiesOutput(rk) - - def newLargeBlobRegistrationOutput( - supported: Boolean - ): LargeBlobRegistrationOutput = new LargeBlobRegistrationOutput(supported) - def newLargeBlobAuthenticationOutput( - blob: Option[ByteArray], - written: Option[Boolean], - ): LargeBlobAuthenticationOutput = - new LargeBlobAuthenticationOutput( - blob.orNull, - written.map(java.lang.Boolean.valueOf).orNull, - ) -} diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala index 2a9395fe9..ddaeca89b 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/test/Helpers.scala @@ -13,6 +13,9 @@ import scala.jdk.OptionConverters.RichOption object Helpers { + def toJava(o: Option[scala.Boolean]): Optional[java.lang.Boolean] = + o.toJava.map((b: scala.Boolean) => b) + object CredentialRepository { val empty = new CredentialRepository { override def getCredentialIdsForUsername( diff --git a/webauthn-server-demo/README b/webauthn-server-demo/README index 69b0f7fac..2cb6b3a9b 100644 --- a/webauthn-server-demo/README +++ b/webauthn-server-demo/README @@ -1,4 +1,6 @@ = webauthn-server-demo +:idprefix: +:idseparator: - A simple self-contained demo server supporting multiple authenticators per user. It illustrates how to use the required integration points, the most important of @@ -7,9 +9,9 @@ one can perform auxiliary actions such as adding an additional authenticator or deregistering a credential. The central part is the -link:src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`] +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`] class, and the -link:src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`] +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`] class which provides the REST API on top of it. @@ -32,21 +34,21 @@ link:../webauthn-server-core/[`webauthn-server-core`] library: - The front end interacts with the server via a *REST API*, implemented in - link:src/main/java/demo/webauthn/WebAuthnRestResource.java[`WebAuthnRestResource`]. + link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/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 - link:src/main/java/demo/webauthn/WebAuthnServer.java[`WebAuthnServer`]. + link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/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 -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] integration point - as the -link: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`] 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. @@ -58,7 +60,7 @@ 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 link:../webauthn-server-core/[`webauthn-server-core`] library gets involved. The entry point into the library is the - link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] + link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/RelyingParty.html[`RelyingParty`] class. + This layer implements the Web Authentication @@ -69,16 +71,27 @@ and exposes integration points for storage of challenges and credentials. Some notable integration points are: + ** The library user must provide an implementation of the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.0/com/yubico/webauthn/CredentialRepository.html[`CredentialRepository`] interface to use for looking up stored public keys, user handles and signature counters. +The example app does this via the +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/demo/webauthn/InMemoryRegistrationStorage.java[`InMemoryRegistrationStorage`] +class. ** The library user can optionally provide an instance of the -link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.5.4/com/yubico/webauthn/attestation/AttestationTrustSource.html[`AttestationTrustSource`] +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-core/2.6.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 link:../webauthn-server-attestation/[`webauthn-server-attestation`] -sibling library provides implementations of this interface that are pre-seeded -with Yubico device metadata. +sibling library provides an implementation of this interface that integrates +with the https://fidoalliance.org/metadata/[FIDO Metadata Service]. ++ +For this the example app uses the +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/src/main/java/com/yubico/webauthn/attestation/YubicoJsonMetadataService.java[`YubicoJsonMetadataService`] +class, which reads attestation data from a bundled JSON file. If enabled by +configuration, this is also combined with the +link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] +implementation from the +link:../webauthn-server-attestation/[`webauthn-server-attestation`] module. == Usage @@ -103,7 +116,7 @@ To build it, run === Standalone Java executable The standalone Java executable has the main class -link:src/main/java/demo/webauthn/EmbeddedServer.java[`demo.webauthn.EmbeddedServer`]. +link:https://github.com/Yubico/java-webauthn-server/blob/main/webauthn-server-demo/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 `/`. @@ -141,12 +154,12 @@ correct environment. - `YUBICO_WEBAUTHN_PORT`: Port number to run the server on. Example: `YUBICO_WEBAUTHN_PORT=8081` - - This is ignored when running as a `.war` artifact, since the port is - controlled by the parent web server. ++ +This is ignored when running as a `.war` artifact, since the port is +controlled by the parent web server. - `YUBICO_WEBAUTHN_ALLOWED_ORIGINS`: Comma-separated list of origins the - server will accept requests for. Example: + server will accept requests from. Example: `YUBICO_WEBAUTHN_ALLOWED_ORIGINS=http://demo.yubico.com:8080` - `YUBICO_WEBAUTHN_RP_ID`: The https://www.w3.org/TR/webauthn/#rp-id[RP ID] @@ -154,11 +167,10 @@ correct environment. - `YUBICO_WEBAUTHN_RP_NAME`: The human-readable https://www.w3.org/TR/webauthn/#dom-publickeycredentialentity-name[RP name] - the server will report. Example: `YUBICO_WEBAUTHN_RP_ID='Yubico Web - Authentication demo'` + the server will report. Example: `YUBICO_WEBAUTHN_RP_NAME='Yubico Web Authentication demo'` - `YUBICO_WEBAUTHN_USE_FIDO_MDS`: If set to `true` (case-insensitive), use - https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.5.4/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] + https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server-attestation/2.6.0/com/yubico/fido/metadata/FidoMetadataService.html[`FidoMetadataService`] from the link:../webauthn-server-attestation[`webauthn-server-attestation`] module as a source of attestation data in addition to the static JSON file bundled with the demo. This will write cache files to the diff --git a/webauthn-server-demo/build.gradle.kts b/webauthn-server-demo/build.gradle.kts index 82830c46e..7a77b2347 100644 --- a/webauthn-server-demo/build.gradle.kts +++ b/webauthn-server-demo/build.gradle.kts @@ -55,9 +55,6 @@ dependencies { application { mainClass.set("demo.webauthn.EmbeddedServer") - - // Required for processing CRL distribution points extension - applicationDefaultJvmArgs = listOf("-Dcom.sun.security.enableCRLDP=true") } for (task in listOf(tasks.installDist, tasks.distZip, tasks.distTar)) { diff --git a/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java b/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java index 77626b03f..2418100e2 100644 --- a/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java +++ b/webauthn-server-demo/src/main/java/demo/webauthn/EmbeddedServer.java @@ -40,10 +40,7 @@ import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.servlet.ServletContainer; -/** - * Standalone Java application launcher that runs the demo server with the API but no static - * resources (i.e., no web GUI) - */ +/** Standalone Java application launcher that runs the demo application. */ public class EmbeddedServer { public static void main(String[] args) throws Exception { diff --git a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java index 2f47aee3f..c34858dbc 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/BinaryUtil.java @@ -28,7 +28,13 @@ import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import lombok.NonNull; +import lombok.ToString; +import lombok.Value; public class BinaryUtil { @@ -36,6 +42,37 @@ public static byte[] copy(byte[] bytes) { return Arrays.copyOf(bytes, bytes.length); } + /** + * Copy src into dest beginning at the offset destFrom, + * then return the modified dest. + */ + public static byte[] copyInto(byte[] src, byte[] dest, int destFrom) { + if (dest.length - destFrom < src.length) { + throw new IllegalArgumentException("Source array will not fit in destination array"); + } + if (destFrom < 0) { + throw new IllegalArgumentException("Invalid destination range"); + } + + for (int i = 0; i < src.length; ++i) { + dest[destFrom + i] = src[i]; + } + + return dest; + } + + /** Return a new array containing the concatenation of the argument arrays. */ + public static byte[] concat(byte[]... arrays) { + final int len = Arrays.stream(arrays).map(a -> a.length).reduce(0, Integer::sum); + byte[] result = new byte[len]; + int i = 0; + for (byte[] src : arrays) { + copyInto(src, result, i); + i += src.length; + } + return result; + } + /** * @param bytes Bytes to encode */ @@ -166,4 +203,311 @@ public static byte[] readAll(InputStream is) throws IOException { } } } + + public static byte[] encodeDerLength(final int length) { + if (length < 0) { + throw new IllegalArgumentException("Length is negative: " + length); + } else if (length <= 0x7f) { + return new byte[] {(byte) (length & 0xff)}; + } else if (length <= 0xff) { + return new byte[] {(byte) (0x80 | 0x01), (byte) (length & 0xff)}; + } else if (length <= 0xffff) { + return new byte[] { + (byte) (0x80 | 0x02), (byte) ((length >> 8) & 0xff), (byte) (length & 0xff) + }; + } else if (length <= 0xffffff) { + return new byte[] { + (byte) (0x80 | 0x03), + (byte) ((length >> 16) & 0xff), + (byte) ((length >> 8) & 0xff), + (byte) (length & 0xff) + }; + } else { + return new byte[] { + (byte) (0x80 | 0x04), + (byte) ((length >> 24) & 0xff), + (byte) ((length >> 16) & 0xff), + (byte) ((length >> 8) & 0xff), + (byte) (length & 0xff) + }; + } + } + + @ToString + public enum DerTagClass { + UNIVERSAL, + APPLICATION, + CONTEXT_SPECIFIC, + PRIVATE; + + public static DerTagClass parse(byte tag) { + switch ((tag >> 6) & 0x03) { + case 0x0: + return DerTagClass.UNIVERSAL; + case 0x1: + return DerTagClass.APPLICATION; + case 0x2: + return DerTagClass.CONTEXT_SPECIFIC; + case 0x3: + return DerTagClass.PRIVATE; + default: + throw new RuntimeException("This should be impossible"); + } + } + } + + @Value + private static class ParseDerAnyResult { + DerTagClass tagClass; + boolean constructed; + byte tagValue; + int valueStart; + int valueEnd; + } + + @Value + public static class ParseDerResult { + /** The parsed value, excluding the tag-and-length header. */ + public T result; + + /** + * The offset of the first octet past the end of the parsed value. In other words, the offset to + * continue reading from. + */ + public int nextOffset; + } + + public static ParseDerResult parseDerLength(@NonNull byte[] der, int offset) { + final int len = der.length - offset; + if (len == 0) { + throw new IllegalArgumentException("Empty input"); + } else if ((der[offset] & 0x80) == 0) { + return new ParseDerResult<>(der[offset] & 0xff, offset + 1); + } else { + final int longLen = der[offset] & 0x7f; + if (len >= longLen) { + switch (longLen) { + case 0: + throw new IllegalArgumentException( + String.format( + "Empty length encoding at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); + case 1: + return new ParseDerResult<>(der[offset + 1] & 0xff, offset + 2); + case 2: + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 8) | (der[offset + 2] & 0xff), offset + 3); + case 3: + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 16) + | ((der[offset + 2] & 0xff) << 8) + | (der[offset + 3] & 0xff), + offset + 4); + case 4: + if ((der[offset + 1] & 0x80) == 0) { + return new ParseDerResult<>( + ((der[offset + 1] & 0xff) << 24) + | ((der[offset + 2] & 0xff) << 16) + | ((der[offset + 3] & 0xff) << 8) + | (der[offset + 4] & 0xff), + offset + 5); + } else { + throw new UnsupportedOperationException( + String.format( + "Length out of range of int: 0x%02x%02x%02x%02x", + der[offset + 1], der[offset + 2], der[offset + 3], der[offset + 4])); + } + default: + throw new UnsupportedOperationException( + String.format("Length is too long for int: %d octets", longLen)); + } + } else { + throw new IllegalArgumentException( + String.format( + "Length encoding needs %d octets but only %s remain at index %d: 0x%s", + longLen, len - (offset + 1), offset + 1, BinaryUtil.toHex(der))); + } + } + } + + private static ParseDerAnyResult parseDerAny(@NonNull byte[] der, int offset) { + final int len = der.length - offset; + if (len == 0) { + throw new IllegalArgumentException( + String.format("Empty input at offset %d: 0x%s", offset, BinaryUtil.toHex(der))); + } else { + final byte tag = der[offset]; + final ParseDerResult contentLen = parseDerLength(der, offset + 1); + final int contentEnd = contentLen.nextOffset + contentLen.result; + return new ParseDerAnyResult( + DerTagClass.parse(tag), + (tag & 0x20) != 0, + (byte) (tag & 0x1f), + contentLen.nextOffset, + contentEnd); + } + } + + /** + * Parse a DER header with the given tag value, constructed bit and tag class, and return the + * start and end offsets of the value octets. If any of the three criteria do not match, return + * empty instead. + * + * @param der DER source to read from. + * @param offset The offset in der from which to start reading. + * @param expectTag The expected tag value, excluding the constructed bit and tag class. This is + * the 5 least significant bits of the tag octet. + * @param constructed The expected "constructed" bit. This is bit 6 (the third-most significant + * bit) of the tag octet. + * @param expectTagClass The expected tag class. This is the 2 most significant bits of the tag + * octet. + * @return The start and end offsets of the value octets, if the parsed tag matches + * expectTag, + * constructed and expectTagClass, otherwise empty. {@link + * ParseDerResult#nextOffset} is always returned. + */ + public static ParseDerResult> parseDerTaggedOrSkip( + @NonNull byte[] der, + int offset, + byte expectTag, + boolean constructed, + DerTagClass expectTagClass) { + final ParseDerAnyResult result = parseDerAny(der, offset); + if (result.tagValue == expectTag + && result.constructed == constructed + && result.tagClass == expectTagClass) { + return new ParseDerResult<>(Optional.of(result.valueStart), result.valueEnd); + } else { + return new ParseDerResult<>(Optional.empty(), result.valueEnd); + } + } + + /** + * Parse a DER header with the given tag value, constructed bit and tag class, and return the + * start and end offsets of the value octets. If any of the three criteria do not match, throw an + * {@link IllegalArgumentException}. + * + * @param der DER source to read from. + * @param offset The offset in der from which to start reading. + * @param expectTag The expected tag value, excluding the constructed bit and tag class. This is + * the 5 least significant bits of the tag octet. + * @param constructed The expected "constructed" bit. This is bit 6 (the third-most significant + * bit) of the tag octet. + * @param expectTagClass The expected tag class. This is the 2 most significant bits of the tag + * octet. + * @return The start and end offsets of the value octets, if the parsed tag matches + * expectTag, + * constructed and expectTagClass, otherwise empty. {@link + * ParseDerResult#nextOffset} is always returned. + */ + private static ParseDerResult parseDerTagged( + @NonNull byte[] der, + int offset, + byte expectTag, + boolean constructed, + DerTagClass expectTagClass) { + final ParseDerAnyResult result = parseDerAny(der, offset); + if (result.tagValue == expectTag) { + if (result.constructed == constructed) { + if (result.tagClass == expectTagClass) { + return new ParseDerResult<>(result.valueStart, result.valueEnd); + } else { + throw new IllegalArgumentException( + String.format( + "Incorrect tag class: expected %s, found %s at offset %d: 0x%s", + expectTagClass, result.tagClass, offset, BinaryUtil.toHex(der))); + } + } else { + throw new IllegalArgumentException( + String.format( + "Incorrect constructed bit: expected %s, found %s at offset %d: 0x%s", + constructed, result.constructed, offset, BinaryUtil.toHex(der))); + } + } else { + throw new IllegalArgumentException( + String.format( + "Incorrect tag: expected 0x%02x, found 0x%02x at offset %d: 0x%s", + expectTag, result.tagValue, offset, BinaryUtil.toHex(der))); + } + } + + /** Function to parse an element of a DER SEQUENCE. */ + @FunctionalInterface + public interface ParseDerSequenceElementFunction { + /** + * Parse an element of a DER SEQUENCE. + * + * @param sequenceDer The content octets of the parent SEQUENCE. This includes ALL elements in + * the sequence. + * @param elementOffset The offset into sequenceDer from where to parse the + * element. + * @return A {@link ParseDerResult} whose {@link ParseDerResult#result} is the parsed element + * and {@link ParseDerResult#nextOffset} is the offset of the first octet past the end of + * the parsed element. + */ + ParseDerResult parse(@NonNull byte[] sequenceDer, int elementOffset); + } + + /** + * Parse the elements of a SEQUENCE using the given element parsing function. + * + * @param der DER source array to read from + * @param offset Offset from which to begin reading the first element + * @param endOffset Offset of the first octet past the end of the sequence + * @param parseElement Function to use to parse each element in the sequence. + */ + public static ParseDerResult> parseDerSequenceContents( + @NonNull byte[] der, + int offset, + int endOffset, + @NonNull ParseDerSequenceElementFunction parseElement) { + List result = new ArrayList<>(); + int seqOffset = offset; + while (seqOffset < endOffset) { + ParseDerResult elementResult = parseElement.parse(der, seqOffset); + result.add(elementResult.result); + seqOffset = elementResult.nextOffset; + } + return new ParseDerResult<>(result, endOffset); + } + + /** + * Parse a SEQUENCE using the given element parsing function. + * + * @param der DER source array to read from + * @param offset Offset from which to begin reading the SEQUENCE + * @param parseElement Function to use to parse each element in the sequence. + */ + public static ParseDerResult> parseDerSequence( + @NonNull byte[] der, int offset, @NonNull ParseDerSequenceElementFunction parseElement) { + final ParseDerResult seq = + parseDerTagged(der, offset, (byte) 0x10, true, DerTagClass.UNIVERSAL); + final ParseDerResult> res = + parseDerSequenceContents(der, seq.result, seq.nextOffset, parseElement); + return new ParseDerResult<>(res.result, seq.nextOffset); + } + + /** Parse an Octet String. */ + public static ParseDerResult parseDerOctetString(@NonNull byte[] der, int offset) { + ParseDerResult res = + parseDerTagged(der, offset, (byte) 0x04, false, DerTagClass.UNIVERSAL); + return new ParseDerResult<>( + Arrays.copyOfRange(der, res.result, res.nextOffset), res.nextOffset); + } + + public static byte[] encodeDerObjectId(@NonNull byte[] oid) { + byte[] result = new byte[2 + oid.length]; + result[0] = 0x06; + result[1] = (byte) oid.length; + return BinaryUtil.copyInto(oid, result, 2); + } + + public static byte[] encodeDerBitStringWithZeroUnused(@NonNull byte[] content) { + return BinaryUtil.concat( + new byte[] {0x03}, encodeDerLength(1 + content.length), new byte[] {0}, content); + } + + public static byte[] encodeDerSequence(final byte[]... items) { + byte[] content = BinaryUtil.concat(items); + return BinaryUtil.concat(new byte[] {0x30}, encodeDerLength(content.length), content); + } } diff --git a/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java b/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java index 1e1c72bfe..f0314f0c8 100755 --- a/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/CertificateParser.java @@ -26,20 +26,28 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Optional; +import lombok.Value; public class CertificateParser { public static final String ID_FIDO_GEN_CE_AAGUID = "1.3.6.1.4.1.45724.1.1.4"; + public static final String OID_CRL_DISTRIBUTION_POINTS = "2.5.29.31"; private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder(); private static final List FIXSIG = @@ -164,4 +172,153 @@ public static Optional parseFidoAaguidExtension(X509Certificate cert) { }); return result; } + + @Value + public static class ParseCrlDistributionPointsExtensionResult { + /** + * The successfully parsed distribution point URLs. If the CRLDistributionPoints extension is + * not present, this will be an empty list. + */ + Collection distributionPoints; + + /** + * True if and only if the CRLDistributionPoints extension is present and contains anything that + * is not a distributionPoint [0] DistributionPointName containing a + * fullName [0] GeneralNames containing exactly one + * uniformResourceIdentifier [6] IA5String + */ + boolean anyDistributionPointUnsupported; + } + + public static ParseCrlDistributionPointsExtensionResult parseCrlDistributionPointsExtension( + X509Certificate cert) { + final byte[] crldpExtension = cert.getExtensionValue(OID_CRL_DISTRIBUTION_POINTS); + if (crldpExtension != null) { + BinaryUtil.ParseDerResult octetString = + BinaryUtil.parseDerOctetString(crldpExtension, 0); + try { + BinaryUtil.ParseDerResult>>>> distributionPoints = + BinaryUtil.parseDerSequence( + octetString.result, + 0, + (outerSequenceDer, distributionPointOffset) -> + BinaryUtil.parseDerSequence( + outerSequenceDer, + distributionPointOffset, + (innerSequenceDer, distributionPointChoiceOffset) -> { + // DistributionPoint ::= SEQUENCE { + // distributionPoint [0] DistributionPointName OPTIONAL, + final BinaryUtil.ParseDerResult> dpElementOffsets = + BinaryUtil.parseDerTaggedOrSkip( + innerSequenceDer, + distributionPointChoiceOffset, + (byte) 0, + true, + BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); + if (dpElementOffsets.result.isPresent()) { + + // DistributionPointName ::= CHOICE { + // fullName [0] GeneralNames, + final BinaryUtil.ParseDerResult> + dpNameElementOffsets = + BinaryUtil.parseDerTaggedOrSkip( + innerSequenceDer, + dpElementOffsets.result.get(), + (byte) 0, + true, + BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); + + if (dpNameElementOffsets.result.isPresent()) { + return BinaryUtil.parseDerSequenceContents( + innerSequenceDer, + dpNameElementOffsets.result.get(), + dpNameElementOffsets.nextOffset, + (generalNamesDer, generalNamesElementOffset) -> { + // fullName [0] GeneralNames, + // GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName + // GeneralName ::= CHOICE { + // uniformResourceIdentifier [6] IA5String, + // + // GeneralNames is defined in RFC 5280 appendix 2 which uses + // IMPLICIT tagging + // https://datatracker.ietf.org/doc/html/rfc5280#appendix-A.2 + // so the SEQUENCE tag in GeneralNames is implicit. + // The IA5String tag is also implicit from the CHOICE tag. + final BinaryUtil.ParseDerResult> + generalNameOffsets = + BinaryUtil.parseDerTaggedOrSkip( + generalNamesDer, + generalNamesElementOffset, + (byte) 6, + false, + BinaryUtil.DerTagClass.CONTEXT_SPECIFIC); + if (generalNameOffsets.result.isPresent()) { + String uriString = + new String( + Arrays.copyOfRange( + generalNamesDer, + generalNameOffsets.result.get(), + generalNameOffsets.nextOffset), + StandardCharsets.US_ASCII); + try { + return new BinaryUtil.ParseDerResult<>( + Optional.of(new URL(uriString)), + generalNameOffsets.nextOffset); + } catch (MalformedURLException e) { + throw new IllegalArgumentException( + String.format( + "Invalid URL in CRLDistributionPoints: %s", + uriString), + e); + } + } else { + return new BinaryUtil.ParseDerResult<>( + Optional.empty(), generalNameOffsets.nextOffset); + } + }); + } + } + + // Ignore all other forms of distribution points + return new BinaryUtil.ParseDerResult<>( + Collections.emptyList(), dpElementOffsets.nextOffset); + })); + + return distributionPoints.result.stream() + .flatMap(Collection::stream) + .flatMap(Collection::stream) + .reduce( + new ParseCrlDistributionPointsExtensionResult(new ArrayList<>(), false), + (result, next) -> { + if (next.isPresent()) { + List dp = new ArrayList<>(result.distributionPoints); + dp.add(next.get()); + return new ParseCrlDistributionPointsExtensionResult( + dp, result.anyDistributionPointUnsupported); + } else { + return new ParseCrlDistributionPointsExtensionResult( + result.distributionPoints, true); + } + }, + (resultA, resultB) -> { + List dp = new ArrayList<>(resultA.distributionPoints); + dp.addAll(resultB.distributionPoints); + return new ParseCrlDistributionPointsExtensionResult( + dp, + resultA.anyDistributionPointUnsupported + || resultB.anyDistributionPointUnsupported); + }); + + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + String.format( + "X.509 extension %s (id-ce-cRLDistributionPoints) is incorrectly encoded.", + OID_CRL_DISTRIBUTION_POINTS), + e); + } + + } else { + return new ParseCrlDistributionPointsExtensionResult(Collections.emptySet(), false); + } + } } diff --git a/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java index 6afb76ea5..3f2f7f3c8 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java @@ -11,6 +11,18 @@ @UtilityClass public class OptionalUtil { + /** + * If primary is present, return it unchanged. Otherwise return + * secondary. + */ + public static Optional orOptional(Optional primary, Optional secondary) { + if (primary.isPresent()) { + return primary; + } else { + return secondary; + } + } + /** * If primary is present, return it unchanged. Otherwise return the result of * recover. 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 b834f95b7..190286649 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 @@ -25,6 +25,7 @@ package com.yubico.internal.util import org.junit.runner.RunWith +import org.scalacheck.Arbitrary import org.scalacheck.Gen import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers @@ -149,4 +150,59 @@ class BinaryUtilSpec } } + describe("DER parsing and encoding:") { + it("encodeDerLength and parseDerLength are each other's inverse.") { + forAll( + Gen.chooseNum(0, Int.MaxValue), + Arbitrary.arbitrary[Array[Byte]], + ) { (len: Int, prefix: Array[Byte]) => + val encoded = BinaryUtil.encodeDerLength(len) + val decoded = BinaryUtil.parseDerLength(encoded, 0) + val decodedWithPrefix = BinaryUtil.parseDerLength( + BinaryUtil.concat(prefix, encoded), + prefix.length, + ) + + decoded.result should equal(len) + decoded.nextOffset should equal(encoded.length) + decodedWithPrefix.result should equal(len) + decodedWithPrefix.nextOffset should equal( + prefix.length + encoded.length + ) + + val recoded = BinaryUtil.encodeDerLength(decoded.result) + recoded should equal(encoded) + } + } + + it("parseDerLength tolerates unnecessarily long encodings.") { + BinaryUtil + .parseDerLength(Array(0x81, 0).map(_.toByte), 0) + .result should equal(0) + BinaryUtil + .parseDerLength(Array(0x82, 0, 0).map(_.toByte), 0) + .result should equal(0) + BinaryUtil + .parseDerLength(Array(0x83, 0, 0, 0).map(_.toByte), 0) + .result should equal(0) + BinaryUtil + .parseDerLength(Array(0x84, 0, 0, 0, 0).map(_.toByte), 0) + .result should equal(0) + BinaryUtil + .parseDerLength(Array(0x81, 7).map(_.toByte), 0) + .result should equal(7) + BinaryUtil + .parseDerLength(Array(0x82, 0, 7).map(_.toByte), 0) + .result should equal(7) + BinaryUtil + .parseDerLength(Array(0x83, 0, 0, 7).map(_.toByte), 0) + .result should equal(7) + BinaryUtil + .parseDerLength(Array(0x84, 0, 0, 4, 2).map(_.toByte), 0) + .result should equal(1026) + BinaryUtil + .parseDerLength(Array(0x84, 0, 1, 33, 7).map(_.toByte), 0) + .result should equal(73991) + } + } }