From 2e53c25620d28b851995a14603148b15323a60c6 Mon Sep 17 00:00:00 2001 From: younghyunkim Date: Thu, 12 Sep 2024 10:18:05 +0900 Subject: [PATCH] first commit --- .editorconfig | 100 ++++ .github/workflows/ci.yml | 130 +++++ .gitignore | 10 + CODE_OF_CONDUCT.md | 132 +++++ CONTRIBUTING.md | 21 + README.md | 115 +++++ build.gradle | 28 ++ gradle.properties | 46 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 185 +++++++ gradlew.bat | 89 ++++ settings.gradle | 16 + webauthn/.gitignore | 1 + webauthn/LICENSE | 201 ++++++++ webauthn/build.gradle | 186 +++++++ webauthn/proguard-rules.pro | 24 + webauthn/src/androidTest/AndroidManifest.xml | 6 + .../webauthn/BiometricAuthenticatorTest.kt | 144 ++++++ .../DeviceCredentialAuthenticatorTest.kt | 146 ++++++ .../com/lycorp/webauthn/util/DataFactory.kt | 77 +++ .../util/MockCredentialSourceStorage.kt | 87 ++++ .../webauthn/util/TestFragmentActivity.kt | 21 + webauthn/src/main/AndroidManifest.xml | 6 + .../webauthn/authenticator/Authenticator.kt | 454 ++++++++++++++++++ .../authenticator/AuthenticatorProvider.kt | 63 +++ .../authenticator/BiometricAuthenticator.kt | 64 +++ .../DeviceCredentialAuthenticator.kt | 72 +++ .../authenticator/Fido2ObjectFactory.kt | 146 ++++++ .../webauthn/db/CredentialSourceStorage.kt | 68 +++ .../webauthn/exceptions/WebAuthnException.kt | 77 +++ .../webauthn/handler/AuthenticationHandler.kt | 45 ++ .../handler/BiometricAuthenticationHandler.kt | 108 +++++ .../DeviceCredentialAuthenticationHandler.kt | 200 ++++++++ .../lycorp/webauthn/model/AssertionObject.kt | 32 ++ .../model/AttestationConveyancePreference.kt | 30 ++ .../webauthn/model/AttestationObject.kt | 44 ++ .../webauthn/model/AttestationStatement.kt | 159 ++++++ .../model/AuthenticationExtensions.kt | 60 +++ .../webauthn/model/AuthenticatorAttachment.kt | 29 ++ .../webauthn/model/AuthenticatorData.kt | 151 ++++++ .../model/AuthenticatorGetAssertionResult.kt | 24 + .../AuthenticatorMakeCredentialResult.kt | 22 + .../webauthn/model/AuthenticatorResponse.kt | 33 ++ .../model/AuthenticatorSelectionCriteria.kt | 34 ++ .../webauthn/model/AuthenticatorTransport.kt | 29 ++ .../webauthn/model/AuthenticatorType.kt | 35 ++ .../lycorp/webauthn/model/CborSerializable.kt | 38 ++ .../webauthn/model/CollectedClientData.kt | 23 + .../webauthn/model/CredentialProtection.kt | 22 + .../lycorp/webauthn/model/Fido2PromptInfo.kt | 24 + .../webauthn/model/Fido2UserAuthResult.kt | 23 + .../PublicKeyCredentialCreationOptions.kt | 28 ++ .../model/PublicKeyCredentialDescriptor.kt | 23 + .../model/PublicKeyCredentialParams.kt | 22 + .../PublicKeyCredentialRequestOptions.kt | 25 + .../model/PublicKeyCredentialResult.kt | 55 +++ .../model/PublicKeyCredentialRpEntity.kt | 22 + .../model/PublicKeyCredentialSource.kt | 54 +++ .../webauthn/model/PublicKeyCredentialType.kt | 28 ++ .../model/PublicKeyCredentialUserEntity.kt | 23 + .../webauthn/publickeycredential/Biometric.kt | 50 ++ .../publickeycredential/DeviceCredential.kt | 50 ++ .../PublicKeyCredential.kt | 428 +++++++++++++++++ .../com/lycorp/webauthn/rp/RelyingParty.kt | 98 ++++ .../com/lycorp/webauthn/util/Constants.kt | 19 + .../java/com/lycorp/webauthn/util/Encoding.kt | 52 ++ .../com/lycorp/webauthn/util/Fido2Util.kt | 70 +++ .../webauthn/util/SecureExecutionHelper.kt | 228 +++++++++ .../webauthn/BiometricAuthenticatorTest.kt | 254 ++++++++++ .../webauthn/PublicKeyCredentialTest.kt | 236 +++++++++ .../com/lycorp/webauthn/util/DataFactory.kt | 191 ++++++++ .../util/MockCredentialSourceStorage.kt | 87 ++++ .../java/com/lycorp/webauthn/util/TestUtil.kt | 120 +++++ 74 files changed, 6049 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 webauthn/.gitignore create mode 100644 webauthn/LICENSE create mode 100644 webauthn/build.gradle create mode 100644 webauthn/proguard-rules.pro create mode 100644 webauthn/src/androidTest/AndroidManifest.xml create mode 100644 webauthn/src/androidTest/java/com/lycorp/webauthn/BiometricAuthenticatorTest.kt create mode 100644 webauthn/src/androidTest/java/com/lycorp/webauthn/DeviceCredentialAuthenticatorTest.kt create mode 100644 webauthn/src/androidTest/java/com/lycorp/webauthn/util/DataFactory.kt create mode 100644 webauthn/src/androidTest/java/com/lycorp/webauthn/util/MockCredentialSourceStorage.kt create mode 100644 webauthn/src/androidTest/java/com/lycorp/webauthn/util/TestFragmentActivity.kt create mode 100644 webauthn/src/main/AndroidManifest.xml create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/authenticator/Authenticator.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/authenticator/AuthenticatorProvider.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/authenticator/BiometricAuthenticator.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/authenticator/DeviceCredentialAuthenticator.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/authenticator/Fido2ObjectFactory.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/db/CredentialSourceStorage.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/exceptions/WebAuthnException.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/handler/AuthenticationHandler.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/handler/BiometricAuthenticationHandler.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/handler/DeviceCredentialAuthenticationHandler.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AssertionObject.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AttestationConveyancePreference.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AttestationObject.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AttestationStatement.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticationExtensions.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorAttachment.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorData.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorGetAssertionResult.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorMakeCredentialResult.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorResponse.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorSelectionCriteria.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorTransport.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorType.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/CborSerializable.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/CollectedClientData.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/CredentialProtection.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/Fido2PromptInfo.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/Fido2UserAuthResult.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialCreationOptions.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialDescriptor.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialParams.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialRequestOptions.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialResult.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialRpEntity.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialSource.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialType.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialUserEntity.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/Biometric.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/DeviceCredential.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/PublicKeyCredential.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/rp/RelyingParty.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/util/Constants.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/util/Encoding.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/util/Fido2Util.kt create mode 100644 webauthn/src/main/java/com/lycorp/webauthn/util/SecureExecutionHelper.kt create mode 100644 webauthn/src/test/java/com/lycorp/webauthn/BiometricAuthenticatorTest.kt create mode 100644 webauthn/src/test/java/com/lycorp/webauthn/PublicKeyCredentialTest.kt create mode 100644 webauthn/src/test/java/com/lycorp/webauthn/util/DataFactory.kt create mode 100644 webauthn/src/test/java/com/lycorp/webauthn/util/MockCredentialSourceStorage.kt create mode 100644 webauthn/src/test/java/com/lycorp/webauthn/util/TestUtil.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e222c34 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,100 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +trim_trailing_whitespace = true +ij_continuation_indent_size = 8 +ij_smart_tabs = false + +[{*.kt,*.kts}] +# https://pinterest.github.io/ktlint/rules/configuration-ktlint/ + +ktlint_code_style = android_studio + +#ktlint_standard = disabled # Disable all rulesg from the `standard` rule set provided by KtLint + +# Enable the rules from the `standard` rule set provided by KtLint + +# Disable the rules from the 'standard' rule set provided by KtLint +ktlint_standard_discouraged-comment-location = disabled +ktlint_standard_comment-wrapping = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_class-naming = disabled + +# trailing_comma rules still have issue. +# https://github.com/pinterest/ktlint/issues/1557 +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled +# https://github.com/pinterest/ktlint/issues/1733 +ktlint_standard_no-semi = disabled + +## `Set from...` on the right -> (`Predefined style`) -> `Kotlin style guide` (Kotlin plugin 1.2.20+). +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL + +## open `Code Generation` tab +# uncheck `Line comment at first column`; +ij_kotlin_line_comment_at_first_column = false +# select `Add a space at comment start` +ij_kotlin_line_comment_add_space = true + +## open `Compose` tab +# select `Enable Compose formatting for Modifiers` +ij_kotlin_use_custom_formatting_for_modifiers = true + +## open `Imports tab` +# select `Use single name import` (all of them); +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 + +## open `Wrapping and Braces` tab +# change `Keep Maximum Blank Lines` / `In declarations` & `In code` to 1 +ij_kotlin_keep_blank_lines_in_declarations = 1 +ij_kotlin_keep_blank_lines_in_code = 1 +# and `Before '}'` to 0 +ij_kotlin_keep_blank_lines_before_right_brace = 0 + +## open `Wrapping and Braces` tab +# uncheck `Function declaration parameters` / `Align when multiline`. +ij_kotlin_align_multiline_parameters = false + +## open `Tabs and Indents` tab +# change `Continuation indent` to the same value as `Indent` (4 by default) +continuation_indent_size = 4 +ij_continuation_indent_size = 4 + +# Other: Insert imports for nested classes -> false +ij_kotlin_import_nested_classes = false +# Import Layout: import all other imports, then import all alias imports +ij_kotlin_imports_layout = *,^ + +# When these values are set any values, disabled trailing_comma rules are activated +#ij_kotlin_allow_trailing_comma = false +#ij_kotlin_allow_trailing_comma_on_call_site = false + +# For Jetpack Compose & Tests +ktlint_function_naming_ignore_when_annotated_with=Composable, Test + +[{*.xsl,*.xsd,*.xml}] +ij_continuation_indent_size = 4 +ij_xml_use_custom_settings = true +ij_xml_block_comment_at_first_column = true +ij_xml_keep_indents_on_empty_lines = false +ij_xml_line_comment_at_first_column = true + +[{*.yml,*.yaml}] +indent_size = 2 +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true + +[{*.md,*.markdown}] +max_line_length = 99999 +trim_trailing_whitespace = false + +[*.json] +indent_size = 2 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1dad951 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,130 @@ +name: CI +on: + push: + pull_request: + workflow_dispatch: + inputs: + deploy: + description: 'Deploy on non-main branches (true/false)' + required: false + default: 'false' +jobs: + lint: + runs-on: [ self-hosted, macOS ] + steps: + - name: Check out Repository + uses: actions/checkout@v3 + with: + ssh-key: ${{ secrets.SSH_KEY }} + ssh-strict: false + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + overwrite-settings: false + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Lint with Gradle + run: ./gradlew ktlintCheck + + test: + needs: [lint] + runs-on: [ self-hosted, macOS ] + steps: + - name: Check out Repository + uses: actions/checkout@v3 + with: + ssh-key: ${{ secrets.SSH_KEY }} + ssh-strict: false + + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + overwrite-settings: false + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Lint with Gradle + run: ./gradlew ktlintCheck + + build: + needs: [lint, test] + runs-on: [ self-hosted, macOS ] + steps: + - name: Check out Repository + uses: actions/checkout@v3 + with: + ssh-key: ${{ secrets.SSH_KEY }} + ssh-strict: false + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + overwrite-settings: false + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew clean assembleRelease + + deploy: + needs: [lint, test, build] + runs-on: [ self-hosted, macOS ] + if: ${{ github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy == 'true') }} + outputs: + gradle-args: ${{ steps.set-args.outputs.gradle-args }} + steps: + - uses: actions/checkout@v3 + with: + ssh-key: ${{ secrets.SSH_KEY }} + ssh-strict: false + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + overwrite-settings: false + + - name: Determine Gradle Arguments + id: set-args + run: | + if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then + echo "::set-output name=gradle-args::-PsnapshotBuild=false" + else + echo "::set-output name=gradle-args::-PsnapshotBuild=true" + fi + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew clean assembleRelease + + - name: Deploy with Gradle + env: + GITHUB_REPOSITORY: ${{ env.GITHUB_REPOSITORY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew publish ${{ steps.set-args.outputs.gradle-args }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10cfdbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..67fe8ce --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ef8303d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# How to contribute to WebAuthn Kotlin + +First of all, thank you so much for taking your time to contribute! +WebAuthn Kotlin is not very different from any other open source projects. +It will be fantastic if you help us by doing any of the following: + +- File an issue in [the issue tracker](https://github.com/line/webauthn-kotlin/issues) + to report bugs and propose new features and improvements. +- Ask a question using [the issue tracker](https://github.com/line/webauthn-kotlin/issues). +- Contribute your work by sending [a pull request](https://github.com/line/webauthn-kotlin/pulls). + +## Contributor license agreement + +When you are sending a pull request and it's a non-trivial change beyond fixing +typos, please sign [the ICLA (individual contributor license agreement)](https://cla-assistant.io/line/webauthn-kotlin). +Please [contact us](mailto:dl_oss_dev@linecorp.com) if you need the CCLA +(corporate contributor license agreement). + +## Code of conduct + +We expect contributors to follow [our code of conduct](./CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ca5c3d --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# WebAuthn Kotlin + +WebAuthn Kotlin is an open source toolkit for secure, password-less authentication in mobile apps. Developed in Kotlin, it integrates seamlessly with native Android apps and adheres to WebAuthn 2.0 standards, boosting security and user experience. + +Designed to align with modern Android development, the SDK offers easy integration and customization. It equips developers with tools for advanced authentication, such as device credentials and biometrics, simplifying logins and enhancing security. + + +## Components + +### PublicKeyCredential +The `PublicKeyCredential` serves as the client within the authentication framework, interacting with the authenticator to carry out the authentication process and communicating with the relying party. It supports two primary operations for secure, password-less authentication: + +- **create()**: Starts the process of generating new asymmetric key credentials via an authenticator. +- **get()**: Prompts the user to authenticate with a relying party using their existing credentials. + +We offer two classes that inherit from PublicKeyCredential. Each class uses different types of authenticators. + +- **Biometric**: Manages public key credentials using the biometric authenticator. It facilitates secure user verification by leveraging biometric data. +- **DeviceCredential**: Manages public key credentials using the device credential authenticator. It supports a combination of biometric data and device-based credentials like PINs or patterns for authentication. + +### RelyingParty + +The `RelyingParty` establishes communication with your server to manage access to secure applications. In FIDO2, it generates and handles authentication requests, verifies responses from authenticators, and maintains user credentials, ensuring secure, password-less interactions between the client and server. +Library users must implement the `RelyingParty` interface themselves. + +### CredentialSourceStorage +The `CredentialSourceStorage` is an interface that defines the behavior of a database for handling a public key credential source and its signature counter. + +## Requirements +- Android >= 9 (Pie) / API level >= 28 + + +## Usage + + +### Step 1: Implement the `RelyingParty` Interface + +First, you need to create an implementation of the `RelyingParty` interface. This interface is crucial for handling communication with your server's FIDO2-compatible endpoints. + +To help you get started with your implementation, we recommend checking out a sample application available on GitHub: + +* [webauthndemo-kotlin/RelyingParty](https://github.com/line/webauthndemo-kotlin/blob/main/app/src/main/java/com/lycorp/webauthn/sample/network/Fido2RelyingPartyImpl.kt) + +This sample provides a practical example of how to implement the `RelyingParty` interface in a real-world Android application. It will give you insights into integrating FIDO2 functionalities effectively with your server setup. + +### Step 2: Implement the `CredentialSourceStorage` Interface + +Next, you need to create an implementation of the `CredentialSourceStorage ` interface to manage credential source and signature counter. + +To help you get started with your implementation, we recommend checking out a sample application available on GitHub: + +* [webauthndemo-kotlin/CredentialSourceStorage](https://github.com/line/webauthndemo-kotlin/blob/main/app/src/main/java/com/lycorp/webauthn/sample/data/database/RoomCredentialSourceStorage.kt) + +### Step 3: Initialize `PublicKeyCredential` +Once you have your relying party and credential storage implementation ready, you can initialize the public key credential. + + +```kotlin +val rp = YourRelyingParty() +val db = YourCredentialSourceStorage() + +// You can use a biometric. +val publicKeyCredential = Biometric( + rpClient = rp, + db = db, + activity = activity +) + +// ,or you can use a device credential. +val publicKeyCredential = DeviceCredential( + rpClient = rp, + db = db, + activity = activity +) +``` + +Here, activity refers to the instance of your current Activity from which you are initiating the authentication process. This allows the `PublicKeyCredential` to interact with the user interface for authentication. + +### Step 4: Register and Authenticate Credentials +Before using the `create` and `get` methods of `publicKeyCredential`, configure `options` and `fido2PromptInfo` according to your needs. These configurations will be used for both registration and authentication processes. + +When you call the `create` method to register a new credential, or the `get` method to authenticate using an existing credential, the methods will return a `Result` type: + + +#### Registering a Credential +Register a new credential using the `create` method: + +```kotlin +val result: Result = publicKeyCredential.create( + options = registrationOptions, + fido2PromptInfo = fido2PromptInfo, +) +``` + +#### Authenticating with a Credential +Authenticate using an existing credential with the `get` method: + +```kotlin +val result: Result = publicKeyCredential.get( + options = authenticationOptions, + fido2PromptInfo = fido2PromptInfo, +) +``` + +## License +Apache License 2.0. See [`LICENSE`](./LICENSE). + + +## Contact Information + +We are dedicated to making our work open-source to assist with your specific needs. We are eager to learn how this library is being utilized and the issues it resolves for you. To communicate, we recommend the following approach: + +* For reporting bugs, proposing improvements, or asking questions about the library, please utilize the [**Issues**](https://github.com/line/webauthn-kotlin/issues) section of our GitHub repository. Your feedback is invaluable in helping us address your concerns more effectively and enhances the community's experience. + +Please avoid sharing any sensitive or confidential information in the issues. If there is a need to discuss sensitive matters, please indicate so in your issue, and we will arrange a more secure communication method. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3de723a --- /dev/null +++ b/build.gradle @@ -0,0 +1,28 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +buildscript { + ext.kotlin_version = "$project.kotlinVersion" + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:7.4.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0" + classpath "de.mannodermaus.gradle.plugins:android-junit5:1.9.3.0" + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3f3285f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,46 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.defaults.buildfeatures.buildconfig=true +android.nonFinalResIds=false +versionMajor=1 +versionMinor=0 +versionPatch=0 +snapshotBuild=false + +kotlinVersion=1.9.22 +coreVersion=1.10.0 +appcompatVersion=1.6.1 +kotlinxCoroutinesVersion=1.7.0 +mockkVersion=1.13.8 +kotestVersion=5.8.0 +roomVersion=2.5.2 +espressoVersion=3.5.1 +kotlinTestJunitVersion=1.7.20 +assertJVersion=3.22.0 +biometricVersion=1.1.0 +kotlinxSerializationVersion=1.4.1 +gsonVersion=2.10.1 +kotlinReflectVersion=1.9.22 +junit5Version=5.9.3 +fragmentTestingVersion=1.6.0 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..59b62cc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Jan 23 19:28:06 KST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..7b31f01 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "webauthn-kotlin" +include ':webauthn' diff --git a/webauthn/.gitignore b/webauthn/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/webauthn/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/webauthn/LICENSE b/webauthn/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/webauthn/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/webauthn/build.gradle b/webauthn/build.gradle new file mode 100644 index 0000000..79d3fff --- /dev/null +++ b/webauthn/build.gradle @@ -0,0 +1,186 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import org.codehaus.groovy.runtime.GStringImpl +import org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs + +buildscript { + project.ext.isSnapshot = project.snapshotBuild.toBoolean() + + def commitId = "git rev-parse --short HEAD".execute().text.trim() + + if (project.ext.isSnapshot) { + project.ext.versionName = "${project.versionMajor}.${project.versionMinor}.${project.versionPatch}-${commitId}-SNAPSHOT" as GStringImpl + } else { + project.ext.versionName = "${project.versionMajor}.${project.versionMinor}.${project.versionPatch}" as GStringImpl + } +} + + +plugins { + id "maven-publish" + id "com.android.library" + id "kotlin-android" + id "kotlin-kapt" + id "org.jetbrains.kotlin.android" + id "de.mannodermaus.android-junit5" + id "org.jlleitschuh.gradle.ktlint" version "12.0.3" +} + +android { + namespace "com.lycorp.webauthn" + compileSdk 33 + + defaultConfig { + minSdkVersion 28 + targetSdk 33 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArgument("runnerBuilder", "de.mannodermaus.junit5.AndroidJUnit5Builder") + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + unitTests { + includeAndroidResources = true + } + } + packagingOptions { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1,LICENSE.md,LICENSE-notice.md,NOTICE.md}" + } + } +} + +tasks.withType(KaptGenerateStubs).configureEach{kotlinOptions{jvmTarget = "11"}} + +kotlin { + jvmToolchain(11) +} + +dependencies { + implementation "androidx.core:core-ktx:$project.coreVersion" + implementation "androidx.appcompat:appcompat:$project.appcompatVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$project.kotlinxCoroutinesVersion" + implementation 'androidx.test.ext:junit-ktx:1.1.5' + + // library for test + androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:$project.kotlinTestJunitVersion" + androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$project.kotlinxCoroutinesVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$project.espressoVersion" + androidTestImplementation "io.mockk:mockk-android:$project.mockkVersion" + androidTestImplementation "androidx.fragment:fragment-testing:$project.fragmentTestingVersion" + testImplementation "io.mockk:mockk-android:$project.mockkVersion" + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$project.kotlinTestJunitVersion" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$project.kotlinxCoroutinesVersion" + + // junit5 + testImplementation platform("org.junit:junit-bom:$project.junit5Version") + testImplementation "org.junit.jupiter:junit-jupiter-api" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" + testImplementation "org.junit.jupiter:junit-jupiter-params:$project.junit5Version" + + androidTestImplementation platform("org.junit:junit-bom:$project.junit5Version") + androidTestImplementation "org.junit.jupiter:junit-jupiter-api" + androidTestRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" + + // assertJ + androidTestImplementation("org.assertj:assertj-core:$project.assertJVersion") + + // cbor + implementation group: "co.nstant.in", name: "cbor", version: "0.9" + + // coroutine + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$project.kotlinxCoroutinesVersion" + + // androidx biometric + implementation "androidx.biometric:biometric:$project.biometricVersion" + + // Room + implementation "androidx.room:room-runtime:$project.roomVersion" + kapt "androidx.room:room-compiler:$project.roomVersion" + implementation "androidx.room:room-ktx:$project.roomVersion" + androidTestImplementation "androidx.room:room-testing:$project.roomVersion" + + // serialization + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$project.kotlinxSerializationVersion" + + // Gson + implementation "com.google.code.gson:gson:$project.gsonVersion" + + // Kotest + testImplementation "io.kotest:kotest-runner-junit5-jvm:$project.kotestVersion" + testImplementation "io.kotest:kotest-assertions-core-jvm:$project.kotestVersion" + testImplementation "io.kotest:kotest-property:$project.kotestVersion" + testImplementation "io.kotest:kotest-framework-datatest:$project.kotestVersion" + + implementation "org.jetbrains.kotlin:kotlin-reflect:$project.kotlinVersion" +} + +android.libraryVariants.all { variant -> + variant.outputs.all { + outputFileName = "webauthn-kotlin-release.aar" + } +} + +publishing { + publications { + webauthnKotlinAar(MavenPublication) { + groupId = "com.lycorp.webauthn" + artifactId = "webauthn-kotlin" + version = project.ext.versionName + pom { + name = "webauthn-kotlin" + description = "A Kotlin library for implementing WebAuthn authentication in Android applications." + url = "https://github.com/line/webauthn-kotlin" + } + artifact("$buildDir/outputs/aar/webauthn-kotlin-release.aar") + pom.withXml { + final dependenciesNode = asNode().appendNode('dependencies') + + configurations.implementation.allDependencies.each { + final dependencyNode = dependenciesNode.appendNode('dependency') + dependencyNode.appendNode('groupId', it.group) + dependencyNode.appendNode('artifactId', it.name) + dependencyNode.appendNode('version', it.version) + } + + configurations.api.allDependencies.each { + final dependencyNode = dependenciesNode.appendNode('dependency') + dependencyNode.appendNode('groupId', it.group) + dependencyNode.appendNode('artifactId', it.name) + dependencyNode.appendNode('version', it.version) + } + } + } + } +} diff --git a/webauthn/proguard-rules.pro b/webauthn/proguard-rules.pro new file mode 100644 index 0000000..06f8e5c --- /dev/null +++ b/webauthn/proguard-rules.pro @@ -0,0 +1,24 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class com.lycorp.webauthn.** { *; } +-dontwarn java.lang.invoke.StringConcatFactory diff --git a/webauthn/src/androidTest/AndroidManifest.xml b/webauthn/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..d469ba0 --- /dev/null +++ b/webauthn/src/androidTest/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/webauthn/src/androidTest/java/com/lycorp/webauthn/BiometricAuthenticatorTest.kt b/webauthn/src/androidTest/java/com/lycorp/webauthn/BiometricAuthenticatorTest.kt new file mode 100644 index 0000000..8dc1d99 --- /dev/null +++ b/webauthn/src/androidTest/java/com/lycorp/webauthn/BiometricAuthenticatorTest.kt @@ -0,0 +1,144 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn + +import androidx.test.core.app.ActivityScenario +import com.lycorp.webauthn.authenticator.BiometricAuthenticator +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.handler.BiometricAuthenticationHandler +import com.lycorp.webauthn.model.Fido2UserAuthResult +import com.lycorp.webauthn.util.DataFactory +import com.lycorp.webauthn.util.MockCredentialSourceStorage +import com.lycorp.webauthn.util.SecureExecutionHelper +import com.lycorp.webauthn.util.TestFragmentActivity +import com.lycorp.webauthn.util.base64urlToString +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import java.security.KeyStore +import java.security.Signature +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class BiometricAuthenticatorTest { + private lateinit var biometricAuthenticator: BiometricAuthenticator + private var mockCredentialSourceStorage = MockCredentialSourceStorage() + + private val fido2Database: CredentialSourceStorage = mockCredentialSourceStorage + private val mockAuthenticationHandler: BiometricAuthenticationHandler = mockk() + private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").also { + it.load(null) + } + + @BeforeEach + fun beforeEachSetUp() { + ActivityScenario.launch(TestFragmentActivity::class.java).use { scenario -> + scenario.onActivity { activity: TestFragmentActivity -> + biometricAuthenticator = BiometricAuthenticator( + activity, fido2Database, authenticationHandler = mockAuthenticationHandler + ) + } + } + + mockkObject(SecureExecutionHelper) + coEvery { + SecureExecutionHelper.generateKey( + keyAlias = any(), + challenge = any(), + publicKeyAlgorithm = any(), + useBiometricOnly = any(), + isStrongBoxBacked = any(), + ) + } coAnswers { + SecureExecutionHelper.generateKey( + keyAlias = arg(0), + challenge = arg(1), + publicKeyAlgorithm = arg(2), + useBiometricOnly = arg(3), + isStrongBoxBacked = arg(6), + userAuthenticationRequired = false, + ) + } + + coEvery { mockAuthenticationHandler.isSupported() } returns true + val signatureSlot = slot<() -> Signature>() + coEvery { + mockAuthenticationHandler.authenticate( + capture(signatureSlot), + any(), + ) + } coAnswers { Fido2UserAuthResult(signature = signatureSlot.captured()) } + } + + @AfterEach + fun afterEachTearDown() { + unmockkObject(SecureExecutionHelper) + } + + @Test + @DisplayName( + "Given valid parameters & behaviors, both makeCredential and getAssertion should not throw any exception" + ) + fun checkGetAssertion() { + assertThatCode { + runBlocking { + val aliasListBefore = keyStore.aliases().toList() + biometricAuthenticator.makeCredential( + // Default parameters + hash = DataFactory.DUMMY_BYTEARRAY, + rpEntity = DataFactory.newRpEntity, + userEntity = DataFactory.newUserEntity, + credTypesAndPubKeyAlgs = listOf(DataFactory.ES256_CRED_PARAMS), + excludeCredDescriptorList = null, + extensions = null, + ) + val aliasListAfter = keyStore.aliases().toList() + + assertThat(aliasListAfter.size).isEqualTo(aliasListBefore.size + 1) + + val newAlias = aliasListAfter.minus(aliasListBefore.toSet()).first() + val newCredId = newAlias.base64urlToString() + + assertThat( + fido2Database.load(newCredId) + ).isNotNull + + biometricAuthenticator.getAssertion( + // Default parameters + DataFactory.newRpId, + DataFactory.DUMMY_BYTEARRAY, + null, + null + ) + + // Erase a key and a credential for next tests + keyStore.deleteEntry(newAlias) + fido2Database.delete(newAlias) + } + }.doesNotThrowAnyException() + } +} diff --git a/webauthn/src/androidTest/java/com/lycorp/webauthn/DeviceCredentialAuthenticatorTest.kt b/webauthn/src/androidTest/java/com/lycorp/webauthn/DeviceCredentialAuthenticatorTest.kt new file mode 100644 index 0000000..f86ce44 --- /dev/null +++ b/webauthn/src/androidTest/java/com/lycorp/webauthn/DeviceCredentialAuthenticatorTest.kt @@ -0,0 +1,146 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn + +import androidx.test.core.app.ActivityScenario +import com.lycorp.webauthn.authenticator.DeviceCredentialAuthenticator +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.handler.DeviceCredentialAuthenticationHandler +import com.lycorp.webauthn.model.Fido2UserAuthResult +import com.lycorp.webauthn.util.DataFactory +import com.lycorp.webauthn.util.MockCredentialSourceStorage +import com.lycorp.webauthn.util.SecureExecutionHelper +import com.lycorp.webauthn.util.TestFragmentActivity +import com.lycorp.webauthn.util.base64urlToString +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import java.security.KeyStore +import java.security.Signature +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class DeviceCredentialAuthenticatorTest { + private lateinit var deviceCredentialAuthenticator: DeviceCredentialAuthenticator + private var mockCredentialSourceStorage = MockCredentialSourceStorage() + + private val fido2Database: CredentialSourceStorage = mockCredentialSourceStorage + private val mockAuthenticationHandler: DeviceCredentialAuthenticationHandler = mockk() + private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").also { + it.load(null) + } + + @BeforeEach + fun beforeEachSetUp() { + ActivityScenario.launch(TestFragmentActivity::class.java).use { scenario -> + scenario.onActivity { activity: TestFragmentActivity -> + deviceCredentialAuthenticator = DeviceCredentialAuthenticator( + activity, fido2Database, authenticationHandler = mockAuthenticationHandler + ) + } + } + + mockkObject(SecureExecutionHelper) + coEvery { + SecureExecutionHelper.generateKey( + keyAlias = any(), + challenge = any(), + publicKeyAlgorithm = any(), + useBiometricOnly = any(), + userAuthenticationValidityDurationSeconds = any(), + isStrongBoxBacked = any(), + ) + } coAnswers { + SecureExecutionHelper.generateKey( + keyAlias = arg(0), + challenge = arg(1), + publicKeyAlgorithm = arg(2), + useBiometricOnly = arg(3), + userAuthenticationValidityDurationSeconds = arg(4), + isStrongBoxBacked = arg(6), + userAuthenticationRequired = false, + ) + } + + coEvery { mockAuthenticationHandler.isSupported() } returns true + val signatureSlot = slot<() -> Signature>() + coEvery { + mockAuthenticationHandler.authenticate( + capture(signatureSlot), + any(), + ) + } coAnswers { Fido2UserAuthResult(signature = signatureSlot.captured()) } + } + + @AfterEach + fun afterEachTearDown() { + unmockkObject(SecureExecutionHelper) + } + + @Test + @DisplayName( + "Given valid parameters & behaviors, both makeCredential and getAssertion should not throw any exception" + ) + fun checkGetAssertion() { + assertThatCode { + runBlocking { + val aliasListBefore = keyStore.aliases().toList() + deviceCredentialAuthenticator.makeCredential( + // Default parameters + hash = DataFactory.DUMMY_BYTEARRAY, + rpEntity = DataFactory.newRpEntity, + userEntity = DataFactory.newUserEntity, + credTypesAndPubKeyAlgs = listOf(DataFactory.ES256_CRED_PARAMS), + excludeCredDescriptorList = null, + extensions = null, + ) + val aliasListAfter = keyStore.aliases().toList() + + assertThat(aliasListAfter.size).isEqualTo(aliasListBefore.size + 1) + + val newAlias = aliasListAfter.minus(aliasListBefore.toSet()).first() + val newCredId = newAlias.base64urlToString() + + assertThat( + fido2Database.load(newCredId) + ).isNotNull + + deviceCredentialAuthenticator.getAssertion( + // Default parameters + DataFactory.newRpId, + DataFactory.DUMMY_BYTEARRAY, + null, + null + ) + + // Erase a key and a credential for next tests + keyStore.deleteEntry(newAlias) + fido2Database.delete(newAlias) + } + }.doesNotThrowAnyException() + } +} diff --git a/webauthn/src/androidTest/java/com/lycorp/webauthn/util/DataFactory.kt b/webauthn/src/androidTest/java/com/lycorp/webauthn/util/DataFactory.kt new file mode 100644 index 0000000..d8f8cbf --- /dev/null +++ b/webauthn/src/androidTest/java/com/lycorp/webauthn/util/DataFactory.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import com.lycorp.webauthn.model.AuthenticatorGetAssertionResult +import com.lycorp.webauthn.model.AuthenticatorMakeCredentialResult +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.PublicKeyCredentialDescriptor +import com.lycorp.webauthn.model.PublicKeyCredentialParams +import com.lycorp.webauthn.model.PublicKeyCredentialRpEntity +import com.lycorp.webauthn.model.PublicKeyCredentialSource +import com.lycorp.webauthn.model.PublicKeyCredentialType +import com.lycorp.webauthn.model.PublicKeyCredentialUserEntity +import java.security.KeyPairGenerator + +class DataFactory { + companion object { + val RP_NAME = "test_rp" + val USER_NAME = "test_user_name" + val USER_DISPLAY_NAME = "test_user_display_name" + + val newRpId = "https://new-rp.com" + val newUserId = "new-user-id" + val newRpEntity = + PublicKeyCredentialRpEntity(newRpId, RP_NAME) + val newUserEntity = PublicKeyCredentialUserEntity(newUserId, USER_NAME, USER_DISPLAY_NAME) + + val registeredRpId = "https://registered-rp.com" + val registeredUserId = "registered-user-id" + val registeredCredId = Fido2Util.generateRandomByteArray(32).toBase64url() + + val registeredCredSource = PublicKeyCredentialSource( + id = registeredCredId, + rpId = registeredRpId, + userHandle = registeredUserId, + aaguid = AuthenticatorType.Biometric.aaguid, + ) + val registeredCredDescriptor = PublicKeyCredentialDescriptor( + type = PublicKeyCredentialType.PUBLIC_KEY.value, + id = registeredCredSource.id.toBase64url(), + transports = null, + ) + + val DUMMY_BYTEARRAY = ByteArray(32) { 0 } + val ES256_CRED_PARAMS = + PublicKeyCredentialParams(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256) + val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(256) } + .generateKeyPair() + + fun getMakeCredentialResult() = AuthenticatorMakeCredentialResult( + DUMMY_BYTEARRAY, + DUMMY_BYTEARRAY, + ) + + fun getGetAssertionResult() = AuthenticatorGetAssertionResult( + DUMMY_BYTEARRAY, + DUMMY_BYTEARRAY, + DUMMY_BYTEARRAY, + DUMMY_BYTEARRAY, + ) + } +} diff --git a/webauthn/src/androidTest/java/com/lycorp/webauthn/util/MockCredentialSourceStorage.kt b/webauthn/src/androidTest/java/com/lycorp/webauthn/util/MockCredentialSourceStorage.kt new file mode 100644 index 0000000..11db22d --- /dev/null +++ b/webauthn/src/androidTest/java/com/lycorp/webauthn/util/MockCredentialSourceStorage.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.model.PublicKeyCredentialSource +import com.lycorp.webauthn.model.PublicKeyCredentialType +import java.util.UUID + +class MockCredentialSourceStorage : CredentialSourceStorage { + private var credSourceTable: MutableList = mutableListOf() + + override fun store(credSource: PublicKeyCredentialSource) { + val credSourceEntity = TestPubKeyCredSourceEntity( + credType = PublicKeyCredentialType.PUBLIC_KEY.value, + aaguid = credSource.aaguid, + credId = credSource.id, + rpId = credSource.rpId, + userHandle = credSource.userHandle, + signatureCounter = 0L, + ) + credSourceTable.add(credSourceEntity) + } + override fun load(credId: String): PublicKeyCredentialSource? { + val credSourceEntity = credSourceTable.firstOrNull { it.credId.contentEquals(credId) } + return credSourceEntity?.let { + PublicKeyCredentialSource( + type = it.credType, + id = it.credId, + rpId = it.rpId, + userHandle = it.userHandle, + aaguid = it.aaguid, + ) + } + } + + override fun loadAll(): List { + return credSourceTable.map { entity -> + PublicKeyCredentialSource( + type = entity.credType, + id = entity.credId, + rpId = entity.rpId, + userHandle = entity.userHandle, + aaguid = entity.aaguid, + ) + } + } + + override fun delete(credId: String) { + credSourceTable = credSourceTable.filter { it.credId != credId }.toMutableList() + } + + override fun increaseSignatureCounter(credId: String) { + val credSourceEntity = credSourceTable.firstOrNull { it.credId.contentEquals(credId) } + credSourceEntity?.let { + it.signatureCounter += 1 + } + } + override fun getSignatureCounter(credId: String): UInt = 0u + + fun removeAllData() { + credSourceTable = mutableListOf() + } +} + +data class TestPubKeyCredSourceEntity( + val credType: String, + var credId: String, + val rpId: String, + val userHandle: String?, + val aaguid: UUID, + var signatureCounter: Long, +) diff --git a/webauthn/src/androidTest/java/com/lycorp/webauthn/util/TestFragmentActivity.kt b/webauthn/src/androidTest/java/com/lycorp/webauthn/util/TestFragmentActivity.kt new file mode 100644 index 0000000..fe1daa7 --- /dev/null +++ b/webauthn/src/androidTest/java/com/lycorp/webauthn/util/TestFragmentActivity.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import androidx.fragment.app.FragmentActivity + +class TestFragmentActivity : FragmentActivity() diff --git a/webauthn/src/main/AndroidManifest.xml b/webauthn/src/main/AndroidManifest.xml new file mode 100644 index 0000000..df3aef1 --- /dev/null +++ b/webauthn/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/webauthn/src/main/java/com/lycorp/webauthn/authenticator/Authenticator.kt b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/Authenticator.kt new file mode 100644 index 0000000..c0c4a39 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/Authenticator.kt @@ -0,0 +1,454 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.authenticator + +import android.content.pm.PackageManager +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.exceptions.WebAuthnException +import com.lycorp.webauthn.handler.AuthenticationHandler +import com.lycorp.webauthn.handler.BiometricAuthenticationHandler +import com.lycorp.webauthn.model.AuthenticatorExtensionsInput +import com.lycorp.webauthn.model.AuthenticatorExtensionsOutput +import com.lycorp.webauthn.model.AuthenticatorGetAssertionResult +import com.lycorp.webauthn.model.AuthenticatorMakeCredentialResult +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.model.Fido2UserAuthResult +import com.lycorp.webauthn.model.PublicKeyCredentialDescriptor +import com.lycorp.webauthn.model.PublicKeyCredentialParams +import com.lycorp.webauthn.model.PublicKeyCredentialRpEntity +import com.lycorp.webauthn.model.PublicKeyCredentialSource +import com.lycorp.webauthn.model.PublicKeyCredentialType +import com.lycorp.webauthn.model.PublicKeyCredentialUserEntity +import com.lycorp.webauthn.model.getSignatureAlgorithmName +import com.lycorp.webauthn.util.CRED_ID_SIZE +import com.lycorp.webauthn.util.Fido2Util +import com.lycorp.webauthn.util.SecureExecutionHelper +import com.lycorp.webauthn.util.base64urlToByteArray +import com.lycorp.webauthn.util.toBase64url +import java.security.KeyPair +import java.security.PrivateKey +import java.security.Signature +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** + * Abstract class representing a FIDO2 authenticator. + * Provides methods for credential creation and assertion. + */ +internal abstract class Authenticator( + protected open val activity: FragmentActivity, + open val db: CredentialSourceStorage, + protected open var fido2PromptInfo: Fido2PromptInfo? = null, + protected open val databaseDispatcher: CoroutineDispatcher = Dispatchers.IO, + protected open val fido2ObjectFactory: Fido2ObjectFactory = Fido2ObjectFactory(), + protected open val authenticationHandler: AuthenticationHandler, +) { + /** + * The type of the authenticator. + */ + abstract val authType: AuthenticatorType + + /** + * The list of supported public key credential parameters. + */ + protected open var supportedCredParamsList: List = + listOf( + PublicKeyCredentialParams(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256), + ) + + /** + * Creates a new credential. + * + * This method implements the `authenticatorMakeCredential` operation as defined in the Web Authentication: An API for accessing Public Key Credentials Level 2 specification. + * For more details, see the specification: [Web Authentication: Level 2 - Create](https://www.w3.org/TR/webauthn-2/#authenticatormakecredential) + * + * @param hash The hash to sign. + * @param rpEntity The relying party entity information. + * @param userEntity The user entity information. + * @param credTypesAndPubKeyAlgs The list of credential types and public key algorithms. + * @param excludeCredDescriptorList The list of credentials to exclude. + * @param extensions The authenticator extensions input. + * @return The result of the credential creation process. + * @throws WebAuthnException If an error occurs during the creation process. + */ + suspend fun makeCredential( + hash: ByteArray, + rpEntity: PublicKeyCredentialRpEntity, + userEntity: PublicKeyCredentialUserEntity, + credTypesAndPubKeyAlgs: List, + excludeCredDescriptorList: List?, + extensions: AuthenticatorExtensionsInput?, + ): Result { + val pubKeyAlgAndCredType: PublicKeyCredentialParams = + credTypesAndPubKeyAlgs.firstOrNull { it in supportedCredParamsList } + ?: throw WebAuthnException.CoreException.NotSupportedException( + message = "The credential type and public key algorithm are not supported." + ) + + val wasNotRegistered = checkCredentialWasNotRegistered(rpEntity.id, excludeCredDescriptorList) + if (!wasNotRegistered) { + throw WebAuthnException.CoreException.InvalidStateException( + message = "The credential is already registered." + ) + } + + if (!authenticationHandler.isSupported()) { + throw WebAuthnException.CoreException.ConstraintException( + message = "Authentication is not supported by a device." + ) + } + + // Generate a unique credential ID + var credIdBytes: ByteArray + var keyAlias: String + var credId: String + do { + credIdBytes = Fido2Util.generateRandomByteArray(CRED_ID_SIZE) + credId = credIdBytes.toBase64url() + keyAlias = credId.toBase64url() + } while (SecureExecutionHelper.containAlias(keyAlias)) + + try { + val isStrongBoxBacked = activity.applicationContext.packageManager.hasSystemFeature( + PackageManager.FEATURE_STRONGBOX_KEYSTORE + ) + + val keyPair = generateFido2Key( + keyAlias = keyAlias, + challenge = hash, + pubKeyAlg = pubKeyAlgAndCredType.alg, + isStrongBoxBacked = isStrongBoxBacked, + ) + + val signatureAlgorithm = pubKeyAlgAndCredType.alg.getSignatureAlgorithmName() + + val fido2UserAuthResult = authenticateUser( + authenticationHandler, + { Signature.getInstance(signatureAlgorithm).apply { initSign(keyPair.private) } }, + fido2PromptInfo, + ) + + val newCredential = PublicKeyCredentialSource( + type = pubKeyAlgAndCredType.type.value, + id = credId, + rpId = rpEntity.id, + userHandle = userEntity.id, + aaguid = authType.aaguid, + ) + + val processedExtensions = AuthenticatorExtensionsOutput.getAuthenticatorExtensionResult(extensions) + + val attestationObject = fido2ObjectFactory.createAttestationObject( + hash = hash, + rpId = rpEntity.id, + aaguid = authType.aaguidBytes(), + credId = credId, + signCount = 0u, + signature = fido2UserAuthResult.signature ?: throw WebAuthnException.AuthenticationException( + message = "CryptoObject does not include signature.", + ), + extensions = processedExtensions, + ) + + try { + withContext(databaseDispatcher) { + db.store(newCredential) + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException("Failed to store new credential for credId: $credId", e) + } + + return Result.success( + AuthenticatorMakeCredentialResult( + credentialId = credIdBytes, + attestationObject = attestationObject.toCBOR(), + ) + ) + } catch (e: Throwable) { + val authenticatorException = if (e is WebAuthnException) { + e + } else { + WebAuthnException.UnknownException( + message = "An unknown error occurred.", + cause = e + ) + } + + try { + retryCleanup(credId, maxTries = 2, delayMillis = 1000) + } catch (e2: Throwable) { + return Result.failure( + WebAuthnException.DeletionException( + "Error occurred while deleting key: $e2", + cause = e2, + trigger = authenticatorException + ) + ) + } + + return Result.failure(authenticatorException) + } + } + + /** + * Gets an assertion for authentication. + * + * This method implements the `authenticatorGetAssertion` operation as defined in the Web Authentication: An API for accessing Public Key Credentials Level 2 specification. + * For more details, see the specification: [Web Authentication: Level 2 - Get](https://www.w3.org/TR/webauthn-2/#sctn-op-get-assertion) + * + * @param rpId The relying party ID. + * @param hash The hash to sign. + * @param allowCredDescriptorList The list of allowed credentials. + * @param extensions The authenticator extensions input. + * @return The result of the assertion process. + * @throws WebAuthnException If an error occurs during the assertion process. + */ + suspend fun getAssertion( + rpId: String, + hash: ByteArray, + allowCredDescriptorList: List?, + extensions: AuthenticatorExtensionsInput?, + ): Result { + val credOptions: List = checkCredentialWasRegistered(rpId, allowCredDescriptorList) + if (credOptions.isEmpty()) { + throw WebAuthnException.CoreException.NotAllowedException( + message = "No credential found for the given RP ID." + ) + } + val selectedCred: PublicKeyCredentialSource = credOptions[0] + val credId: String = selectedCred.id + val keyAlias: String = credId.toBase64url() + + if (!authenticationHandler.isSupported()) { + throw WebAuthnException.CoreException.ConstraintException( + message = "Authentication is not supported by a device." + ) + } + val key = SecureExecutionHelper.getKey(keyAlias) ?: throw WebAuthnException.KeyNotFoundException( + message = "Cannot get a key from device." + ) + val signatureAlgorithm = SecureExecutionHelper.getX509Certificate(keyAlias).sigAlgName + val fido2UserAuthResult = authenticateUser( + authenticationHandler, + { Signature.getInstance(signatureAlgorithm).apply { initSign(key as PrivateKey) } }, + fido2PromptInfo, + ) + + val processedExtensions = AuthenticatorExtensionsOutput.getAuthenticatorExtensionResult(extensions) + + try { + withContext(databaseDispatcher) { + db.increaseSignatureCounter(credId) + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException( + "Failed to increase signature counter for credId: $credId", + e + ) + } + + val signCount: UInt = try { + withContext(databaseDispatcher) { + db.getSignatureCounter(credId) + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException("Failed to get signature counter for credId: $credId", e) + } + + val assertionObject = + fido2ObjectFactory.createAssertionObject( + hash = hash, + rpId = rpId, + signCount = signCount, + signature = fido2UserAuthResult.signature ?: throw WebAuthnException.AuthenticationException( + message = "CryptoObject does not include signature.", + ), + extensions = processedExtensions + ) + + return Result.success( + AuthenticatorGetAssertionResult( + credentialId = credId.base64urlToByteArray(), + authenticatorData = assertionObject.authenticatorData, + signature = assertionObject.signature, + userHandle = selectedCred.userHandle?.base64urlToByteArray(), + ) + ) + } + + /** + * Checks if a credential is not registered. + * + * @param rpId The relying party ID. + * @param excludeCredDescriptorList The list of credentials to exclude. + * @return True if the credential is not registered, false otherwise. + */ + private suspend fun checkCredentialWasNotRegistered( + rpId: String, + excludeCredDescriptorList: List?, + ): Boolean { + if (excludeCredDescriptorList.isNullOrEmpty()) { + return true + } + for (descriptor in excludeCredDescriptorList) { + val credentialSource = try { + withContext(databaseDispatcher) { + db.load(credId = descriptor.id) + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException( + "Failed to load credential source for credId: ${descriptor.id}", + e + ) + } + + if (credentialSource != null && + credentialSource.rpId == rpId && + credentialSource.type == descriptor.type + ) { + return false + } + } + return true + } + + /** + * Checks if a credential is registered. + * + * @param rpId The relying party ID. + * @param allowCredDescriptorList The list of allowed credentials. + * @return The list of registered public key credential sources. + */ + private suspend fun checkCredentialWasRegistered( + rpId: String, + allowCredDescriptorList: List?, + ): List { + val credOptions: MutableList = mutableListOf() + if (!allowCredDescriptorList.isNullOrEmpty()) { + for (descriptor in allowCredDescriptorList) { + val credId = descriptor.id + val credSource = try { + withContext(databaseDispatcher) { + db.load(credId = credId) + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException( + "Failed to load credential source for credId: $credId", + e + ) + } + if (credSource != null && credSource.rpId == rpId) { + credOptions.add(credSource) + } + } + } else { + val credSourceList = try { + withContext(databaseDispatcher) { + db.loadAll() + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException("Failed to load all credential sources", e) + } + + for (credSource in credSourceList) { + if (credSource.rpId == rpId) { + credOptions.add(credSource) + } + } + } + return credOptions + } + + protected abstract fun generateFido2Key( + keyAlias: String, + challenge: ByteArray, + pubKeyAlg: COSEAlgorithmIdentifier, + isStrongBoxBacked: Boolean, + ): KeyPair + + /** + * Authenticates the user, enabling the use of keys for signing. + * + * This method performs user authentication using the provided authentication handler. + * The process includes handling initial signatures and displaying prompt information for FIDO2 authentication. + * + * @param authenticationHandler The handler for authentication. + * @param signatureProvider The provider for the signature. + * @param fido2PromptInfo The prompt information for FIDO2 authentication. + * @return The result of the user authentication. + * @throws WebAuthnException.CoreException.NotAllowedException If authentication fails or if an authentication error occurs. + */ + protected suspend fun authenticateUser( + authenticationHandler: AuthenticationHandler, + signatureProvider: () -> Signature, + fido2PromptInfo: Fido2PromptInfo?, + ): Fido2UserAuthResult { + try { + return authenticationHandler.authenticate(signatureProvider, fido2PromptInfo) + } catch (e: BiometricAuthenticationHandler.AuthenticationFailedException) { + throw WebAuthnException.CoreException.NotAllowedException( + message = "Authentication failed" + ) + } catch (e: BiometricAuthenticationHandler.AuthenticationErrorException) { + throw WebAuthnException.CoreException.NotAllowedException( + message = "Authentication error is occurred." + ) + } + } + + /** + * Cleans up by deleting a unnecessary credential. + * + * @param credId The credential ID. + * @throws WebAuthnException.CredSrcStorageException If there is an error deleting the credential from the database. + */ + private suspend fun cleanup(credId: String) { + val keyAlias = credId.toBase64url() + SecureExecutionHelper.deleteKey(keyAlias) + try { + withContext(databaseDispatcher) { + db.delete(credId = credId) + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException("Failed to delete credential for credId: $credId", e) + } + } + + /** + * Retries cleanup in case of failure. + * + * @param credId The credential ID. + * @param maxTries The maximum number of attempts. + * @param delayMillis The delay between retries in milliseconds. + */ + private suspend fun retryCleanup(credId: String, maxTries: Int, delayMillis: Long) { + repeat(maxTries) { attempt -> + try { + cleanup(credId) + return + } catch (e: Throwable) { + if (attempt == maxTries - 1) throw e + delay(delayMillis) + } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/authenticator/AuthenticatorProvider.kt b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/AuthenticatorProvider.kt new file mode 100644 index 0000000..1ebacba --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/AuthenticatorProvider.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.authenticator + +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.model.Fido2PromptInfo +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +class AuthenticatorProvider( + private val activity: FragmentActivity, + private val db: CredentialSourceStorage, + private val databaseDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val authenticationDispatcher: CoroutineDispatcher = Dispatchers.Main, +) { + + private var deviceCredentialAuthenticator: DeviceCredentialAuthenticator = + DeviceCredentialAuthenticator( + activity = activity, + db = db, + databaseDispatcher = databaseDispatcher, + authenticationDispatcher = authenticationDispatcher + ) + + internal fun getAuthenticator( + authType: AuthenticatorType, + fido2PromptInfo: Fido2PromptInfo? = null, + ): Authenticator { + return when (authType) { + AuthenticatorType.Biometric -> { + BiometricAuthenticator( + activity = activity, + db = db, + fido2PromptInfo = fido2PromptInfo, + databaseDispatcher = databaseDispatcher, + authenticationDispatcher = authenticationDispatcher, + ) + } + + AuthenticatorType.Device -> { + deviceCredentialAuthenticator.apply { + this.fido2PromptInfo = fido2PromptInfo + } + } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/authenticator/BiometricAuthenticator.kt b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/BiometricAuthenticator.kt new file mode 100644 index 0000000..d7d02a5 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/BiometricAuthenticator.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.authenticator + +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.handler.AuthenticationHandler +import com.lycorp.webauthn.handler.BiometricAuthenticationHandler +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.util.SecureExecutionHelper +import java.security.KeyPair +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal class BiometricAuthenticator( + override val activity: FragmentActivity, + override val db: CredentialSourceStorage, + override var fido2PromptInfo: Fido2PromptInfo? = null, + override val databaseDispatcher: CoroutineDispatcher = Dispatchers.IO, + override val fido2ObjectFactory: Fido2ObjectFactory = Fido2ObjectFactory(), + private val authenticationDispatcher: CoroutineDispatcher = Dispatchers.Main, + override val authenticationHandler: AuthenticationHandler = + BiometricAuthenticationHandler(activity, authenticationDispatcher) +) : Authenticator( + activity, + db, + fido2PromptInfo, + databaseDispatcher, + fido2ObjectFactory, + authenticationHandler +) { + override val authType: AuthenticatorType = AuthenticatorType.Biometric + + override fun generateFido2Key( + keyAlias: String, + challenge: ByteArray, + pubKeyAlg: COSEAlgorithmIdentifier, + isStrongBoxBacked: Boolean, + ): KeyPair { + return SecureExecutionHelper.generateKey( + keyAlias = keyAlias, + challenge = challenge, + publicKeyAlgorithm = pubKeyAlg, + useBiometricOnly = true, + isStrongBoxBacked = isStrongBoxBacked + ) + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/authenticator/DeviceCredentialAuthenticator.kt b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/DeviceCredentialAuthenticator.kt new file mode 100644 index 0000000..1d1dbad --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/DeviceCredentialAuthenticator.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.authenticator + +import android.os.Build +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.handler.AuthenticationHandler +import com.lycorp.webauthn.handler.DeviceCredentialAuthenticationHandler +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.util.SecureExecutionHelper +import java.security.KeyPair +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal class DeviceCredentialAuthenticator( + override val activity: FragmentActivity, + override val db: CredentialSourceStorage, + public override var fido2PromptInfo: Fido2PromptInfo? = null, + override val databaseDispatcher: CoroutineDispatcher = Dispatchers.IO, + override val fido2ObjectFactory: Fido2ObjectFactory = Fido2ObjectFactory(), + private val authenticationDispatcher: CoroutineDispatcher = Dispatchers.Main, + override val authenticationHandler: AuthenticationHandler = + DeviceCredentialAuthenticationHandler(activity, authenticationDispatcher) +) : Authenticator( + activity, + db, + fido2PromptInfo, + databaseDispatcher, + fido2ObjectFactory, + authenticationHandler +) { + override val authType: AuthenticatorType = AuthenticatorType.Device + + override fun generateFido2Key( + keyAlias: String, + challenge: ByteArray, + pubKeyAlg: COSEAlgorithmIdentifier, + isStrongBoxBacked: Boolean, + ): KeyPair { + val userAuthenticationValidityDurationSeconds = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + 0 + } else { + 5 + } + + return SecureExecutionHelper.generateKey( + keyAlias = keyAlias, + challenge = challenge, + publicKeyAlgorithm = pubKeyAlg, + useBiometricOnly = false, + userAuthenticationValidityDurationSeconds = userAuthenticationValidityDurationSeconds, + isStrongBoxBacked = isStrongBoxBacked + ) + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/authenticator/Fido2ObjectFactory.kt b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/Fido2ObjectFactory.kt new file mode 100644 index 0000000..465021b --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/authenticator/Fido2ObjectFactory.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.authenticator + +import com.lycorp.webauthn.model.AssertionObject +import com.lycorp.webauthn.model.AttestationObject +import com.lycorp.webauthn.model.AttestationStatement +import com.lycorp.webauthn.model.AttestationStatementFormats +import com.lycorp.webauthn.model.AttestedCredData +import com.lycorp.webauthn.model.AuthenticatorData +import com.lycorp.webauthn.model.AuthenticatorDataFlags +import com.lycorp.webauthn.model.AuthenticatorExtensionsOutput +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.EC2COSEKey +import com.lycorp.webauthn.util.SecureExecutionHelper +import com.lycorp.webauthn.util.base64urlToByteArray +import com.lycorp.webauthn.util.toBase64url +import java.security.MessageDigest +import java.security.Signature +import java.security.interfaces.ECPublicKey + +internal class Fido2ObjectFactory { + private val fmt = AttestationStatementFormats.ANDROID_KEY.value + + fun createAttestationObject( + hash: ByteArray, + rpId: String, + aaguid: ByteArray, + credId: String, + signCount: UInt, + signature: Signature, + extensions: AuthenticatorExtensionsOutput?, + ): AttestationObject { + val credIdBytes = credId.base64urlToByteArray() + val keyAlias = credId.toBase64url() + val publicKey = SecureExecutionHelper.getPublicKey(keyAlias) + val encodedCredPubKey = EC2COSEKey(publicKey as ECPublicKey) + .toCBOR() + val rpIdHash = MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray()) + val attestedCredData = AttestedCredData( + aaguid, + credIdBytes, + encodedCredPubKey + ) + val authenticatorData = + createAuthenticatorData( + signCount = signCount, + rpIdHash = rpIdHash, + extensions = extensions?.toCBOR(), + attestedCredData = attestedCredData, + ) + val authenticatorDataBytes = authenticatorData.toByteArray() + signature.update(authenticatorDataBytes + hash) + val sig = signature.sign() + + val certChain = SecureExecutionHelper.getX509Certificates(keyAlias) + val x5cBytesList = certChain.map { it.encoded } + val x5c = x5cBytesList + + val attStmt = + AttestationStatement( + alg = COSEAlgorithmIdentifier.ES256.value, + sig = sig, + x5c = x5c, + ) + return AttestationObject(authenticatorDataBytes, fmt, attStmt) + } + + fun createAssertionObject( + hash: ByteArray, + rpId: String, + signCount: UInt, + signature: Signature, + extensions: AuthenticatorExtensionsOutput?, + ): AssertionObject { + val rpIdHash = MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray()) + + val authenticatorData = + createAuthenticatorData( + signCount = signCount, + rpIdHash = rpIdHash, + extensions = extensions?.toCBOR(), + attestedCredData = null + ) + val authenticatorDataBytes = authenticatorData.toByteArray() + signature.update(authenticatorDataBytes + hash) + val sig = signature.sign() + + return AssertionObject( + authenticatorDataBytes, + sig, + ) + } + + private fun createAuthenticatorData( + signCount: UInt, + rpIdHash: ByteArray, + userPresent: Boolean = true, + userVerified: Boolean = true, + extensions: ByteArray? = null, + attestedCredData: AttestedCredData?, + ): AuthenticatorData { + val attestedCredDataBytes = attestedCredData?.toByteArray() + val flags = + createFlags( + userPresent, + userVerified, + attestedCredData != null, + extensions, + ) + return AuthenticatorData( + rpIdHash, + flags, + signCount, + attestedCredDataBytes, + extensions + ) + } + + private fun createFlags( + userPresent: Boolean, + userVerified: Boolean, + attestedCredDataIncluded: Boolean, + extensions: ByteArray?, + ): UByte { + val up = userPresent + val uv = userVerified + val at = attestedCredDataIncluded + val ed = extensions != null + return AuthenticatorDataFlags.makeFlags(up, uv, at, ed) + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/db/CredentialSourceStorage.kt b/webauthn/src/main/java/com/lycorp/webauthn/db/CredentialSourceStorage.kt new file mode 100644 index 0000000..499936b --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/db/CredentialSourceStorage.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.db + +import com.lycorp.webauthn.model.PublicKeyCredentialSource + +/** + * Interface for storing and managing credential sources. + */ +interface CredentialSourceStorage { + /** + * Stores the given credential source. + * + * @param credSource The credential source to store. + */ + fun store(credSource: PublicKeyCredentialSource) + + /** + * Loads the credential source corresponding to the given credential ID. + * + * @param credId The ID of the credential source to load. + * @return The loaded credential source, or null if not found. + */ + fun load(credId: String): PublicKeyCredentialSource? + + /** + * Loads all stored credential sources. + * + * @return A list of all stored credential sources. + */ + fun loadAll(): List + + /** + * Deletes the credential source corresponding to the given credential ID. + * + * @param credId The ID of the credential source to delete. + */ + fun delete(credId: String) + + /** + * Gets the signature counter for the given credential ID. + * + * @param credId The ID of the credential source whose signature counter is to be retrieved. + * @return The current value of the signature counter. + */ + fun getSignatureCounter(credId: String): UInt + + /** + * Increases the signature counter for the given credential ID. + * + * @param credId The ID of the credential source whose signature counter is to be increased. + */ + fun increaseSignatureCounter(credId: String) +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/exceptions/WebAuthnException.kt b/webauthn/src/main/java/com/lycorp/webauthn/exceptions/WebAuthnException.kt new file mode 100644 index 0000000..11b23e8 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/exceptions/WebAuthnException.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.exceptions + +sealed class WebAuthnException( + override val message: String?, + override val cause: Throwable? = null +) : Exception(message, cause) { + + sealed class CoreException(message: String?, cause: Throwable? = null) : WebAuthnException(message, cause) { + class ConstraintException( + message: String? = "A mutation operation in a transaction failed because a constraint was not satisfied.", + cause: Throwable? = null + ) : CoreException(message, cause) + class InvalidStateException( + message: String? = "The object is in an invalid state.", + cause: Throwable? = null + ) : CoreException(message, cause) + class NotAllowedException( + message: String? = "The request is not allowed by the user agent or the platform in the current context, " + + "possibly because the user denied permission.", + cause: Throwable? = null + ) : CoreException(message, cause) + class NotSupportedException( + message: String? = "The operation is not supported.", + cause: Throwable? = null + ) : CoreException(message, cause) + class TypeException( + message: String? = null, + cause: Throwable? = null + ) : CoreException(message, cause) + } + + class CredSrcStorageException(message: String?, cause: Throwable? = null) : WebAuthnException(message, cause) + class RpException(message: String? = null, cause: Throwable? = null) : WebAuthnException(message, cause) + class AuthenticationException( + message: String? = null, + cause: Throwable? = null + ) : WebAuthnException(message, cause) + class SecureExecutionException( + message: String? = null, + cause: Throwable? = null + ) : WebAuthnException(message, cause) + class KeyNotFoundException(message: String? = null, cause: Throwable? = null) : WebAuthnException(message, cause) + class UnknownException(message: String, cause: Throwable? = null) : WebAuthnException(message, cause) + class UtilityException(message: String, cause: Throwable? = null) : WebAuthnException(message, cause) + class EncodingException(message: String, cause: Throwable? = null) : WebAuthnException(message, cause) + + /** + * Error occurs when an exception is raised during the FIDO2 operation, + * triggering the deletion of intermediate data, + * but an issue arises during the deletion process. + * + * @param message The error message. + * @param cause The cause of the issue during the deletion process. + * @param trigger The exception that triggered the deletion. + */ + class DeletionException( + message: String, + cause: Throwable? = null, + trigger: Throwable? = null + ) : WebAuthnException(message, cause) +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/handler/AuthenticationHandler.kt b/webauthn/src/main/java/com/lycorp/webauthn/handler/AuthenticationHandler.kt new file mode 100644 index 0000000..ea206d3 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/handler/AuthenticationHandler.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.handler + +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.model.Fido2UserAuthResult +import java.security.Signature + +/** + * Interface for handling authentication operations in a WebAuthn context. + * Provides methods to check support for authentication and to perform authentication. + */ +interface AuthenticationHandler { + + /** + * Checks if the current device or environment supports the required authentication methods. + * + * @return True if the authentication method is supported, false otherwise. + */ + fun isSupported(): Boolean + + /** + * Performs user authentication, optionally using a provided signature and prompt information. + * + * @param signatureProvider A function that provides a signature for the authentication process. + * @param fido2PromptInfo The prompt information for FIDO2 authentication, if any. + * @return The result of the user authentication, encapsulated in a Fido2UserAuthResult object. + * @throws WebAuthnException If an error occurs during the authentication process. + */ + suspend fun authenticate(signatureProvider: () -> Signature, fido2PromptInfo: Fido2PromptInfo?): Fido2UserAuthResult +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/handler/BiometricAuthenticationHandler.kt b/webauthn/src/main/java/com/lycorp/webauthn/handler/BiometricAuthenticationHandler.kt new file mode 100644 index 0000000..96940c2 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/handler/BiometricAuthenticationHandler.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.handler + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.model.Fido2UserAuthResult +import java.security.Signature +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +internal class BiometricAuthenticationHandler( + private val activity: FragmentActivity, + private val authHandlerDispatcher: CoroutineDispatcher = Dispatchers.Main, +) : AuthenticationHandler { + class AuthenticationFailedException : Exception() + + class AuthenticationErrorException : Exception() + + override fun isSupported(): Boolean { + val biometricManager = BiometricManager.from(activity.applicationContext) + return biometricManager.canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG + ) == BiometricManager.BIOMETRIC_SUCCESS + } + + override suspend fun authenticate( + signatureProvider: () -> Signature, + fido2PromptInfo: Fido2PromptInfo? + ): Fido2UserAuthResult { + return authenticateUserWithBiometricPrompt(signatureProvider, fido2PromptInfo) + } + + private suspend fun authenticateUserWithBiometricPrompt( + signatureProvider: () -> Signature, + fido2PromptInfo: Fido2PromptInfo? + ): Fido2UserAuthResult = withContext(authHandlerDispatcher) { + suspendCancellableCoroutine { continuation -> + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(fido2PromptInfo?.title ?: "Biometric Authentication") + .setSubtitle(fido2PromptInfo?.subtitle ?: "Enter biometric credentials to proceed") + .setDescription( + fido2PromptInfo?.description + ?: "Input your Fingerprint or FaceID to ensure it's you!", + ) + .setNegativeButtonText(fido2PromptInfo?.negativeButtonText ?: "Cancel") + .build() + + val cryptoObject: BiometricPrompt.CryptoObject = BiometricPrompt.CryptoObject(signatureProvider()) + + val biometricPrompt = + BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity.applicationContext), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + if (continuation.isActive) { + continuation.resumeWith( + Result.success( + Fido2UserAuthResult( + signature = result.cryptoObject?.signature + ) + ) + ) + } + } + + override fun onAuthenticationFailed() { + // In the event of an authentication failure, the user is allowed to try again. + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (continuation.isActive) { + continuation.resumeWithException(AuthenticationErrorException()) + } + } + }, + ) + + continuation.invokeOnCancellation { + biometricPrompt.cancelAuthentication() + } + + biometricPrompt.authenticate(promptInfo, cryptoObject) + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/handler/DeviceCredentialAuthenticationHandler.kt b/webauthn/src/main/java/com/lycorp/webauthn/handler/DeviceCredentialAuthenticationHandler.kt new file mode 100644 index 0000000..c91bf61 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/handler/DeviceCredentialAuthenticationHandler.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.handler + +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.model.Fido2UserAuthResult +import java.security.Signature +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +internal class DeviceCredentialAuthenticationHandler( + private val activity: FragmentActivity, + private val authHandlerDispatcher: CoroutineDispatcher = Dispatchers.Main, +) : AuthenticationHandler { + class AuthenticationFailedException : Exception() + class AuthenticationErrorException : Exception() + + internal interface OnConfirmation { + fun onConfirmation(result: Boolean) + } + + private var onConfirmation: OnConfirmation? = null + private var confirmCredentialLauncher: ActivityResultLauncher = + activity.registerForActivityResult( + ConfirmDeviceCredentialContract(getKeyguardManager(activity.applicationContext)!!) + ) { result: Boolean -> + if (onConfirmation != null) { + onConfirmation!!.onConfirmation(result) + onConfirmation = null + } + } + + override fun isSupported(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // API level >= 30 + val biometricManager = BiometricManager.from(activity.applicationContext) + biometricManager.canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) == BiometricManager.BIOMETRIC_SUCCESS + } else { + // API level < 30 + val keyguardManager = getKeyguardManager(activity.applicationContext) ?: return false + return keyguardManager.isDeviceSecure + } + } + + override suspend fun authenticate( + signatureProvider: () -> Signature, + fido2PromptInfo: Fido2PromptInfo? + ): Fido2UserAuthResult { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + authenticateUserWithBiometricPrompt(signatureProvider, fido2PromptInfo) + } else { + authenticateUserWithKeyguardManager(signatureProvider, fido2PromptInfo) + } + } + + private suspend fun authenticateUserWithBiometricPrompt( + signatureProvider: () -> Signature, + fido2PromptInfo: Fido2PromptInfo? + ): Fido2UserAuthResult = withContext(authHandlerDispatcher) { + suspendCancellableCoroutine { continuation -> + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(fido2PromptInfo?.title ?: "Device Credential Authentication") + .setSubtitle(fido2PromptInfo?.subtitle ?: "Enter device credentials to proceed") + .setDescription( + fido2PromptInfo?.description + ?: "Input your Fingerprint or device credential to ensure it's you!", + ) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .build() + + val cryptoObject: BiometricPrompt.CryptoObject = BiometricPrompt.CryptoObject(signatureProvider()) + + val biometricPrompt = + BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity.applicationContext), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + continuation.resumeWith( + Result.success( + Fido2UserAuthResult( + signature = result.cryptoObject?.signature + ) + ) + ) + } + + override fun onAuthenticationFailed() { + continuation.resumeWithException(AuthenticationFailedException()) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + continuation.resumeWithException(AuthenticationErrorException()) + } + }, + ) + + continuation.invokeOnCancellation { + biometricPrompt.cancelAuthentication() + } + + biometricPrompt.authenticate(promptInfo, cryptoObject) + } + } + + private suspend fun authenticateUserWithKeyguardManager( + signatureProvider: () -> Signature, + fido2PromptInfo: Fido2PromptInfo? + ): Fido2UserAuthResult = withContext(authHandlerDispatcher) { + suspendCancellableCoroutine { continuation -> + val keyguardManager = getKeyguardManager(activity.applicationContext) + if (keyguardManager == null) { + Log.e("DeviceCredentialAuthenticator", "Could not get KeyguardManager") + throw AuthenticationErrorException() + } + + onConfirmation = object : OnConfirmation { + override fun onConfirmation(result: Boolean) { + if (result) { + val signature = signatureProvider() + continuation.resumeWith( + Result.success( + Fido2UserAuthResult( + signature = signature + ) + ) + ) + } else { + continuation.resumeWithException(AuthenticationFailedException()) + } + } + } + confirmCredentialLauncher.launch(fido2PromptInfo) + + continuation.invokeOnCancellation { + continuation.resumeWithException(AuthenticationErrorException()) + } + } + } + + private fun getKeyguardManager(context: Context): KeyguardManager? { + val keyguardManager = try { + context.getSystemService(KeyguardManager::class.java) + } catch (e: Exception) { + null + } + return keyguardManager + } +} + +class ConfirmDeviceCredentialContract( + private val keyguardManager: KeyguardManager +) : ActivityResultContract() { + override fun createIntent(context: Context, input: Fido2PromptInfo?): Intent { + return keyguardManager.createConfirmDeviceCredentialIntent( + input?.title ?: "Device Credential Authentication", + input?.description ?: "Input your Fingerprint or device credential to ensure it's you!" + ) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Boolean { + return resultCode == Activity.RESULT_OK + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AssertionObject.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AssertionObject.kt new file mode 100644 index 0000000..21d2cd5 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AssertionObject.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import co.nstant.`in`.cbor.builder.AbstractBuilder +import co.nstant.`in`.cbor.builder.MapBuilder + +class AssertionObject( + val authenticatorData: ByteArray, + val signature: ByteArray, +) : CborSerializable { + override fun ?> toCBOR(builder: MapBuilder): T { + return builder + .put("authenticatorData", this.authenticatorData) + .put("signature", this.signature) + .end() + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationConveyancePreference.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationConveyancePreference.kt new file mode 100644 index 0000000..ee47668 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationConveyancePreference.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +enum class AttestationConveyancePreference(val value: String) { + NONE("none"), + INDIRECT("indirect"), + DIRECT("direct"), + ; + + companion object { + fun fromValue(value: String): AttestationConveyancePreference? { + return AttestationConveyancePreference.values().find { it.value == value } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationObject.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationObject.kt new file mode 100644 index 0000000..0152748 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationObject.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import co.nstant.`in`.cbor.builder.AbstractBuilder +import co.nstant.`in`.cbor.builder.MapBuilder + +/** + * Represents an Attestation Object in the WebAuthn process. + * + * This class corresponds to the attestation object as defined in the Web Authentication: An API for accessing Public Key Credentials Level 2 specification. + * For more details, see the specification: [Web Authentication: Level 2 - Attestation Object](https://www.w3.org/TR/webauthn-2/#attestation-object) + * + * AttestationObject: + * | authData | fmt | attStmt | + * |--------------|------|-----------| + * | variable size| text | map-based | + */ +data class AttestationObject( + val authData: ByteArray, + val fmt: String, + val attStmt: AttestationStatement, +) : CborSerializable { + override fun ?> toCBOR(builder: MapBuilder): T { + builder.put("fmt", fmt) + attStmt.toCBOR(builder.startMap("attStmt")) + builder.put("authData", authData) + return builder.end() + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationStatement.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationStatement.kt new file mode 100644 index 0000000..59a4cc4 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AttestationStatement.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import android.security.keystore.KeyProperties +import co.nstant.`in`.cbor.builder.AbstractBuilder +import co.nstant.`in`.cbor.builder.MapBuilder +import java.security.spec.AlgorithmParameterSpec +import java.security.spec.ECGenParameterSpec + +data class AttestationStatement( + val alg: Long, + val sig: ByteArray, + val x5c: List, +) : CborSerializable { + override fun ?> toCBOR(builder: MapBuilder): T { + return builder + .put("alg", alg) + .put("sig", sig) + .putArray("x5c").also { + for (i in x5c) { + it.add(i) + } + } + .end() + .end() + } +} + +enum class AttestationStatementFormats(val value: String) { + PACKED("packed"), + TPM("tpm"), + ANDROID_KEY("android-key"), + ANDROID_SAFETYNET("android-safetynet"), + FIDO_U2F("fido-u2f"), +} + +enum class COSEAlgorithmIdentifier(val value: Long) { + RS1(-65535), // RSASSA-PKCS1-v1_5 with SHA-1 + RS256(-257), // RSASSA-PKCS1-v1_5 with SHA-256 + RS384(-258), // RSASSA-PKCS1-v1_5 with SHA-384 + RS512(-259), // RSASSA-PKCS1-v1_5 with SHA-512 + PS256(-37), // RSASSA-PSS with SHA-256 + PS384(-38), // RSASSA-PSS with SHA-384 + PS512(-39), // RSASSA-PSS with SHA-512 + EdDSA(-8), // EdDSA + ES256(-7), // ECDSA with SHA-256 + ES384(-35), // ECDSA with SHA-384 + ES512(-36), // ECDSA with SHA-512 + ES256K(-43), // ECDSA using P-256K and SHA-256 + ; + + companion object { + fun fromValue(value: Long): COSEAlgorithmIdentifier? { + return COSEAlgorithmIdentifier.values().find { it.value == value } + } + } +} +fun COSEAlgorithmIdentifier.getSignatureAlgorithmName(): String? { + val correspondingSignatureAlgorithm = SignatureAlgorithms.values().find { it.name == this.name } + return correspondingSignatureAlgorithm?.algName +} + +fun COSEAlgorithmIdentifier.getDigests(): String? { + return when (this) { + COSEAlgorithmIdentifier.RS1 -> KeyProperties.DIGEST_SHA1 + COSEAlgorithmIdentifier.RS256 -> KeyProperties.DIGEST_SHA256 + COSEAlgorithmIdentifier.RS384 -> KeyProperties.DIGEST_SHA384 + COSEAlgorithmIdentifier.RS512 -> KeyProperties.DIGEST_SHA512 + COSEAlgorithmIdentifier.PS256 -> KeyProperties.DIGEST_SHA256 + COSEAlgorithmIdentifier.PS384 -> KeyProperties.DIGEST_SHA384 + COSEAlgorithmIdentifier.PS512 -> KeyProperties.DIGEST_SHA512 + COSEAlgorithmIdentifier.EdDSA -> KeyProperties.DIGEST_SHA512 + COSEAlgorithmIdentifier.ES256 -> KeyProperties.DIGEST_SHA256 + COSEAlgorithmIdentifier.ES384 -> KeyProperties.DIGEST_SHA384 + COSEAlgorithmIdentifier.ES512 -> KeyProperties.DIGEST_SHA512 + COSEAlgorithmIdentifier.ES256K -> KeyProperties.DIGEST_SHA256 + } +} + +fun COSEAlgorithmIdentifier.getSignaturePaddings(): String? { + return when (this) { + COSEAlgorithmIdentifier.RS1 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 + COSEAlgorithmIdentifier.RS256 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 + COSEAlgorithmIdentifier.RS384 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 + COSEAlgorithmIdentifier.RS512 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 + COSEAlgorithmIdentifier.PS256 -> KeyProperties.SIGNATURE_PADDING_RSA_PSS + COSEAlgorithmIdentifier.PS384 -> KeyProperties.SIGNATURE_PADDING_RSA_PSS + COSEAlgorithmIdentifier.PS512 -> KeyProperties.SIGNATURE_PADDING_RSA_PSS + COSEAlgorithmIdentifier.EdDSA -> null + COSEAlgorithmIdentifier.ES256 -> null + COSEAlgorithmIdentifier.ES384 -> null + COSEAlgorithmIdentifier.ES512 -> null + COSEAlgorithmIdentifier.ES256K -> null + } +} + +fun COSEAlgorithmIdentifier.getAlgorithmParameterSpec(): AlgorithmParameterSpec? { + return when (this) { + COSEAlgorithmIdentifier.RS1 -> null + COSEAlgorithmIdentifier.RS256 -> null + COSEAlgorithmIdentifier.RS384 -> null + COSEAlgorithmIdentifier.RS512 -> null + COSEAlgorithmIdentifier.PS256 -> null + COSEAlgorithmIdentifier.PS384 -> null + COSEAlgorithmIdentifier.PS512 -> null + COSEAlgorithmIdentifier.EdDSA -> null + COSEAlgorithmIdentifier.ES256 -> ECGenParameterSpec("secp256r1") + COSEAlgorithmIdentifier.ES384 -> ECGenParameterSpec("secp384r1") + COSEAlgorithmIdentifier.ES512 -> ECGenParameterSpec("secp512r1") + COSEAlgorithmIdentifier.ES256K -> ECGenParameterSpec("secp256k1") + } +} + +fun COSEAlgorithmIdentifier.getKeyProperties(): String? { + return when (this) { + COSEAlgorithmIdentifier.RS1 -> KeyProperties.KEY_ALGORITHM_RSA + COSEAlgorithmIdentifier.RS256 -> KeyProperties.KEY_ALGORITHM_RSA + COSEAlgorithmIdentifier.RS384 -> KeyProperties.KEY_ALGORITHM_RSA + COSEAlgorithmIdentifier.RS512 -> KeyProperties.KEY_ALGORITHM_RSA + COSEAlgorithmIdentifier.PS256 -> KeyProperties.KEY_ALGORITHM_RSA + COSEAlgorithmIdentifier.PS384 -> KeyProperties.KEY_ALGORITHM_RSA + COSEAlgorithmIdentifier.PS512 -> KeyProperties.KEY_ALGORITHM_RSA + COSEAlgorithmIdentifier.EdDSA -> KeyProperties.KEY_ALGORITHM_EC + COSEAlgorithmIdentifier.ES256 -> KeyProperties.KEY_ALGORITHM_EC + COSEAlgorithmIdentifier.ES384 -> KeyProperties.KEY_ALGORITHM_EC + COSEAlgorithmIdentifier.ES512 -> KeyProperties.KEY_ALGORITHM_EC + COSEAlgorithmIdentifier.ES256K -> KeyProperties.KEY_ALGORITHM_EC + } +} + +enum class SignatureAlgorithms(val algName: String) { + RS1("SHA1withRSA"), + RS256("SHA256withRSA"), + RS384("SHA384withRSA"), + RS512("SHA512withRSA"), + PS256("SHA256withRSA/PSS"), + PS384("SHA384withRSA/PSS"), + PS512("SHA512withRSA/PSS"), + EdDSA("EdDSA"), + ES256("SHA256withECDSA"), + ES384("SHA384withECDSA"), + ES512("SHA512withECDSA"), + ES256K("SHA256withECDSAinP256K"), +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticationExtensions.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticationExtensions.kt new file mode 100644 index 0000000..0cfcab2 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticationExtensions.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import co.nstant.`in`.cbor.builder.AbstractBuilder +import co.nstant.`in`.cbor.builder.MapBuilder + +// NOTE: This file contains several dummy methods and classes. +// These are made to support authenticator extensions if they are needed in the future. +// If an authenticator extension is required, the following interfaces and methods should be properly implemented. + +// AuthenticatorExtensionInput is not currently implemented. (Therefore, it is always treated as null in code now.) +interface AuthenticatorExtensionsInput : CborSerializable { + override fun ?> toCBOR(builder: MapBuilder): T +} + +class AuthenticatorExtensionsOutput : CborSerializable { + companion object { + fun getAuthenticatorExtensionResult( + authenticatorExtensionsInput: AuthenticatorExtensionsInput? = null, + ): AuthenticatorExtensionsOutput? { + // Need to be implemented when authenticator extension is used. + return null + } + } + + override fun ?> toCBOR(builder: MapBuilder): T { + // Need to be implemented when authenticator extension is used. + return builder.end() + } +} + +class ClientExtensionInput { + + fun processAuthenticatorExtensionsInput(): AuthenticatorExtensionsInput? { + // Need to be implemented when authenticator extension is used. + return null + } + + fun processClientExtensionsOutput(): ClientExtensionsOutput { + // Need to be implemented when client extension is used. + return ClientExtensionsOutput() + } +} + +class ClientExtensionsOutput diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorAttachment.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorAttachment.kt new file mode 100644 index 0000000..37fcd38 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorAttachment.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +enum class AuthenticatorAttachment(val value: String) { + PLATFORM("platform"), + CROSS_PLATFORM("cross-platform"), + ; + + companion object { + fun fromValue(value: String): AuthenticatorAttachment? { + return AuthenticatorAttachment.values().find { it.name == value } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorData.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorData.kt new file mode 100644 index 0000000..8e9bc7d --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorData.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import co.nstant.`in`.cbor.builder.AbstractBuilder +import co.nstant.`in`.cbor.builder.MapBuilder +import com.lycorp.webauthn.exceptions.WebAuthnException +import java.nio.ByteBuffer +import java.security.interfaces.ECPublicKey + +/** + * Represents Authenticator Data in the WebAuthn process. + * + * This class corresponds to the authenticator data as defined in the Web Authentication: An API for accessing Public Key Credentials Level 2 specification. + * For more details, see the specification: [Web Authentication: Level 2 - Authenticator Data](https://www.w3.org/TR/webauthn-2/#authenticator-data) + * + * AuthenticatorData: + * | rpIdHash | flags | signCount | attestedCredData | extensions | + * |----------|---------|-----------|------------------|---------------| + * | 32 bytes | 1 byte | 4 bytes | variable size | variable size | + */ +data class AuthenticatorData( + val rpIdHash: ByteArray, + var flags: UByte, + val signCount: UInt, + val attestedCredData: ByteArray? = null, + val extensions: ByteArray? = null, +) { + fun toByteArray(): ByteArray { + if (attestedCredData != null) { + val atFlag = AuthenticatorDataFlags.AT.value + flags = flags or atFlag + } + if (extensions != null) { + val edFlag = AuthenticatorDataFlags.ED.value + flags = flags or edFlag + } + + try { + return ByteBuffer.allocate( + rpIdHash.size + + 1 + + 4 + + (attestedCredData?.size ?: 0) + + (extensions?.size ?: 0), + ).apply { + put(rpIdHash) + put(flags.toByte()) + putInt(signCount.toInt()) + attestedCredData?.let { put(it) } + extensions?.let { put(it) } + }.array() + } catch (e: Exception) { + throw WebAuthnException.EncodingException("Cannot convert AuthenticatorData to ByteArray", e) + } + } +} + +/** + * Represents Attested Credential Data in the WebAuthn process. + * + * This class corresponds to the attested credential data as defined in the Web Authentication: An API for accessing Public Key Credentials Level 2 specification. + * For more details, see the specification: [Web Authentication: Level 2 - Attested Credential Data](https://www.w3.org/TR/webauthn-2/#attested-credential-data) + * + * AttestedCredentialData: + * | aaguid | credIdLength | credID | publicKey | + * |----------|--------------|-----------------|---------------| + * | 16 bytes | 2 bytes | credID.length() | variable size | + */ +class AttestedCredData( + val aaguid: ByteArray, + val credId: ByteArray, + val publicKey: ByteArray, +) { + fun toByteArray(): ByteArray { + try { + return ByteBuffer.allocate(aaguid.size + 2 + credId.size + publicKey.size).apply { + put(aaguid) + putShort(credId.size.toShort()) + put(credId) + put(publicKey) + }.array() + } catch (e: Exception) { + throw WebAuthnException.EncodingException("Cannot convert AttestedCredData to ByteArray", e) + } + } +} + +class EC2COSEKey( + var kty: Int, + var alg: Int, + var crv: Int, + var x: ByteArray, + var y: ByteArray, +) : CborSerializable { + constructor(ecPublicKey: ECPublicKey) : this( + kty = 2, + alg = -7, + crv = 1, + x = ecPublicKey.w.affineX.toByteArray(), + y = ecPublicKey.w.affineY.toByteArray(), + ) + + override fun ?> toCBOR(builder: MapBuilder): T { + return builder + .put(1, kty.toLong()) + .put(3, alg.toLong()) + .put(-1, crv.toLong()) + .put(-2, x) + .put(-3, y) + .end() + } +} + +enum class AuthenticatorDataFlags(val value: UByte) { + UP(0b0000_0001u), + UV(0b0000_0100u), + AT(0b0100_0000u), + ED(0b1000_0000u), + ; + + companion object { + fun makeFlags( + userPresent: Boolean = false, + userVerified: Boolean = false, + attestedCredentialDataIncluded: Boolean = false, + extensionDataIncluded: Boolean = false, + ): UByte { + var flags: UByte = 0u + if (userPresent) flags = flags or UP.value + if (userVerified) flags = flags or UV.value + if (attestedCredentialDataIncluded) flags = flags or AT.value + if (extensionDataIncluded) flags = flags or ED.value + return flags + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorGetAssertionResult.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorGetAssertionResult.kt new file mode 100644 index 0000000..bf48d6d --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorGetAssertionResult.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +data class AuthenticatorGetAssertionResult( + val credentialId: ByteArray, + val authenticatorData: ByteArray, + val signature: ByteArray, + val userHandle: ByteArray?, +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorMakeCredentialResult.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorMakeCredentialResult.kt new file mode 100644 index 0000000..9b50ec1 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorMakeCredentialResult.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +data class AuthenticatorMakeCredentialResult( + val credentialId: ByteArray, + val attestationObject: ByteArray, +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorResponse.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorResponse.kt new file mode 100644 index 0000000..fb8e2cf --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorResponse.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +interface AuthenticatorResponse { + val clientDataJSON: ByteArray +} + +class AuthenticatorAttestationResponse( + override val clientDataJSON: ByteArray, + val attestationObject: ByteArray, +) : AuthenticatorResponse + +class AuthenticatorAssertionResponse( + val authenticatorData: ByteArray, + val signature: ByteArray, + val userHandle: ByteArray?, + override val clientDataJSON: ByteArray, +) : AuthenticatorResponse diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorSelectionCriteria.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorSelectionCriteria.kt new file mode 100644 index 0000000..068ccab --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorSelectionCriteria.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +data class AuthenticatorSelectionCriteria( + val authenticatorAttachment: String?, + val userVerification: String = UserVerificationRequirement.PREFERRED.value, +) + +enum class UserVerificationRequirement(val value: String) { + REQUIRED("required"), + PREFERRED("preferred"), + ; + + companion object { + fun fromValue(value: String): UserVerificationRequirement? { + return UserVerificationRequirement.values().find { it.value == value } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorTransport.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorTransport.kt new file mode 100644 index 0000000..2500f60 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorTransport.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +enum class AuthenticatorTransport(val value: String) { + BLE("ble"), + INTERNAL("internal"), + ; + + companion object { + fun fromValue(value: String): AuthenticatorTransport? { + return AuthenticatorTransport.values().find { it.value == value } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorType.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorType.kt new file mode 100644 index 0000000..732c8d0 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/AuthenticatorType.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID + +enum class AuthenticatorType(val authenticatorName: String, val aaguid: UUID) { + Biometric("Biometric", UUID.fromString("8c120a4d-52b3-99ef-eaf6-7cfb2a3e3f89")), + Device("Device", UUID.fromString("2b7a96a3-f571-ee4c-632c-c5458dfadfe3")), + ; + + fun aaguidBytes(): ByteArray { + return ByteBuffer.wrap(ByteArray(16)).apply { + order(ByteOrder.BIG_ENDIAN) + putLong(aaguid.mostSignificantBits) + putLong(aaguid.leastSignificantBits) + }.array() + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/CborSerializable.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/CborSerializable.kt new file mode 100644 index 0000000..b91a2c4 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/CborSerializable.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import co.nstant.`in`.cbor.CborBuilder +import co.nstant.`in`.cbor.CborEncoder +import co.nstant.`in`.cbor.builder.AbstractBuilder +import co.nstant.`in`.cbor.builder.MapBuilder +import com.lycorp.webauthn.exceptions.WebAuthnException +import java.io.ByteArrayOutputStream + +interface CborSerializable { + fun ?> toCBOR(builder: MapBuilder): T + + fun toCBOR(canonical: Boolean = true): ByteArray { + try { + val baos = ByteArrayOutputStream() + CborEncoder(baos).encode(toCBOR(CborBuilder().startMap()).build()) + return baos.toByteArray() + } catch (e: Exception) { + throw WebAuthnException.EncodingException("Cannot convert Attestation Object to CBOR.", e) + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/CollectedClientData.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/CollectedClientData.kt new file mode 100644 index 0000000..bbd4645 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/CollectedClientData.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +data class CollectedClientData( + val type: String, + val challenge: String, + val origin: String, +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/CredentialProtection.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/CredentialProtection.kt new file mode 100644 index 0000000..4550c6e --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/CredentialProtection.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +class CredentialProtection { + val credentialProtectionPolicy: String = "USER_VERIFICATION_REQUIRED" + val enforceCredentialProtectionPolicy: Boolean = true +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/Fido2PromptInfo.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/Fido2PromptInfo.kt new file mode 100644 index 0000000..79a12bd --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/Fido2PromptInfo.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +data class Fido2PromptInfo( + val title: CharSequence? = "Biometric Authentication", + val subtitle: CharSequence? = "Enter biometric credentials to proceed", + val description: CharSequence? = "Input your Fingerprint or FaceID to ensure it's you!", + val negativeButtonText: CharSequence? = "Cancel", +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/Fido2UserAuthResult.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/Fido2UserAuthResult.kt new file mode 100644 index 0000000..e4ec21b --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/Fido2UserAuthResult.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import java.security.Signature + +data class Fido2UserAuthResult( + var signature: Signature? +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialCreationOptions.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialCreationOptions.kt new file mode 100644 index 0000000..5ab04b9 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialCreationOptions.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +class PublicKeyCredentialCreationOptions( + val rp: PublicKeyCredentialRpEntity, + val user: PublicKeyCredentialUserEntity, + val challenge: String, + val publicKeyCredentialParams: List, + val excludeCredentials: List?, + val authenticatorSelection: AuthenticatorSelectionCriteria?, + val attestation: AttestationConveyancePreference = AttestationConveyancePreference.DIRECT, + val extensions: ClientExtensionInput?, +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialDescriptor.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialDescriptor.kt new file mode 100644 index 0000000..cb2dc8f --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialDescriptor.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +class PublicKeyCredentialDescriptor( + var type: String, + var id: String, + var transports: List? = listOf(AuthenticatorTransport.INTERNAL), +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialParams.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialParams.kt new file mode 100644 index 0000000..b835aa6 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialParams.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +data class PublicKeyCredentialParams( + val type: PublicKeyCredentialType, + val alg: COSEAlgorithmIdentifier, +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialRequestOptions.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialRequestOptions.kt new file mode 100644 index 0000000..d610977 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialRequestOptions.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +class PublicKeyCredentialRequestOptions( + val challenge: String, + val rpId: String, + val allowCredentials: List?, + val userVerification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED, + val extensions: ClientExtensionInput?, +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialResult.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialResult.kt new file mode 100644 index 0000000..ef88e57 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialResult.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import com.lycorp.webauthn.util.base64urlToByteArray + +/** + * The result of the WebAuthn registration process. + * + * @property id Base64url encoding of the credential ID. + * @property authenticatorAttestationResponse The authenticator's attestation response. + * @property clientExtensionsOutput Optional client extension outputs. + * @property rawId ByteArray representation of the credential ID. + * @property type The type of public key credential. + */ +class PublicKeyCredentialCreateResult( + val id: String, + val authenticatorAttestationResponse: AuthenticatorAttestationResponse, + val clientExtensionsOutput: ClientExtensionsOutput? = null, +) { + var rawId: ByteArray = id.base64urlToByteArray() + val type: String = PublicKeyCredentialType.PUBLIC_KEY.value +} + +/** + * The result of the WebAuthn authentication process. + * + * @property id Base64url encoding of the credential ID. + * @property authenticatorAssertionResponse The authenticator's assertion response. + * @property clientExtensionsOutput Optional client extension outputs. + * @property rawId ByteArray representation of the credential ID. + * @property type The type of public key credential. + */ +class PublicKeyCredentialGetResult( + val id: String, + val authenticatorAssertionResponse: AuthenticatorAssertionResponse, + val clientExtensionsOutput: ClientExtensionsOutput? = null, +) { + var rawId: ByteArray = id.base64urlToByteArray() + val type: String = PublicKeyCredentialType.PUBLIC_KEY.value +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialRpEntity.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialRpEntity.kt new file mode 100644 index 0000000..f1f2f86 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialRpEntity.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +class PublicKeyCredentialRpEntity( + val id: String, + val name: String, +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialSource.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialSource.kt new file mode 100644 index 0000000..6afb93b --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialSource.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +import java.util.UUID + +data class PublicKeyCredentialSource( + val type: String = PublicKeyCredentialType.PUBLIC_KEY.value, + var id: String, + val rpId: String, + val userHandle: String?, // base64url encoding of the user handle + val aaguid: UUID, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PublicKeyCredentialSource + + if (type != other.type) return false + if (!id.contentEquals(other.id)) return false + if (rpId != other.rpId) return false + if (userHandle != null) { + if (other.userHandle == null) return false + if (!userHandle.contentEquals(other.userHandle)) return false + } else if (other.userHandle != null) { + return false + } + return aaguid == other.aaguid + } + + override fun hashCode(): Int { + var result = type.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + rpId.hashCode() + result = 31 * result + (userHandle?.hashCode() ?: 0) + result = 31 * result + aaguid.hashCode() + return result + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialType.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialType.kt new file mode 100644 index 0000000..86d4503 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialType.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +enum class PublicKeyCredentialType(val value: String) { + PUBLIC_KEY("public-key"), + ; + + companion object { + fun fromValue(value: String): PublicKeyCredentialType? { + return values().find { it.value == value } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialUserEntity.kt b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialUserEntity.kt new file mode 100644 index 0000000..085d7b6 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/model/PublicKeyCredentialUserEntity.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.model + +data class PublicKeyCredentialUserEntity( + val id: String, // same as user handle + val name: String, + val displayName: String, +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/Biometric.kt b/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/Biometric.kt new file mode 100644 index 0000000..9d57757 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/Biometric.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.publickeycredential + +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.authenticator.AuthenticatorProvider +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.rp.RelyingParty +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +class Biometric( + rpClient: RelyingParty, + private val activity: FragmentActivity, + db: CredentialSourceStorage, + authType: AuthenticatorType = AuthenticatorType.Biometric, + relyingPartyDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val databaseDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val authenticationDispatcher: CoroutineDispatcher = Dispatchers.Main, + authenticatorProvider: AuthenticatorProvider = AuthenticatorProvider( + activity, + db, + databaseDispatcher, + authenticationDispatcher + ), +) : PublicKeyCredential( + rpClient, + activity, + db, + authType, + relyingPartyDispatcher, + databaseDispatcher, + authenticationDispatcher, + authenticatorProvider +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/DeviceCredential.kt b/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/DeviceCredential.kt new file mode 100644 index 0000000..6448976 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/DeviceCredential.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.publickeycredential + +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.authenticator.AuthenticatorProvider +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.rp.RelyingParty +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +class DeviceCredential( + rpClient: RelyingParty, + private val activity: FragmentActivity, + db: CredentialSourceStorage, + authType: AuthenticatorType = AuthenticatorType.Device, + relyingPartyDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val databaseDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val authenticationDispatcher: CoroutineDispatcher = Dispatchers.Main, + authenticatorProvider: AuthenticatorProvider = AuthenticatorProvider( + activity, + db, + databaseDispatcher, + authenticationDispatcher + ), +) : PublicKeyCredential( + rpClient, + activity, + db, + authType, + relyingPartyDispatcher, + databaseDispatcher, + authenticationDispatcher, + authenticatorProvider +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/PublicKeyCredential.kt b/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/PublicKeyCredential.kt new file mode 100644 index 0000000..97d678c --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/publickeycredential/PublicKeyCredential.kt @@ -0,0 +1,428 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.publickeycredential + +import androidx.fragment.app.FragmentActivity +import com.google.gson.Gson +import com.lycorp.webauthn.authenticator.Authenticator +import com.lycorp.webauthn.authenticator.AuthenticatorProvider +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.exceptions.WebAuthnException +import com.lycorp.webauthn.model.AuthenticatorAssertionResponse +import com.lycorp.webauthn.model.AuthenticatorAttestationResponse +import com.lycorp.webauthn.model.AuthenticatorGetAssertionResult +import com.lycorp.webauthn.model.AuthenticatorMakeCredentialResult +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.CollectedClientData +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.model.PublicKeyCredentialCreateResult +import com.lycorp.webauthn.model.PublicKeyCredentialCreationOptions +import com.lycorp.webauthn.model.PublicKeyCredentialGetResult +import com.lycorp.webauthn.model.PublicKeyCredentialParams +import com.lycorp.webauthn.model.PublicKeyCredentialRequestOptions +import com.lycorp.webauthn.model.PublicKeyCredentialSource +import com.lycorp.webauthn.model.PublicKeyCredentialType +import com.lycorp.webauthn.rp.AuthenticationData +import com.lycorp.webauthn.rp.AuthenticationOptions +import com.lycorp.webauthn.rp.RegistrationData +import com.lycorp.webauthn.rp.RegistrationOptions +import com.lycorp.webauthn.rp.RelyingParty +import com.lycorp.webauthn.util.Fido2Util +import com.lycorp.webauthn.util.SecureExecutionHelper +import com.lycorp.webauthn.util.toBase64url +import java.security.MessageDigest +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Abstract class representing a PublicKeyCredential for WebAuthn operations. + * Provides methods for credential creation, authentication, and account management. + */ +abstract class PublicKeyCredential( + private val rpClient: RelyingParty, + private val activity: FragmentActivity, + private val db: CredentialSourceStorage, + private val authType: AuthenticatorType, + private val relyingPartyDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val databaseDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val authenticationDispatcher: CoroutineDispatcher = Dispatchers.Main, + private val authenticatorProvider: AuthenticatorProvider = AuthenticatorProvider( + activity, + db, + databaseDispatcher, + authenticationDispatcher + ), +) { + companion object { + /** + * Mutex to ensure that create and get operations are thread-safe. + */ + private val mutex = Mutex() + } + + /** + * Initiates the registration process for a new credential. + * + * @param options The registration options provided by the relying party. + * @param fido2PromptInfo Optional prompt information for FIDO2 authentication. + * @return Result of the registration process. + * @throws WebAuthnException.RpException If there is an error obtaining or verifying registration data from the relying party. + * @throws WebAuthnException.DeletionException If there is an error deleting keys during cleanup. + * @throws WebAuthnException If an error occurs in the authenticator during the registration process. + */ + suspend fun create(options: RegistrationOptions, fido2PromptInfo: Fido2PromptInfo? = null): Result = + mutex.withLock { + runCatching { + val registrationData: RegistrationData = try { + withContext(relyingPartyDispatcher) { + rpClient.getRegistrationData(options) + } + } catch (e: Throwable) { + throw WebAuthnException.RpException( + "Error occurred while getting registration data from rp: $e", + e + ) + } + + val createResult: PublicKeyCredentialCreateResult = publicKeyCredentialCreate( + PublicKeyCredentialCreationOptions( + rp = registrationData.rp, + user = registrationData.user, + challenge = registrationData.challenge, + publicKeyCredentialParams = registrationData.pubKeyCredParams, + excludeCredentials = registrationData.excludeCredentials, + authenticatorSelection = registrationData.authenticatorSelection, + attestation = registrationData.attestation, + extensions = registrationData.extensions, + ), + fido2PromptInfo + ) + + try { + withContext(relyingPartyDispatcher) { + rpClient.verifyRegistration(createResult) + } + } catch (e: Throwable) { + val rpException = WebAuthnException.RpException( + "Error occurred while verifying registration data from rp: $e", + e + ) + + try { + retryCleanup(createResult.id, maxTries = 2, delayMillis = 1000) + } catch (e2: Throwable) { + throw WebAuthnException.DeletionException( + "Error occurred while deleting key: $e2", + cause = e2, + trigger = rpException + ) + } + throw rpException + } + } + } + + /** + * Initiates the authentication process for an existing credential. + * + * @param options The authentication options provided by the relying party. + * @param fido2PromptInfo Optional prompt information for FIDO2 authentication. + * @return Result of the authentication process. + * @throws WebAuthnException.RpException If there is an error obtaining or verifying authentication data from the relying party. + * @throws WebAuthnException If an error occurs in the authenticator during the authentication process. + */ + suspend fun get(options: AuthenticationOptions, fido2PromptInfo: Fido2PromptInfo? = null): Result = + mutex.withLock { + runCatching { + val authenticationData: AuthenticationData = try { + withContext(relyingPartyDispatcher) { + rpClient.getAuthenticationData(options) + } + } catch (e: Throwable) { + throw WebAuthnException.RpException( + "Error occurred while getting authentication data from rp: $e", + e + ) + } + + val getResult = publicKeyCredentialGet( + PublicKeyCredentialRequestOptions( + challenge = authenticationData.challenge, + rpId = authenticationData.rpId, + allowCredentials = authenticationData.allowCredentials, + userVerification = authenticationData.userVerification, + extensions = authenticationData.extensions, + ), + fido2PromptInfo + ) + + try { + withContext(relyingPartyDispatcher) { + rpClient.verifyAuthentication(getResult) + } + } catch (e: Throwable) { + throw WebAuthnException.RpException( + "Error occurred while verifying authentication data from rp: $e", + e + ) + } + } + } + + /** + * Retrieves all registered accounts. + * + * @return List of all registered PublicKeyCredentialSource. + * @throws WebAuthnException.CredSrcStorageException If there is an error loading credentials from the database. + */ + suspend fun getAllAccounts(): List { + val result = mutableListOf() + AuthenticatorType.values().forEach { type -> + val authenticator = authenticatorProvider.getAuthenticator( + authType = type, + fido2PromptInfo = null, + ) + val credentials: List = try { + withContext(databaseDispatcher) { + authenticator.db.loadAll() + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException( + "Failed to load credentials for authenticator type: $type", + e + ) + } + result.addAll(credentials) + } + return result + } + + /** + * Deletes all registered accounts. + * + * @throws WebAuthnException.CredSrcStorageException If there is an error loading or deleting credentials from the database. + */ + suspend fun deleteAllAccounts() { + AuthenticatorType.values().forEach { type -> + val authenticator = authenticatorProvider.getAuthenticator( + authType = type, + fido2PromptInfo = null, + ) + + try { + withContext(databaseDispatcher) { + authenticator.db.loadAll().forEach { credential -> + authenticator.db.delete(credential.id) + } + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException("Failed to load and delete all credentials", e) + } + } + } + + /** + * Creates a new public key credential. + * + * This method implements the `create` operation as defined in the Web Authentication: An API for accessing Public Key Credentials Level 2 specification. + * For more details, see the specification: [Web Authentication: Level 2 - Create](https://www.w3.org/TR/webauthn-2/#sctn-createCredential) + * + * @param options The public key credential creation options. + * @param fido2PromptInfo Optional prompt information for FIDO2 authentication. + * @return The result of the credential creation process. + * @throws WebAuthnException If there is an error during the creation process. + */ + private suspend fun publicKeyCredentialCreate( + options: PublicKeyCredentialCreationOptions, + fido2PromptInfo: Fido2PromptInfo? = null + ): PublicKeyCredentialCreateResult { + try { + if (options.user.id.length !in 1..64) { + throw WebAuthnException.CoreException.TypeException( + "The length of the user id must be between 1 and 64." + ) + } + + val credTypesAndPubKeyAlgs = processCredTypesAndPubKeyAlgs(options) + + val collectedClientData = + CollectedClientData( + type = "webauthn.create", + challenge = options.challenge, + origin = Fido2Util.getPackageFacetID(activity.applicationContext), + ) + val clientDataJSON: ByteArray = Gson().toJson(collectedClientData).toByteArray() + val clientDataHash: ByteArray = + MessageDigest.getInstance("SHA-256").digest(clientDataJSON) + + val authenticator: Authenticator = authenticatorProvider.getAuthenticator( + authType = authType, + fido2PromptInfo = fido2PromptInfo + ) + + val authMakeCredResult: AuthenticatorMakeCredentialResult = authenticator.makeCredential( + hash = clientDataHash, + rpEntity = options.rp, + userEntity = options.user, + credTypesAndPubKeyAlgs = credTypesAndPubKeyAlgs, + excludeCredDescriptorList = options.excludeCredentials, + extensions = options.extensions?.processAuthenticatorExtensionsInput(), + ).getOrThrow() + + return PublicKeyCredentialCreateResult( + id = authMakeCredResult.credentialId.toBase64url(), + authenticatorAttestationResponse = + AuthenticatorAttestationResponse( + clientDataJSON = clientDataJSON, + attestationObject = authMakeCredResult.attestationObject, + ), + clientExtensionsOutput = options.extensions?.processClientExtensionsOutput(), + ) + } catch (e: Exception) { + if (e is WebAuthnException) { + throw e + } else { + throw WebAuthnException.UnknownException( + "Error occurred while creating public key credential: $e", + e + ) + } + } + } + + /** + * Retrieves an existing public key credential. + * + * This method implements the `get` operation as defined in the Web Authentication: An API for accessing Public Key Credentials Level 2 specification. + * For more details, see the specification: [Web Authentication: Level 2 - Get](https://www.w3.org/TR/webauthn-2/#sctn-getAssertion) + * + * @param options The public key credential request options. + * @param fido2PromptInfo Optional prompt information for FIDO2 authentication. + * @return The result of the credential retrieval process. + * @throws WebAuthnException If there is an error during the retrieval process. + */ + private suspend fun publicKeyCredentialGet( + options: PublicKeyCredentialRequestOptions, + fido2PromptInfo: Fido2PromptInfo? = null + ): PublicKeyCredentialGetResult { + val collectedClientData = CollectedClientData( + type = "webauthn.get", + challenge = options.challenge, + origin = Fido2Util.getPackageFacetID(activity.applicationContext), + ) + val clientDataJSON: ByteArray = Gson().toJson(collectedClientData).toByteArray() + val clientDataHash: ByteArray = + MessageDigest.getInstance("SHA-256").digest(clientDataJSON) + + val authenticator: Authenticator = authenticatorProvider.getAuthenticator( + authType = authType, + fido2PromptInfo = fido2PromptInfo + ) + + val authGetAssertionResult: AuthenticatorGetAssertionResult = authenticator.getAssertion( + rpId = options.rpId, + hash = clientDataHash, + allowCredDescriptorList = options.allowCredentials, + extensions = options.extensions?.processAuthenticatorExtensionsInput(), + ).getOrThrow() + + return PublicKeyCredentialGetResult( + id = authGetAssertionResult.credentialId.toBase64url(), + authenticatorAssertionResponse = + AuthenticatorAssertionResponse( + clientDataJSON = clientDataJSON, + authenticatorData = authGetAssertionResult.authenticatorData, + signature = authGetAssertionResult.signature, + userHandle = authGetAssertionResult.userHandle, + ), + clientExtensionsOutput = options.extensions?.processClientExtensionsOutput(), + ) + } + + /** + * Processes the credential types and public key algorithms from the creation options. + * + * @param options The public key credential creation options. + * @return The list of processed public key credential parameters. + * @throws WebAuthnException.CoreException.NotSupportedException If no valid credential types and public key algorithms are found. + */ + private fun processCredTypesAndPubKeyAlgs( + options: PublicKeyCredentialCreationOptions + ): List { + val credTypesAndPubKeyAlgs = mutableListOf() + if (options.publicKeyCredentialParams.isEmpty()) { + credTypesAndPubKeyAlgs.add( + PublicKeyCredentialParams(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256) + ) + } else { + for (pubKeyParam in options.publicKeyCredentialParams) { + if (pubKeyParam.type == PublicKeyCredentialType.PUBLIC_KEY) { + credTypesAndPubKeyAlgs.add(PublicKeyCredentialParams(pubKeyParam.type, pubKeyParam.alg)) + } + } + } + if (credTypesAndPubKeyAlgs.isEmpty()) { + throw WebAuthnException.CoreException.NotSupportedException( + "credTypesAndPubKeyAlgs is empty." + ) + } + return credTypesAndPubKeyAlgs + } + + /** + * Cleans up by deleting a unnecessary credential. + * + * @param credId The credential ID. + * @throws WebAuthnException.CredSrcStorageException If there is an error deleting the credential from the database. + */ + private suspend fun cleanup(credId: String) { + val keyAlias = credId.toBase64url() + SecureExecutionHelper.deleteKey(keyAlias) + + try { + withContext(databaseDispatcher) { + db.delete(credId = credId) + } + } catch (e: Exception) { + throw WebAuthnException.CredSrcStorageException("Failed to delete credential for credId: $credId", e) + } + } + + /** + * Retries cleanup in case of failure. + * + * @param credId The credential ID. + * @param maxTries The maximum number of attempts. + * @param delayMillis The delay between retries in milliseconds. + */ + private suspend fun retryCleanup(credId: String, maxTries: Int, delayMillis: Long) { + repeat(maxTries) { attempt -> + try { + cleanup(credId) + return + } catch (e: Throwable) { + if (attempt == maxTries - 1) throw e + delay(delayMillis) + } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/rp/RelyingParty.kt b/webauthn/src/main/java/com/lycorp/webauthn/rp/RelyingParty.kt new file mode 100644 index 0000000..16993f4 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/rp/RelyingParty.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.rp + +import com.lycorp.webauthn.model.AttestationConveyancePreference +import com.lycorp.webauthn.model.AuthenticatorSelectionCriteria +import com.lycorp.webauthn.model.ClientExtensionInput +import com.lycorp.webauthn.model.CredentialProtection +import com.lycorp.webauthn.model.PublicKeyCredentialCreateResult +import com.lycorp.webauthn.model.PublicKeyCredentialDescriptor +import com.lycorp.webauthn.model.PublicKeyCredentialGetResult +import com.lycorp.webauthn.model.PublicKeyCredentialParams +import com.lycorp.webauthn.model.PublicKeyCredentialRpEntity +import com.lycorp.webauthn.model.PublicKeyCredentialUserEntity +import com.lycorp.webauthn.model.UserVerificationRequirement + +/** + * Interface for Relying Party operations in WebAuthn. + * Defines methods for registration and authentication processes. + */ +interface RelyingParty { + + /** + * Generates and returns the data required to initiate a WebAuthn registration process. + * + * @param options The registration options containing parameters like attestation, authenticator selection, etc. + * @return RegistrationData The data required to initiate the registration process. + */ + suspend fun getRegistrationData(options: RegistrationOptions): RegistrationData + + /** + * Verifies the result of a WebAuthn registration process. + * + * @param result The result of the registration process. + */ + suspend fun verifyRegistration(result: PublicKeyCredentialCreateResult) + + /** + * Generates and returns the data required to initiate a WebAuthn authentication process. + * + * @param options The authentication options containing parameters like user verification, username, etc. + * @return AuthenticationData The data required to initiate the authentication process. + */ + suspend fun getAuthenticationData(options: AuthenticationOptions): AuthenticationData + + /** + * Verifies the result of a WebAuthn authentication process. + * + * @param result The result of the authentication process. + */ + suspend fun verifyAuthentication(result: PublicKeyCredentialGetResult) +} + +class RegistrationOptions( + val attestation: AttestationConveyancePreference, + val authenticatorSelection: AuthenticatorSelectionCriteria?, + val credProtect: CredentialProtection?, + val displayName: String, + val username: String +) + +class AuthenticationOptions( + val userVerification: UserVerificationRequirement, + val username: String +) + +class RegistrationData( + val attestation: AttestationConveyancePreference, + val authenticatorSelection: AuthenticatorSelectionCriteria?, + val challenge: String, + val excludeCredentials: List?, + val extensions: ClientExtensionInput?, + val pubKeyCredParams: List, + val rp: PublicKeyCredentialRpEntity, + val user: PublicKeyCredentialUserEntity +) + +class AuthenticationData( + val allowCredentials: List?, + val challenge: String, + val extensions: ClientExtensionInput?, + val rpId: String, + val userVerification: UserVerificationRequirement +) diff --git a/webauthn/src/main/java/com/lycorp/webauthn/util/Constants.kt b/webauthn/src/main/java/com/lycorp/webauthn/util/Constants.kt new file mode 100644 index 0000000..1cb24d9 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/util/Constants.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +const val CRED_ID_SIZE = 32 diff --git a/webauthn/src/main/java/com/lycorp/webauthn/util/Encoding.kt b/webauthn/src/main/java/com/lycorp/webauthn/util/Encoding.kt new file mode 100644 index 0000000..a972d44 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/util/Encoding.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import com.lycorp.webauthn.exceptions.WebAuthnException +import java.util.Base64 + +fun String.toBase64url(): String { + try { + return Base64.getUrlEncoder().withoutPadding().encodeToString(this.encodeToByteArray()) + } catch (e: Exception) { + throw WebAuthnException.UtilityException("Failed to encode String to base64url String", e) + } +} + +fun String.base64urlToString(): String { + try { + return Base64.getUrlDecoder().decode(this).decodeToString() + } catch (e: Exception) { + throw WebAuthnException.UtilityException("Failed to decode base64url String to String", e) + } +} + +fun String.base64urlToByteArray(): ByteArray { + try { + return Base64.getUrlDecoder().decode(this) + } catch (e: Exception) { + throw WebAuthnException.UtilityException("Failed to decode base64url String to ByteArray", e) + } +} + +fun ByteArray.toBase64url(): String { + try { + return Base64.getUrlEncoder().withoutPadding().encodeToString(this) + } catch (e: Exception) { + throw WebAuthnException.UtilityException("Failed to encode ByteArray to base64url String", e) + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/util/Fido2Util.kt b/webauthn/src/main/java/com/lycorp/webauthn/util/Fido2Util.kt new file mode 100644 index 0000000..6818214 --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/util/Fido2Util.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Base64 +import com.lycorp.webauthn.exceptions.WebAuthnException +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +class Fido2Util { + companion object { + fun getPackageFacetID(context: Context): String { + val cert: ByteArray = if (Build.VERSION.SDK_INT >= 33) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()) + ).signingInfo.apkContentsSigners[0].toByteArray() + } else if (Build.VERSION.SDK_INT >= 28) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNING_CERTIFICATES + ).signingInfo.apkContentsSigners[0].toByteArray() + } else { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNATURES + ).signatures[0].toByteArray() + } + val input: InputStream = ByteArrayInputStream(cert) + val cf = CertificateFactory.getInstance("X509") + val certificate: X509Certificate = cf.generateCertificate(input) as X509Certificate + val md = MessageDigest.getInstance("SHA256") + val hash = md.digest(certificate.encoded) + + // According to the "FIDO AppID and Facet Specification" v2.0 specification draft + // Supposed to be default (non URL safe) encoding + return "android:apk-key-hash-sha256:" + + Base64.encodeToString(hash, Base64.DEFAULT or Base64.NO_WRAP or Base64.NO_PADDING) + } + + fun generateRandomByteArray(numByte: Int): ByteArray { + try { + return ByteArray(numByte).also { SecureRandom().nextBytes(it) } + } catch (e: Throwable) { + throw WebAuthnException.UtilityException("Cannot generate random byte array.", e) + } + } + } +} diff --git a/webauthn/src/main/java/com/lycorp/webauthn/util/SecureExecutionHelper.kt b/webauthn/src/main/java/com/lycorp/webauthn/util/SecureExecutionHelper.kt new file mode 100644 index 0000000..91c649f --- /dev/null +++ b/webauthn/src/main/java/com/lycorp/webauthn/util/SecureExecutionHelper.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import com.lycorp.webauthn.exceptions.WebAuthnException +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.getAlgorithmParameterSpec +import com.lycorp.webauthn.model.getDigests +import com.lycorp.webauthn.model.getKeyProperties +import com.lycorp.webauthn.model.getSignaturePaddings +import java.security.Key +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.KeyStoreException +import java.security.PublicKey +import java.security.cert.X509Certificate + +/** + * Helper class for secure execution + */ +internal object SecureExecutionHelper { + private val lock = Any() + + fun generateKey( + keyAlias: String, + challenge: ByteArray, + publicKeyAlgorithm: COSEAlgorithmIdentifier, + useBiometricOnly: Boolean, + userAuthenticationValidityDurationSeconds: Int = 0, + userAuthenticationRequired: Boolean = true, + isStrongBoxBacked: Boolean = false, + purpose: Int = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY, + ): KeyPair { + return if (isStrongBoxBacked && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return try { + generateKeyWithOption( + keyAlias, + challenge, + publicKeyAlgorithm, + useBiometricOnly, + userAuthenticationValidityDurationSeconds, + userAuthenticationRequired, + purpose, + true + ) + } catch (e: StrongBoxUnavailableException) { + generateKeyWithOption( + keyAlias, + challenge, + publicKeyAlgorithm, + useBiometricOnly, + userAuthenticationValidityDurationSeconds, + userAuthenticationRequired, + purpose, + false + ) + } + } else { + generateKeyWithOption( + keyAlias, + challenge, + publicKeyAlgorithm, + useBiometricOnly, + userAuthenticationValidityDurationSeconds, + userAuthenticationRequired, + purpose, + false + ) + } + } + + private fun generateKeyWithOption( + keyAlias: String, + challenge: ByteArray, + publicKeyAlgorithm: COSEAlgorithmIdentifier, + useBiometricOnly: Boolean, + userAuthenticationValidityDurationSeconds: Int = 0, + userAuthenticationRequired: Boolean = true, + purpose: Int = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY, + isStrongBoxBacked: Boolean = false, + ): KeyPair { + synchronized(lock) { + val keyProperties = publicKeyAlgorithm.getKeyProperties() + ?: throw IllegalArgumentException("Unsupported algorithm") + val kpg: KeyPairGenerator = + KeyPairGenerator.getInstance( + keyProperties, + "AndroidKeyStore", + ) + val parameterSpec: KeyGenParameterSpec = + KeyGenParameterSpec.Builder( + keyAlias, + purpose, + ).run { + if (isStrongBoxBacked && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setIsStrongBoxBacked(true) + } + publicKeyAlgorithm.getSignaturePaddings()?.let { + setSignaturePaddings(it) + } + publicKeyAlgorithm.getAlgorithmParameterSpec()?.let { + setAlgorithmParameterSpec(it) + } + publicKeyAlgorithm.getDigests()?.let { + setDigests(it) + } + setUserAuthenticationRequired(userAuthenticationRequired) + setInvalidatedByBiometricEnrollment(true) + if (!useBiometricOnly) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters( + 0, + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL + ) + } else { + setUserAuthenticationValidityDurationSeconds(userAuthenticationValidityDurationSeconds) + } + } + setAttestationChallenge(challenge) + build() + } + kpg.initialize(parameterSpec) + return kpg.generateKeyPair() + } + } + + fun deleteKey(keyAlias: String) { + synchronized(lock) { + try { + KeyStore.getInstance("AndroidKeyStore").also { + it.load(null) + it.deleteEntry(keyAlias) + } + } catch (e: Throwable) { + throw WebAuthnException.SecureExecutionException("Cannot delete key from KeyStore.", e) + } + } + } + + fun getKey(keyAlias: String): Key? { + synchronized(lock) { + try { + val keyStore = + KeyStore.getInstance("AndroidKeyStore").also { + it.load(null, null) + } + return keyStore.getKey(keyAlias, null) + } catch (e: Throwable) { + throw WebAuthnException.SecureExecutionException("Cannot fetch key from KeyStore.", e) + } + } + } + + fun getX509Certificates(keyAlias: String): List { + synchronized(lock) { + try { + val keyStore = + KeyStore.getInstance("AndroidKeyStore").also { + it.load(null, null) + } + val certChain = keyStore.getCertificateChain(keyAlias) + ?: throw KeyStoreException("Cannot find certificate chain") + val certChainList = certChain.toList() + return certChainList.map { it as X509Certificate } + } catch (e: Throwable) { + throw WebAuthnException.SecureExecutionException("Cannot fetch X509 certificate from KeyStore.", e) + } + } + } + + fun getX509Certificate(keyAlias: String): X509Certificate { + synchronized(lock) { + try { + val keyStore = + KeyStore.getInstance("AndroidKeyStore").also { + it.load(null, null) + } + val certificate = keyStore.getCertificate(keyAlias) + ?: throw KeyStoreException("Cannot find certificate") + return certificate as X509Certificate + } catch (e: Throwable) { + throw WebAuthnException.SecureExecutionException("Cannot fetch X509 certificate from KeyStore.", e) + } + } + } + + fun containAlias(keyAlias: String): Boolean { + synchronized(lock) { + try { + val keyStore = + KeyStore.getInstance("AndroidKeyStore").also { + it.load(null, null) + } + return keyStore.containsAlias(keyAlias) + } catch (e: Throwable) { + throw WebAuthnException.SecureExecutionException("Cannot check key alias from KeyStore.", e) + } + } + } + + fun getPublicKey(keyAlias: String): PublicKey { + try { + val x509CertificateChain = getX509Certificates(keyAlias) + return x509CertificateChain[0].publicKey + } catch (e: Throwable) { + throw WebAuthnException.SecureExecutionException("Cannot fetch public key from KeyStore.", e) + } + } +} diff --git a/webauthn/src/test/java/com/lycorp/webauthn/BiometricAuthenticatorTest.kt b/webauthn/src/test/java/com/lycorp/webauthn/BiometricAuthenticatorTest.kt new file mode 100644 index 0000000..3b9297f --- /dev/null +++ b/webauthn/src/test/java/com/lycorp/webauthn/BiometricAuthenticatorTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn + +import android.content.Context +import android.content.pm.PackageManager +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.authenticator.BiometricAuthenticator +import com.lycorp.webauthn.authenticator.Fido2ObjectFactory +import com.lycorp.webauthn.exceptions.WebAuthnException +import com.lycorp.webauthn.handler.BiometricAuthenticationHandler +import com.lycorp.webauthn.model.AssertionObject +import com.lycorp.webauthn.model.AttestationObject +import com.lycorp.webauthn.model.AuthenticatorExtensionsInput +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.model.Fido2UserAuthResult +import com.lycorp.webauthn.model.PublicKeyCredentialDescriptor +import com.lycorp.webauthn.model.PublicKeyCredentialParams +import com.lycorp.webauthn.model.PublicKeyCredentialRpEntity +import com.lycorp.webauthn.model.PublicKeyCredentialType +import com.lycorp.webauthn.model.PublicKeyCredentialUserEntity +import com.lycorp.webauthn.util.DataFactory +import com.lycorp.webauthn.util.MockCredentialSourceStorage +import com.lycorp.webauthn.util.SecureExecutionHelper +import io.kotest.assertions.fail +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.core.spec.style.scopes.BehaviorSpecGivenContainerScope +import io.kotest.core.test.isRootTest +import io.kotest.matchers.should +import io.kotest.matchers.types.beInstanceOf +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import java.security.Signature +import java.security.cert.X509Certificate +import kotlin.reflect.KClass + +class BiometricAuthenticatorTest : BehaviorSpec({ + val mockActivity: FragmentActivity = mockk() + val mockContext: Context = mockk() + val mockPackageManager: PackageManager = mockk() + val mockFido2Database = MockCredentialSourceStorage() + val mockAuthenticationHandler: BiometricAuthenticationHandler = mockk() + val mockFido2PromptInfo: Fido2PromptInfo = mockk() + val mockAuthObjectFactory: Fido2ObjectFactory = mockk() + val biometricAuthenticator = + BiometricAuthenticator( + activity = mockActivity, + db = mockFido2Database, + fido2ObjectFactory = mockAuthObjectFactory, + fido2PromptInfo = mockFido2PromptInfo, + authenticationHandler = mockAuthenticationHandler, + ) + val mockAttestationObject: AttestationObject = mockk() + val mockAssertionObject: AssertionObject = mockk() + val mockSignature: Signature = mockk() + val mockCertificate: X509Certificate = mockk() + + suspend fun shouldThrowException(exceptionType: KClass, block: suspend () -> Unit) { + try { + block() + } catch (e: Throwable) { + e should beInstanceOf(exceptionType) + return + } + fail("Expected exception of type: ${exceptionType.qualifiedName} but successfully completed without exception.") + } + + suspend fun BehaviorSpecGivenContainerScope.makeCredentialShouldThrowException( + exceptionType: KClass, + clientDataHash: ByteArray = DataFactory.DUMMY_BYTEARRAY, + rpEntity: PublicKeyCredentialRpEntity = DataFactory.newRpEntity, + userEntity: PublicKeyCredentialUserEntity = DataFactory.newUserEntity, + credTypesAndPubKeyAlgs: List = listOf(DataFactory.ES256_CRED_PARAMS), + excludeCredDescriptorList: List? = listOf(DataFactory.registeredCredDescriptor), + extensions: AuthenticatorExtensionsInput? = null, + ) { + `when`("makeCredential is called") { + then("throw ${exceptionType.simpleName}") { + shouldThrowException(exceptionType) { + biometricAuthenticator.makeCredential( + clientDataHash, + rpEntity, + userEntity, + credTypesAndPubKeyAlgs, + excludeCredDescriptorList, + extensions + ).getOrThrow() + } + } + } + } + + suspend fun BehaviorSpecGivenContainerScope.getAssertionShouldThrowException( + exceptionType: KClass, + rpId: String = DataFactory.registeredRpId, + hash: ByteArray = DataFactory.DUMMY_BYTEARRAY, + allowCredDescriptorList: List? = null, + extensions: AuthenticatorExtensionsInput? = null, + ) { + `when`("getAssertion is called") { + then("throw ${exceptionType.simpleName}") { + shouldThrowException(exceptionType) { + biometricAuthenticator.getAssertion(rpId, hash, allowCredDescriptorList, extensions).getOrThrow() + } + } + } + } + + beforeTest { + if (it.isRootTest()) { + mockkObject(SecureExecutionHelper) + coEvery { mockAuthenticationHandler.isSupported() } returns true + coEvery { mockAuthenticationHandler.authenticate(any(), any()) } returns Fido2UserAuthResult(mockSignature) + coEvery { + mockAuthObjectFactory.createAttestationObject(any(), any(), any(), any(), any(), any(), any()) + } returns mockAttestationObject + coEvery { + mockAuthObjectFactory.createAssertionObject(any(), any(), any(), any(), any()) + } returns mockAssertionObject + coEvery { mockAttestationObject.toCBOR() } returns ByteArray(100) + coEvery { mockAssertionObject.authenticatorData } returns ByteArray(100) + coEvery { mockAssertionObject.signature } returns ByteArray(100) + every { SecureExecutionHelper.generateKey(any(), any(), any(), any()) } returns DataFactory.keyPair + every { SecureExecutionHelper.generateKey(any(), any(), any(), any(), any()) } returns DataFactory.keyPair + every { + SecureExecutionHelper.generateKey(any(), any(), any(), any(), any(), any()) + } returns DataFactory.keyPair + every { + SecureExecutionHelper.generateKey(any(), any(), any(), any(), any(), any(), any()) + } returns DataFactory.keyPair + every { SecureExecutionHelper.getKey(any()) } returns DataFactory.keyPair.private + every { SecureExecutionHelper.deleteKey(any()) } returns Unit + every { SecureExecutionHelper.getX509Certificate(any()) } returns mockCertificate + every { mockCertificate.sigAlgName } returns "SHA256withECDSA" + every { SecureExecutionHelper.containAlias(any()) } returns false + every { mockActivity.applicationContext } returns mockContext + every { mockContext.packageManager } returns mockPackageManager + every { mockPackageManager.hasSystemFeature(any()) } returns false + + // By default, assume that the 'registeredCredSource' is always pre-registered in all tests. + mockFido2Database.store(DataFactory.registeredCredSource) + } + } + + afterTest { + if (it.a.isRootTest()) { + unmockkObject(SecureExecutionHelper) + mockFido2Database.removeAllData() + } + } + + given("Valid parameters & behaviors") { + `when`("makeCredential is called") { + then("function works normally without exception occurrence") { + shouldNotThrowAny { + biometricAuthenticator.makeCredential( + // Default parameters + DataFactory.DUMMY_BYTEARRAY, + DataFactory.newRpEntity, + DataFactory.newUserEntity, + listOf(DataFactory.ES256_CRED_PARAMS), + listOf(DataFactory.registeredCredDescriptor), + null + ) + } + } + } + `when`("getAssertion is called") { + then("function works normally without exception occurrence") { + shouldNotThrowAny { + biometricAuthenticator.getAssertion( + // Default parameters + DataFactory.registeredRpId, + DataFactory.DUMMY_BYTEARRAY, + null, + null + ) + } + } + } + } + + given("CredTypesAndPubKeyAlgs does not including ES256") { + val credTypesAndPubKeyAlgsWithUncompatibleAlg = + mutableListOf(PublicKeyCredentialParams(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES512)) + + makeCredentialShouldThrowException( + WebAuthnException.CoreException.NotSupportedException::class, + credTypesAndPubKeyAlgs = credTypesAndPubKeyAlgsWithUncompatibleAlg + ) + } + + given("A credential corresponding to a particular rpId is already registered") { + makeCredentialShouldThrowException( + WebAuthnException.CoreException.InvalidStateException::class, + rpEntity = PublicKeyCredentialRpEntity( + DataFactory.registeredRpId, + DataFactory.RP_NAME + ), + excludeCredDescriptorList = listOf(DataFactory.registeredCredDescriptor) + ) + } + + given("A credential corresponding to a particular rpId was not registered") { + getAssertionShouldThrowException( + WebAuthnException.CoreException.NotAllowedException::class, + rpId = DataFactory.newRpId, + ) + } + + given("Biometric authentication is not supported") { + coEvery { mockAuthenticationHandler.isSupported() } returns false + + makeCredentialShouldThrowException(WebAuthnException.CoreException.ConstraintException::class) + getAssertionShouldThrowException(WebAuthnException.CoreException.ConstraintException::class) + } + + given("Biometric authentication is failed") { + coEvery { + mockAuthenticationHandler.authenticate(any(), any()) + } throws BiometricAuthenticationHandler.AuthenticationFailedException() + + makeCredentialShouldThrowException(WebAuthnException.CoreException.NotAllowedException::class) + getAssertionShouldThrowException(WebAuthnException.CoreException.NotAllowedException::class) + } + + given("Biometric authentication throws error") { + coEvery { + mockAuthenticationHandler.authenticate(any(), any()) + } throws BiometricAuthenticationHandler.AuthenticationErrorException() + + makeCredentialShouldThrowException(WebAuthnException.CoreException.NotAllowedException::class) + getAssertionShouldThrowException(WebAuthnException.CoreException.NotAllowedException::class) + } +}) diff --git a/webauthn/src/test/java/com/lycorp/webauthn/PublicKeyCredentialTest.kt b/webauthn/src/test/java/com/lycorp/webauthn/PublicKeyCredentialTest.kt new file mode 100644 index 0000000..03eab68 --- /dev/null +++ b/webauthn/src/test/java/com/lycorp/webauthn/PublicKeyCredentialTest.kt @@ -0,0 +1,236 @@ +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalKotest::class) + +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn + +import android.content.Context +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.authenticator.AuthenticatorProvider +import com.lycorp.webauthn.authenticator.BiometricAuthenticator +import com.lycorp.webauthn.exceptions.WebAuthnException +import com.lycorp.webauthn.model.Fido2PromptInfo +import com.lycorp.webauthn.model.PublicKeyCredentialRpEntity +import com.lycorp.webauthn.model.PublicKeyCredentialUserEntity +import com.lycorp.webauthn.publickeycredential.Biometric +import com.lycorp.webauthn.rp.AuthenticationOptions +import com.lycorp.webauthn.rp.RegistrationOptions +import com.lycorp.webauthn.rp.RelyingParty +import com.lycorp.webauthn.util.DataFactory +import com.lycorp.webauthn.util.MockCredentialSourceStorage +import com.lycorp.webauthn.util.TestUtil +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.common.ExperimentalKotest +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.core.test.isRootTest +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeTypeOf +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi + +val mockActivity = mockk() +val mockContext = mockk() +val mockAuthenticatorProvider = mockk() +val mockRelyingParty = mockk() +internal val mockBiometricAuthenticator = mockk() +val mockDb = MockCredentialSourceStorage() + +typealias RegisterParams = Pair +typealias AuthenticateParams = Pair + +class PublicKeyCredentialTest : BehaviorSpec({ + + val publicKeyCredential = Biometric( + rpClient = mockRelyingParty, + activity = mockActivity, + db = mockDb, + authenticatorProvider = mockAuthenticatorProvider, + ) + val defaultRegParams = DataFactory.getRegParams() + val defaultAuthParams = DataFactory.getAuthParams() + + beforeTest { + if (it.isRootTest()) { + TestUtil.mockLogClass() + TestUtil.mockActivity(mockActivity, mockContext) + TestUtil.mockDefaultBiometricAuthenticatorAction(mockBiometricAuthenticator) + TestUtil.mockDefaultReyingPartyAction(mockRelyingParty) + TestUtil.mockDefaultAuthenticatorProviderAction(mockAuthenticatorProvider) + TestUtil.mockDefaultFido2UtilAction() + } + } + + // Testcases + listOf( + DataFactory.generateString("a", 0), + DataFactory.generateString("a", 65), + ).forEach { invalidUserId -> + given("options.user.id with invalid length(${invalidUserId.length})") { + coEvery { + mockRelyingParty.getRegistrationData(any()) + } returns DataFactory.getRegistrationData( + user = PublicKeyCredentialUserEntity(id = invalidUserId, name = "test", displayName = "test"), + ) + + val (options, fido2PromptInfo) = DataFactory.getRegParams() + `when`("register is called") { + then("throw TypeException") { + val result = publicKeyCredential.create(options, fido2PromptInfo) + result.isFailure shouldBe true + result.exceptionOrNull().shouldBeTypeOf() + } + } + } + } + + given("AuthenticatorProvider returns BiometricAuthenticator") { + coEvery { mockAuthenticatorProvider.getAuthenticator(any(), any()) } returns mockBiometricAuthenticator + `when`("register is called with valid parameters") { + val regParams = DataFactory.getRegParams() + then("works well") { + val result = publicKeyCredential.create(regParams.first, regParams.second) + result.isSuccess shouldBe true + } + } + `when`("authenticate is called with valid parameters") { + val authParams = DataFactory.getAuthParams() + then("works well") { + val result = publicKeyCredential.get(authParams.first, authParams.second) + result.isSuccess shouldBe true + } + } + } + + given("default inputs") { + val (regOptions, regFido2PromptInfo) = defaultRegParams + val (authOptions, authFido2PromptInfo) = defaultAuthParams + + // Concurrency test + val times = 10 + `when`("'register's are called $times times simultaneously with different rpEntity") { + val registeredRpId = mutableListOf() + coEvery { + mockBiometricAuthenticator.makeCredential(any(), any(), any(), any(), any(), any()) + } coAnswers { + val rpEntity: PublicKeyCredentialRpEntity = secondArg() + if (rpEntity.id !in registeredRpId) { + registeredRpId.add(rpEntity.id) + Result.success(DataFactory.getMakeCredentialResult()) + } else { + throw WebAuthnException.CoreException.InvalidStateException() + } + } + then("works well $times times") { + shouldNotThrowAny { + TestUtil.performConcurrentExecution(times) { + val (newRegOptions, newRegFido2PromptInfo) = + DataFactory.getRegParams() + coEvery { mockRelyingParty.getRegistrationData(any()) } returns DataFactory.getRegistrationData( + rp = PublicKeyCredentialRpEntity( + id = "https://test-rp.com/$it", + name = "test_rp", + ), + ) + publicKeyCredential.create(newRegOptions, newRegFido2PromptInfo) + } + } + } + } + `when`( + "'register's are called $times times simultaneously with multiple instances" + + " under the assumption that makeCredentials succeed only the first time", + ) { + val registeredRpId = mutableListOf() + coEvery { + mockBiometricAuthenticator.makeCredential(any(), any(), any(), any(), any(), any()) + } answers { + val rpEntity: PublicKeyCredentialRpEntity = secondArg() + if (rpEntity.id !in registeredRpId) { + registeredRpId.add(rpEntity.id) + Result.success(DataFactory.getMakeCredentialResult()) + } else { + throw WebAuthnException.CoreException.InvalidStateException() + } + } + then("only the first register call should succeed and throw InvalidStateException for the rest") { + var invalidStateExceptionCount = 0 + TestUtil.performConcurrentExecution(times) { + val newPublicKeyCredential = + Biometric( + rpClient = mockRelyingParty, + activity = mockActivity, + db = mockDb, + authenticatorProvider = mockAuthenticatorProvider, + ) + val result = newPublicKeyCredential.create(regOptions, regFido2PromptInfo) + if (result.exceptionOrNull() is WebAuthnException.CoreException.InvalidStateException) { + invalidStateExceptionCount++ + } + } + invalidStateExceptionCount shouldBe times - 1 + } + // initialize conditions + coEvery { + mockBiometricAuthenticator.makeCredential(any(), any(), any(), any(), any(), any()) + } returns Result.success(DataFactory.getMakeCredentialResult()) + } + + `when`("'authenticate's are called $times times simultaneously") { + then("works well $times times") { + shouldNotThrowAny { + TestUtil.performConcurrentExecution( + times + ) { publicKeyCredential.get(authOptions, authFido2PromptInfo) } + } + } + } + + // Authenticator action test + listOf( + WebAuthnException.CoreException.InvalidStateException::class, + WebAuthnException.CoreException.NotAllowedException::class, + ).forEach { exceptionType -> + `when`("Authenticator.makeCredential throws ${exceptionType.simpleName}") { + coEvery { + mockBiometricAuthenticator.makeCredential(any(), any(), any(), any(), any(), any()) + } throws TestUtil.getExceptionBasedOnType(exceptionType) + then("register propagates the exception") { + val result = publicKeyCredential.create(regOptions, regFido2PromptInfo) + result.isFailure shouldBe true + val exception = result.exceptionOrNull() + exception shouldNotBe null + exception!!::class shouldBe exceptionType + } + } + + `when`("Authenticator.getAssertion throws ${exceptionType.simpleName}") { + coEvery { + mockBiometricAuthenticator.getAssertion(any(), any(), any(), any()) + } throws TestUtil.getExceptionBasedOnType(exceptionType) + then("authenticate propagates the exception") { + val result = publicKeyCredential.get(authOptions, authFido2PromptInfo) + result.isFailure shouldBe true + val exception = result.exceptionOrNull() + exception shouldNotBe null + exception!!::class shouldBe exceptionType + } + } + } + } +}) diff --git a/webauthn/src/test/java/com/lycorp/webauthn/util/DataFactory.kt b/webauthn/src/test/java/com/lycorp/webauthn/util/DataFactory.kt new file mode 100644 index 0000000..51c48c7 --- /dev/null +++ b/webauthn/src/test/java/com/lycorp/webauthn/util/DataFactory.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import com.lycorp.webauthn.AuthenticateParams +import com.lycorp.webauthn.RegisterParams +import com.lycorp.webauthn.model.AttestationConveyancePreference +import com.lycorp.webauthn.model.AuthenticatorGetAssertionResult +import com.lycorp.webauthn.model.AuthenticatorMakeCredentialResult +import com.lycorp.webauthn.model.AuthenticatorSelectionCriteria +import com.lycorp.webauthn.model.AuthenticatorType +import com.lycorp.webauthn.model.COSEAlgorithmIdentifier +import com.lycorp.webauthn.model.ClientExtensionInput +import com.lycorp.webauthn.model.CredentialProtection +import com.lycorp.webauthn.model.PublicKeyCredentialCreationOptions +import com.lycorp.webauthn.model.PublicKeyCredentialDescriptor +import com.lycorp.webauthn.model.PublicKeyCredentialParams +import com.lycorp.webauthn.model.PublicKeyCredentialRequestOptions +import com.lycorp.webauthn.model.PublicKeyCredentialRpEntity +import com.lycorp.webauthn.model.PublicKeyCredentialSource +import com.lycorp.webauthn.model.PublicKeyCredentialType +import com.lycorp.webauthn.model.PublicKeyCredentialUserEntity +import com.lycorp.webauthn.model.UserVerificationRequirement +import com.lycorp.webauthn.rp.AuthenticationData +import com.lycorp.webauthn.rp.AuthenticationOptions +import com.lycorp.webauthn.rp.RegistrationData +import com.lycorp.webauthn.rp.RegistrationOptions +import java.security.KeyPairGenerator + +class DataFactory { + companion object { + val RP_NAME = "test_rp" + val USER_NAME = "test_user_name" + val USER_DISPLAY_NAME = "test_user_display_name" + + val newRpId = "https://new-rp.com" + val newUserId = "new-user-id" + val newRpEntity = + PublicKeyCredentialRpEntity(newRpId, RP_NAME) + val newUserEntity = PublicKeyCredentialUserEntity(newUserId, USER_NAME, USER_DISPLAY_NAME) + + val registeredRpId = "https://registered-rp.com" + val registeredUserId = "registered-user-id" + val registeredCredId = Fido2Util.generateRandomByteArray(32).toBase64url() + + val registeredCredSource = PublicKeyCredentialSource( + id = registeredCredId, + rpId = registeredRpId, + userHandle = registeredUserId, + aaguid = AuthenticatorType.Biometric.aaguid, + ) + val registeredCredDescriptor = PublicKeyCredentialDescriptor( + type = PublicKeyCredentialType.PUBLIC_KEY.value, + id = registeredCredSource.id, + transports = null, + ) + + val DUMMY_BYTEARRAY = ByteArray(32) { 0 } + val ES256_CRED_PARAMS = + PublicKeyCredentialParams(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256) + val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(256) } + .generateKeyPair() + + fun getRegParams(): RegisterParams { + return Pair( + RegistrationOptions( + AttestationConveyancePreference.DIRECT, + AuthenticatorSelectionCriteria( + null, + UserVerificationRequirement.PREFERRED.value, + ), + CredentialProtection(), + USER_DISPLAY_NAME, + USER_NAME, + ), + null, + ) + } + + fun getAuthParams( + rpId: String = registeredRpId, + allowCredentials: List? = null, + extensions: ClientExtensionInput? = null, + ): AuthenticateParams { + val challenge = Fido2Util.generateRandomByteArray(32) + return Pair( + AuthenticationOptions( + UserVerificationRequirement.PREFERRED, + USER_NAME + ), + null, + ) + } + + fun getPublicKeyCredentialCreationOptions( + rpId: String = newRpId, + userId: String = newUserId, + pubKeyCredParams: List = listOf(ES256_CRED_PARAMS), + excludeCredentials: List = emptyList(), + extensions: ClientExtensionInput? = null, + ): PublicKeyCredentialCreationOptions { + val challenge = Fido2Util.generateRandomByteArray(32).toBase64url() + return PublicKeyCredentialCreationOptions( + rp = PublicKeyCredentialRpEntity(rpId, RP_NAME), + user = PublicKeyCredentialUserEntity(userId, USER_NAME, USER_DISPLAY_NAME), + challenge = challenge, + publicKeyCredentialParams = pubKeyCredParams, + excludeCredentials = excludeCredentials, + authenticatorSelection = AuthenticatorSelectionCriteria( + authenticatorAttachment = null, + userVerification = UserVerificationRequirement.PREFERRED.value, + ), + extensions = extensions, + ) + } + + fun getPublicKeyCredentialRequestOptions( + rpId: String = registeredRpId, + allowCredentials: List = listOf(registeredCredDescriptor), + extensions: ClientExtensionInput? = null, + ): PublicKeyCredentialRequestOptions { + val challenge = Fido2Util.generateRandomByteArray(32).toBase64url() + return PublicKeyCredentialRequestOptions( + challenge = challenge, + rpId = rpId, + allowCredentials = allowCredentials, + userVerification = UserVerificationRequirement.PREFERRED, + extensions = extensions, + ) + } + + fun getRegistrationData( + rp: PublicKeyCredentialRpEntity = newRpEntity, + user: PublicKeyCredentialUserEntity = newUserEntity, + ): RegistrationData { + return RegistrationData( + attestation = AttestationConveyancePreference.DIRECT, + authenticatorSelection = AuthenticatorSelectionCriteria( + null, + UserVerificationRequirement.REQUIRED.value, + ), + challenge = Fido2Util.generateRandomByteArray(32).toBase64url(), + excludeCredentials = emptyList(), + extensions = ClientExtensionInput(), + pubKeyCredParams = listOf(ES256_CRED_PARAMS), + rp = rp, + user = user, + ) + } + + fun getAuthenticationData(): AuthenticationData { + return AuthenticationData( + allowCredentials = listOf(registeredCredDescriptor), + challenge = Fido2Util.generateRandomByteArray(32).toBase64url(), + extensions = ClientExtensionInput(), + rpId = registeredRpId, + userVerification = UserVerificationRequirement.REQUIRED, + ) + } + + fun getMakeCredentialResult() = AuthenticatorMakeCredentialResult( + DUMMY_BYTEARRAY, + DUMMY_BYTEARRAY, + ) + + fun getGetAssertionResult() = AuthenticatorGetAssertionResult( + DUMMY_BYTEARRAY, + DUMMY_BYTEARRAY, + DUMMY_BYTEARRAY, + DUMMY_BYTEARRAY, + ) + + fun generateString(pattern: String, length: Int): String { + return pattern.repeat((length + pattern.length - 1) / pattern.length).take(length) + } + } +} diff --git a/webauthn/src/test/java/com/lycorp/webauthn/util/MockCredentialSourceStorage.kt b/webauthn/src/test/java/com/lycorp/webauthn/util/MockCredentialSourceStorage.kt new file mode 100644 index 0000000..11db22d --- /dev/null +++ b/webauthn/src/test/java/com/lycorp/webauthn/util/MockCredentialSourceStorage.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import com.lycorp.webauthn.db.CredentialSourceStorage +import com.lycorp.webauthn.model.PublicKeyCredentialSource +import com.lycorp.webauthn.model.PublicKeyCredentialType +import java.util.UUID + +class MockCredentialSourceStorage : CredentialSourceStorage { + private var credSourceTable: MutableList = mutableListOf() + + override fun store(credSource: PublicKeyCredentialSource) { + val credSourceEntity = TestPubKeyCredSourceEntity( + credType = PublicKeyCredentialType.PUBLIC_KEY.value, + aaguid = credSource.aaguid, + credId = credSource.id, + rpId = credSource.rpId, + userHandle = credSource.userHandle, + signatureCounter = 0L, + ) + credSourceTable.add(credSourceEntity) + } + override fun load(credId: String): PublicKeyCredentialSource? { + val credSourceEntity = credSourceTable.firstOrNull { it.credId.contentEquals(credId) } + return credSourceEntity?.let { + PublicKeyCredentialSource( + type = it.credType, + id = it.credId, + rpId = it.rpId, + userHandle = it.userHandle, + aaguid = it.aaguid, + ) + } + } + + override fun loadAll(): List { + return credSourceTable.map { entity -> + PublicKeyCredentialSource( + type = entity.credType, + id = entity.credId, + rpId = entity.rpId, + userHandle = entity.userHandle, + aaguid = entity.aaguid, + ) + } + } + + override fun delete(credId: String) { + credSourceTable = credSourceTable.filter { it.credId != credId }.toMutableList() + } + + override fun increaseSignatureCounter(credId: String) { + val credSourceEntity = credSourceTable.firstOrNull { it.credId.contentEquals(credId) } + credSourceEntity?.let { + it.signatureCounter += 1 + } + } + override fun getSignatureCounter(credId: String): UInt = 0u + + fun removeAllData() { + credSourceTable = mutableListOf() + } +} + +data class TestPubKeyCredSourceEntity( + val credType: String, + var credId: String, + val rpId: String, + val userHandle: String?, + val aaguid: UUID, + var signatureCounter: Long, +) diff --git a/webauthn/src/test/java/com/lycorp/webauthn/util/TestUtil.kt b/webauthn/src/test/java/com/lycorp/webauthn/util/TestUtil.kt new file mode 100644 index 0000000..57db61b --- /dev/null +++ b/webauthn/src/test/java/com/lycorp/webauthn/util/TestUtil.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.lycorp.webauthn.util + +import android.content.Context +import android.util.Log +import androidx.fragment.app.FragmentActivity +import com.lycorp.webauthn.authenticator.AuthenticatorProvider +import com.lycorp.webauthn.authenticator.BiometricAuthenticator +import com.lycorp.webauthn.authenticator.DeviceCredentialAuthenticator +import com.lycorp.webauthn.exceptions.WebAuthnException +import com.lycorp.webauthn.mockBiometricAuthenticator +import com.lycorp.webauthn.rp.RelyingParty +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import kotlin.reflect.KClass +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest + +class TestUtil { + companion object { + fun mockLogClass() { + mockkStatic(Log::class) + every { Log.v(any(), any()) } returns 0 + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + } + + suspend fun mockActivity(mockActivity: FragmentActivity, mockContext: Context) { + coEvery { + mockActivity.applicationContext + } returns mockContext + } + + internal suspend fun mockDefaultBiometricAuthenticatorAction( + mockBiometricAuthenticator: BiometricAuthenticator + ) { + coEvery { + mockBiometricAuthenticator.makeCredential(any(), any(), any(), any(), any(), any()) + } returns Result.success(DataFactory.getMakeCredentialResult()) + coEvery { + mockBiometricAuthenticator.getAssertion(any(), any(), any(), any()) + } returns Result.success(DataFactory.getGetAssertionResult()) + } + + internal suspend fun mockDefaultDeviceCredentialAuthenticatorAction( + mockDeviceCredentialAuthenticator: DeviceCredentialAuthenticator + ) { + coEvery { + mockDeviceCredentialAuthenticator.makeCredential(any(), any(), any(), any(), any(), any()) + } returns Result.success(DataFactory.getMakeCredentialResult()) + coEvery { + mockDeviceCredentialAuthenticator.getAssertion(any(), any(), any(), any()) + } returns Result.success(DataFactory.getGetAssertionResult()) + } + + suspend fun mockDefaultAuthenticatorProviderAction(mockAuthenticatorProvider: AuthenticatorProvider) { + coEvery { + mockAuthenticatorProvider.getAuthenticator(any(), any()) + } returns mockBiometricAuthenticator + } + + suspend fun mockDefaultReyingPartyAction(mockRelyingParty: RelyingParty) { + coEvery { + mockRelyingParty.getRegistrationData(any()) + } returns DataFactory.getRegistrationData() + coEvery { + mockRelyingParty.verifyRegistration(any()) + } returns Unit + coEvery { + mockRelyingParty.getAuthenticationData(any()) + } returns DataFactory.getAuthenticationData() + coEvery { + mockRelyingParty.verifyAuthentication(any()) + } returns Unit + } + + suspend fun mockDefaultFido2UtilAction() { + mockkObject(Fido2Util) + coEvery { + Fido2Util.getPackageFacetID(any()) + } returns "TEST_FACET_ID" + } + + fun getExceptionBasedOnType(exceptionClass: KClass): Throwable { + return when (exceptionClass) { + WebAuthnException.CoreException.NotAllowedException::class -> + WebAuthnException.CoreException.NotAllowedException() + WebAuthnException.CoreException.InvalidStateException::class -> + WebAuthnException.CoreException.InvalidStateException() + else -> Exception("Unknown exception type") + } + } + + fun performConcurrentExecution(times: Int, block: suspend (Int) -> Unit) = runTest { + (1..times).map { + async { + block(it) + } + }.map { it.await() } + } + } +}