├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── git.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── SECURITY.md ├── assets └── webauthn-ruby.png ├── bin ├── console └── setup ├── docs ├── advanced_configuration.md └── u2f_migration.md ├── lib ├── cose │ └── rsapkcs1_algorithm.rb ├── webauthn.rb └── webauthn │ ├── attestation_object.rb │ ├── attestation_statement.rb │ ├── attestation_statement │ ├── android_key.rb │ ├── android_safetynet.rb │ ├── apple.rb │ ├── base.rb │ ├── fido_u2f.rb │ ├── fido_u2f │ │ └── public_key.rb │ ├── none.rb │ ├── packed.rb │ └── tpm.rb │ ├── authenticator_assertion_response.rb │ ├── authenticator_attestation_response.rb │ ├── authenticator_data.rb │ ├── authenticator_data │ └── attested_credential_data.rb │ ├── authenticator_response.rb │ ├── client_data.rb │ ├── configuration.rb │ ├── credential.rb │ ├── credential_creation_options.rb │ ├── credential_entity.rb │ ├── credential_options.rb │ ├── credential_request_options.rb │ ├── credential_rp_entity.rb │ ├── credential_user_entity.rb │ ├── encoder.rb │ ├── encoders.rb │ ├── error.rb │ ├── fake_authenticator.rb │ ├── fake_authenticator │ ├── attestation_object.rb │ └── authenticator_data.rb │ ├── fake_client.rb │ ├── json_serializer.rb │ ├── public_key.rb │ ├── public_key_credential.rb │ ├── public_key_credential │ ├── creation_options.rb │ ├── entity.rb │ ├── options.rb │ ├── request_options.rb │ ├── rp_entity.rb │ └── user_entity.rb │ ├── public_key_credential_with_assertion.rb │ ├── public_key_credential_with_attestation.rb │ ├── relying_party.rb │ ├── u2f_migrator.rb │ └── version.rb ├── spec ├── conformance │ ├── .ruby-version │ ├── Gemfile │ ├── Gemfile.lock │ ├── MDSROOT.crt │ ├── README.md │ ├── conformance_cache_store.rb │ ├── conformance_patches.rb │ ├── mds_finder.rb │ └── server.rb ├── spec_helper.rb ├── support │ ├── roots │ │ ├── android_key_root.pem │ │ ├── android_safetynet_root.crt │ │ ├── feitian_ft_fido_0200.pem │ │ ├── microsoft_tpm_root_certificate_authority_2014.cer │ │ └── yubico_u2f_root.pem │ └── seeds.rb ├── webauthn │ ├── attestation_statement │ │ ├── android_key_spec.rb │ │ ├── android_safetynet_spec.rb │ │ ├── apple_spec.rb │ │ ├── fido_u2f_spec.rb │ │ ├── none_spec.rb │ │ ├── packed_spec.rb │ │ └── tpm_spec.rb │ ├── authenticator_assertion_response_spec.rb │ ├── authenticator_attestation_response_spec.rb │ ├── authenticator_data │ │ └── attested_credential_data_spec.rb │ ├── authenticator_data_spec.rb │ ├── credential_creation_options_spec.rb │ ├── credential_request_options_spec.rb │ ├── credential_spec.rb │ ├── fake_client_spec.rb │ ├── public_key_credential │ │ ├── creation_options_spec.rb │ │ └── request_options_spec.rb │ ├── public_key_credential_with_assertion_spec.rb │ ├── public_key_credential_with_attestation_spec.rb │ ├── public_key_spec.rb │ ├── relying_party_spec.rb │ └── u2f_migrator_spec.rb └── webauthn_spec.rb └── webauthn.gemspec /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: build 9 | 10 | on: 11 | push: 12 | branches: [master] 13 | pull_request: 14 | types: [opened, synchronize] 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-24.04 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | ruby: 23 | - '3.4' 24 | - '3.3' 25 | - '3.2' 26 | - '3.1' 27 | - '3.0' 28 | - '2.7' 29 | - '2.6' 30 | - '2.5' 31 | - truffleruby 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | bundler-cache: true 38 | - run: bundle exec rspec 39 | env: 40 | RUBYOPT: ${{ startsWith(matrix.ruby, '3.4') && '--enable=frozen-string-literal' || '' }} 41 | 42 | lint: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: ruby/setup-ruby@v1 47 | with: 48 | ruby-version: '3.3' 49 | bundler-cache: true 50 | - run: bundle exec rubocop -f github 51 | -------------------------------------------------------------------------------- /.github/workflows/git.yml: -------------------------------------------------------------------------------- 1 | # Syntax reference: 2 | # https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions 3 | name: Git Checks 4 | 5 | on: 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | # Fixup commits are OK in pull requests, but should generally be squashed 11 | # before merging to master, e.g. using `git rebase -i --autosquash master`. 12 | # See https://github.com/marketplace/actions/block-autosquash-commits 13 | block-fixup: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Block autosquash commits 19 | uses: xt0rted/block-autosquash-commits-action@v2 20 | with: 21 | repo-token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | /Gemfile.lock 14 | /gemfiles/*.gemfile.lock 15 | .byebug_history 16 | /spec/conformance/metadata.zip 17 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --order rand 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | - rubocop-rake 4 | 5 | inherit_mode: 6 | merge: 7 | - AllowedNames 8 | 9 | AllCops: 10 | TargetRubyVersion: 2.5 11 | DisabledByDefault: true 12 | NewCops: disable 13 | Exclude: 14 | - "gemfiles/**/*" 15 | - "vendor/**/*" 16 | 17 | Bundler: 18 | Enabled: true 19 | 20 | Gemspec: 21 | Enabled: true 22 | 23 | Layout: 24 | Enabled: true 25 | 26 | Layout/ClassStructure: 27 | Enabled: true 28 | 29 | Layout/EmptyLineBetweenDefs: 30 | AllowAdjacentOneLineDefs: true 31 | 32 | Layout/EmptyLinesAroundAttributeAccessor: 33 | Enabled: true 34 | 35 | Layout/FirstMethodArgumentLineBreak: 36 | Enabled: true 37 | 38 | Layout/LineLength: 39 | Max: 120 40 | Exclude: 41 | - spec/support/seeds.rb 42 | 43 | Layout/MultilineAssignmentLayout: 44 | Enabled: true 45 | 46 | Layout/MultilineMethodArgumentLineBreaks: 47 | Enabled: true 48 | 49 | Layout/SpaceAroundMethodCallOperator: 50 | Enabled: true 51 | 52 | Lint: 53 | Enabled: true 54 | 55 | Lint/DeprecatedOpenSSLConstant: 56 | Enabled: true 57 | 58 | Lint/MixedRegexpCaptureTypes: 59 | Enabled: true 60 | 61 | Lint/RaiseException: 62 | Enabled: true 63 | 64 | Lint/StructNewOverride: 65 | Enabled: true 66 | 67 | Lint/BinaryOperatorWithIdenticalOperands: 68 | Enabled: true 69 | 70 | Lint/DuplicateElsifCondition: 71 | Enabled: true 72 | 73 | Lint/DuplicateRescueException: 74 | Enabled: true 75 | 76 | Lint/EmptyConditionalBody: 77 | Enabled: true 78 | 79 | Lint/FloatComparison: 80 | Enabled: true 81 | 82 | Lint/MissingSuper: 83 | Enabled: true 84 | 85 | Lint/OutOfRangeRegexpRef: 86 | Enabled: true 87 | 88 | Lint/SelfAssignment: 89 | Enabled: true 90 | 91 | Lint/TopLevelReturnWithArgument: 92 | Enabled: true 93 | 94 | Lint/UnreachableLoop: 95 | Enabled: true 96 | 97 | Naming: 98 | Enabled: true 99 | 100 | Naming/VariableNumber: 101 | Enabled: false 102 | 103 | RSpec/Be: 104 | Enabled: true 105 | 106 | RSpec/BeforeAfterAll: 107 | Enabled: true 108 | 109 | RSpec/EmptyExampleGroup: 110 | Enabled: true 111 | 112 | RSpec/EmptyLineAfterExample: 113 | Enabled: true 114 | 115 | RSpec/EmptyLineAfterExampleGroup: 116 | Enabled: true 117 | 118 | RSpec/EmptyLineAfterFinalLet: 119 | Enabled: true 120 | 121 | RSpec/EmptyLineAfterHook: 122 | Enabled: true 123 | 124 | RSpec/EmptyLineAfterSubject: 125 | Enabled: true 126 | 127 | RSpec/HookArgument: 128 | Enabled: true 129 | 130 | RSpec/LeadingSubject: 131 | Enabled: true 132 | 133 | RSpec/NamedSubject: 134 | Enabled: true 135 | 136 | RSpec/ScatteredLet: 137 | Enabled: true 138 | 139 | RSpec/ScatteredSetup: 140 | Enabled: true 141 | 142 | Naming/MethodParameterName: 143 | AllowedNames: 144 | - rp 145 | 146 | Security: 147 | Enabled: true 148 | 149 | Style/BlockComments: 150 | Enabled: true 151 | 152 | Style/CaseEquality: 153 | Enabled: true 154 | 155 | Style/ClassAndModuleChildren: 156 | Enabled: true 157 | 158 | Style/ClassMethods: 159 | Enabled: true 160 | 161 | Style/ClassVars: 162 | Enabled: true 163 | 164 | Style/CommentAnnotation: 165 | Enabled: true 166 | 167 | Style/ConditionalAssignment: 168 | Enabled: true 169 | 170 | Style/DefWithParentheses: 171 | Enabled: true 172 | 173 | Style/Dir: 174 | Enabled: true 175 | 176 | Style/EachForSimpleLoop: 177 | Enabled: true 178 | 179 | Style/EachWithObject: 180 | Enabled: true 181 | 182 | Style/EmptyBlockParameter: 183 | Enabled: true 184 | 185 | Style/EmptyCaseCondition: 186 | Enabled: true 187 | 188 | Style/EmptyElse: 189 | Enabled: true 190 | 191 | Style/EmptyLambdaParameter: 192 | Enabled: true 193 | 194 | Style/EmptyLiteral: 195 | Enabled: true 196 | 197 | Style/EvenOdd: 198 | Enabled: true 199 | 200 | Style/ExpandPathArguments: 201 | Enabled: true 202 | 203 | Style/For: 204 | Enabled: true 205 | 206 | Style/FrozenStringLiteralComment: 207 | Enabled: true 208 | 209 | Style/GlobalVars: 210 | Enabled: true 211 | 212 | Style/HashSyntax: 213 | Enabled: true 214 | 215 | Style/IdenticalConditionalBranches: 216 | Enabled: true 217 | 218 | Style/IfInsideElse: 219 | Enabled: true 220 | 221 | Style/InverseMethods: 222 | Enabled: true 223 | 224 | Style/MethodCallWithoutArgsParentheses: 225 | Enabled: true 226 | 227 | Style/MethodDefParentheses: 228 | Enabled: true 229 | 230 | Style/MultilineMemoization: 231 | Enabled: true 232 | 233 | Style/MutableConstant: 234 | Enabled: true 235 | 236 | Style/NestedParenthesizedCalls: 237 | Enabled: true 238 | 239 | Style/OptionalArguments: 240 | Enabled: true 241 | 242 | Style/ParenthesesAroundCondition: 243 | Enabled: true 244 | 245 | Style/RedundantBegin: 246 | Enabled: true 247 | 248 | Style/RedundantConditional: 249 | Enabled: true 250 | 251 | Style/RedundantException: 252 | Enabled: true 253 | 254 | Style/RedundantFreeze: 255 | Enabled: true 256 | 257 | Style/RedundantInterpolation: 258 | Enabled: true 259 | 260 | Style/RedundantParentheses: 261 | Enabled: true 262 | 263 | Style/RedundantPercentQ: 264 | Enabled: true 265 | 266 | Style/RedundantReturn: 267 | Enabled: true 268 | 269 | Style/RedundantSelf: 270 | Enabled: true 271 | 272 | Style/Semicolon: 273 | Enabled: true 274 | 275 | Style/SingleLineMethods: 276 | Enabled: true 277 | 278 | Style/SpecialGlobalVars: 279 | Enabled: true 280 | 281 | Style/SymbolLiteral: 282 | Enabled: true 283 | 284 | Style/TrailingBodyOnClass: 285 | Enabled: true 286 | 287 | Style/TrailingBodyOnMethodDefinition: 288 | Enabled: true 289 | 290 | Style/TrailingBodyOnModule: 291 | Enabled: true 292 | 293 | Style/TrailingMethodEndStatement: 294 | Enabled: true 295 | 296 | Style/TrivialAccessors: 297 | Enabled: true 298 | 299 | Style/UnpackFirst: 300 | Enabled: true 301 | 302 | Style/YodaCondition: 303 | Enabled: true 304 | 305 | Style/ZeroLengthPredicate: 306 | Enabled: true 307 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to webauthn-ruby 2 | 3 | ### How? 4 | 5 | - Creating a new issue to report a bug 6 | - Creating a new issue to suggest a new feature 7 | - Commenting on an existing issue to answer an open question 8 | - Commenting on an existing issue to ask the reporter for more details to aid reproducing the problem 9 | - Improving documentation 10 | - Creating a pull request that fixes an issue (see [beginner friendly issues](https://github.com/cedarcode/webauthn-ruby/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)) 11 | - Creating a pull request that implements a new feature (worth first creating an issue to discuss the suggested feature) 12 | 13 | ### Development 14 | 15 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the tests and code-style checks. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 16 | 17 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 18 | 19 | ### Styleguide 20 | 21 | #### Ruby 22 | 23 | We use [rubocop](https://rubygems.org/gems/rubocop) to check ruby code style. 24 | 25 | #### Git commit messages 26 | 27 | We try to follow [Conventional Commits](https://conventionalcommits.org) specification since `v1.17.0`. 28 | 29 | On top of `fix` and `feat` types, we also use optional: 30 | 31 | * __build__: Changes that affect the build system or external dependencies 32 | * __ci__: Changes to the CI configuration files and scripts 33 | * __docs__: Documentation only changes 34 | * __perf__: A code change that improves performance 35 | * __refactor__: A code change that neither fixes a bug nor adds a feature 36 | * __style__: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 37 | * __test__: Adding missing tests or correcting existing tests 38 | 39 | Partially inspired in [Angular's Commit Message Guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines). 40 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in webauthn.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Gonzalo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new 9 | 10 | task default: [:rubocop, :spec] 11 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 2.5.z | :white_check_mark: | 8 | | 2.4.z | :white_check_mark: | 9 | | 2.3.z | :white_check_mark: | 10 | | 2.2.z | :x: | 11 | | 2.1.z | :x: | 12 | | 2.0.z | :x: | 13 | | 1.18.z | :white_check_mark: | 14 | | < 1.18 | :x: | 15 | 16 | ## Reporting a Vulnerability 17 | 18 | If you have discovered a security bug, please send an email to security@cedarcode.com 19 | instead of posting to the GitHub issue tracker. 20 | 21 | Thank you! 22 | -------------------------------------------------------------------------------- /assets/webauthn-ruby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-ruby/02258ff79027607b7d065eec9b66ebeab869443f/assets/webauthn-ruby.png -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "webauthn" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /docs/advanced_configuration.md: -------------------------------------------------------------------------------- 1 | # Advanced Configuration 2 | 3 | ## Global vs Instance Based Configuration 4 | 5 | Which approach suits best your needs will depend on the architecture of your application and how do your users need to register and authenticate to it. 6 | 7 | If you have a multi-tenant application, or any application segmenation, where your users register and authenticate to each of these tenants or segments individuallly using different hostnames, or with different security needs, you need to go through [Instance Based Configuration](#instance-based-configuration). 8 | 9 | However, if your application is served for just one hostname, or else if your users authenticate to only one subdmain (e.g. your application serves www.example.com and admin.example.com but all you users authenticate through auth.example.com) you can still rely on one [Global Configuration](../README.md#configuration). 10 | 11 | If you are still not sure, or want to keep your options open, be aware that [Instance Based Configuration](#instance-based-configuration) is also a valid way of defining a single instance configuration and how you share such configuration across your application, it's up to you. 12 | 13 | 14 | ## Instance Based Configuration 15 | 16 | Intead of the [Global Configuration](../README.md#configuration) you place in `config/initializers/webauthn.rb`, 17 | you can now have an on-demand instance of `WebAuthn::RelyingParty` with the same configuration options, that 18 | you can build anywhere in you application, in the following way: 19 | 20 | ```ruby 21 | relying_party = WebAuthn::RelyingParty.new( 22 | # This value needs to match `window.location.origin` evaluated by 23 | # the User Agent during registration and authentication ceremonies. 24 | origin: "https://admin.example.com", 25 | 26 | # Relying Party name for display purposes 27 | name: "Admin Site for Example Inc." 28 | 29 | # Optionally configure a client timeout hint, in milliseconds. 30 | # This hint specifies how long the browser should wait for any 31 | # interaction with the user. 32 | # This hint may be overridden by the browser. 33 | # https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout 34 | # credential_options_timeout: 120_000 35 | 36 | # You can optionally specify a different Relying Party ID 37 | # (https://www.w3.org/TR/webauthn/#relying-party-identifier) 38 | # if it differs from the default one. 39 | # 40 | # In this case the default would be "admin.example.com", but you can set it to 41 | # the suffix "example.com" 42 | # 43 | # id: "example.com" 44 | 45 | # Configure preferred binary-to-text encoding scheme. This should match the encoding scheme 46 | # used in your client-side (user agent) code before sending the credential to the server. 47 | # Supported values: `:base64url` (default), `:base64` or `false` to disable all encoding. 48 | # 49 | # encoding: :base64url 50 | 51 | # Possible values: "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "RS1" 52 | # Default: ["ES256", "PS256", "RS256"] 53 | # 54 | # algorithms: ["ES384"] 55 | ) 56 | ``` 57 | 58 | ## Instance Based API 59 | 60 | **DISCLAIMER: This API was released on version 3.0.0.alpha1 and is still under evaluation. Although it has been throughly tested and it is fully functional it might be changed until the final release of version 3.0.0.** 61 | 62 | The explanation for each ceremony can be found in depth in [Credential Registration](../README.md#credential-registration) and [Credential Authentication](../README.md#credential-authentication) but if you choose this instance based approach to define your WebAuthn configurations and assuming `relying_party` is the result of an instance you get through `WebAuthn::RelyingParty.new(...)` the code in those explanations needs to be updated to: 63 | 64 | ### Credential Registration 65 | 66 | #### Initiation phase 67 | 68 | ```ruby 69 | # Generate and store the WebAuthn User ID the first time the user registers a credential 70 | if !user.webauthn_id 71 | user.update!(webauthn_id: WebAuthn.generate_user_id) 72 | end 73 | 74 | options = relying_party.options_for_registration( 75 | user: { id: user.webauthn_id, name: user.name }, 76 | exclude: user.credentials.map { |c| c.external_id } 77 | ) 78 | 79 | # Store the newly generated challenge somewhere so you can have it 80 | # for the verification phase. 81 | session[:creation_challenge] = options.challenge 82 | 83 | # Send `options` back to the browser, so that they can be used 84 | # to call `navigator.credentials.create({ "publicKey": options })` 85 | # 86 | # You can call `options.as_json` to get a ruby hash with a JSON representation if needed. 87 | 88 | # If inside a Rails controller, `render json: options` will just work. 89 | # I.e. it will encode and convert the options to JSON automatically. 90 | 91 | # For your frontend code, you might find @github/webauthn-json npm package useful. 92 | # Especially for handling the necessary decoding of the options, and sending the 93 | # `PublicKeyCredential` object back to the server. 94 | ``` 95 | 96 | #### Verification phase 97 | 98 | ```ruby 99 | # Assuming you're using @github/webauthn-json package to send the `PublicKeyCredential` object back 100 | # in params[:publicKeyCredential]: 101 | begin 102 | webauthn_credential = relying_party.verify_registration( 103 | params[:publicKeyCredential], 104 | params[:create_challenge] 105 | ) 106 | 107 | # Store Credential ID, Credential Public Key and Sign Count for future authentications 108 | user.credentials.create!( 109 | external_id: webauthn_credential.id, 110 | public_key: webauthn_credential.public_key, 111 | sign_count: webauthn_credential.sign_count 112 | ) 113 | rescue WebAuthn::Error => e 114 | # Handle error 115 | end 116 | ``` 117 | 118 | ### Credential Authentication 119 | 120 | #### Initiation phase 121 | 122 | ```ruby 123 | options = relying_party.options_for_authentication(allow: user.credentials.map { |c| c.webauthn_id }) 124 | 125 | # Store the newly generated challenge somewhere so you can have it 126 | # for the verification phase. 127 | session[:authentication_challenge] = options.challenge 128 | 129 | # Send `options` back to the browser, so that they can be used 130 | # to call `navigator.credentials.get({ "publicKey": options })` 131 | 132 | # You can call `options.as_json` to get a ruby hash with a JSON representation if needed. 133 | 134 | # If inside a Rails controller, `render json: options` will just work. 135 | # I.e. it will encode and convert the options to JSON automatically. 136 | 137 | # For your frontend code, you might find @github/webauthn-json npm package useful. 138 | # Especially for handling the necessary decoding of the options, and sending the 139 | # `PublicKeyCredential` object back to the server. 140 | ``` 141 | 142 | #### Verification phase 143 | 144 | ```ruby 145 | begin 146 | # Assuming you're using @github/webauthn-json package to send the `PublicKeyCredential` object back 147 | # in params[:publicKeyCredential]: 148 | webauthn_credential, stored_credential = relying_party.verify_authentication( 149 | params[:publicKeyCredential], 150 | session[:authentication_challenge] 151 | ) do |webauthn_credential| 152 | # the returned object needs to respond to #public_key and #sign_count 153 | user.credentials.find_by(external_id: webauthn_credential.id) 154 | end 155 | 156 | # Update the stored credential sign count with the value from `webauthn_credential.sign_count` 157 | stored_credential.update!(sign_count: webauthn_credential.sign_count) 158 | 159 | # Continue with successful sign in or 2FA verification... 160 | 161 | rescue WebAuthn::SignCountVerificationError => e 162 | # Cryptographic verification of the authenticator data succeeded, but the signature counter was less then or equal 163 | # to the stored value. This can have several reasons and depending on your risk tolerance you can choose to fail or 164 | # pass authentication. For more information see https://www.w3.org/TR/webauthn/#sign-counter 165 | rescue WebAuthn::Error => e 166 | # Handle error 167 | end 168 | ``` 169 | 170 | ## Moving from Global to Instance Based Configuration 171 | 172 | Adding a configuration for a new instance does not mean you need to get rid of your Global configuration. They can co-exist in your application and be both available for the different usages you might have. `WebAuthn.configuration.relying_party` will always return the global one while `WebAuthn::RelyingParty.new`, executed anywhere in your codebase, will allow you to create a different instance as you see the need. They will not collide and instead operate in isolation without any shared state. 173 | 174 | The gem API described in the current [Usage](../README.md#usage) section for the [Global Configuration](../README.md#configuration) approach will still valid but the [Instance Based API](#instance-based-api) also works with the global `relying_party` that is maintain globally at `WebAuthn.configuration.relying_party`. 175 | -------------------------------------------------------------------------------- /docs/u2f_migration.md: -------------------------------------------------------------------------------- 1 | # Migrating from U2F to WebAuthn 2 | 3 | The Chromium team [recommends](https://groups.google.com/a/chromium.org/forum/#!msg/security-dev/BGWA1d7a6rI/W2avestmBAAJ) 4 | application developers to switch from the U2F API to the WebAuthn API. This document describes how a Ruby application 5 | using the [u2f gem by Castle](https://github.com/castle/ruby-u2f) can migrate existing credentials so that their users 6 | do not experience interruption or need to re-register their security keys. 7 | 8 | Note that the migration is one-way: credentials registered using WebAuthn cannot be made compatible with the U2F API. 9 | It is recommended to successfully migrate authorization flows before migrating registration flows. 10 | 11 | ## Migrate registered U2F credentials 12 | 13 | Assuming you have a registered credential per the u2f gem readme, base64 urlsafe encoded in a database: 14 | 15 | ```ruby 16 | # This domain will be used in all code examples. It's a single-facet app but a multi-facet AppID 17 | # (e.g. https://example.com/app-id.json) will work as well. 18 | domain = URI("https://login.example.com") 19 | 20 | u2f_registration = U2F::U2F.new(domain.to_s).register!(u2f_challenge, u2f_register_response) 21 | # => # 26 | ``` 27 | 28 | The `U2fMigrator` class quacks like `WebAuthn::AuthenticatorAttestationResponse` and can be used similarly as documented 29 | in the [registration verification phase](https://github.com/cedarcode/webauthn-ruby/blob/master/README.md#verification-phase). 30 | Of course a `verify` instance method is not implemented, as there is no real interaction with an authenticator. 31 | 32 | The migrator can be used to convert credentials in real time during authentication while keeping them stored in the U2F 33 | format, and in a backfill task to store credentials in the new format, depending on how you are approaching your 34 | migration. 35 | 36 | ```ruby 37 | require "webauthn/u2f_migrator" 38 | 39 | migrated_credential = WebAuthn::U2fMigrator.new( 40 | app_id: domain, 41 | certificate: u2f_registration.certificate, 42 | key_handle: u2f_registration.key_handle, 43 | public_key: u2f_registration.public_key, 44 | counter: u2f_registration.counter 45 | ) 46 | migrated_credential.credential.id 47 | # => "\x99\xB5LE83I>q.\xE9\x9C\x90l\xED'\xD5E[\xAB\xDE9\xB7\xCD!\x85\x92\x9F{\x13\xA8\x86" 48 | migrated_credential.credential.public_key 49 | # => "\xA5\x03& \x01!X \xE2P^Q`\xF9\x97\xD9*n<\x14\xDA\xB6a\xEEoK\x03\xACpMb\xED\x8B\x06E\"#!\xED\xC6\x01\x02\"X #C\x97\xAD C\x000\xE7\xD1\xD4%\xCFh\x83\xCD\x9E\xCB\xBC,\"\x1F>\xF6SZ\xA1U\xAB7\xBE\xEB" 50 | migrated_credential.authenticator_data.sign_count 51 | # => 41 52 | ``` 53 | 54 | ## Authenticate migrated U2F credentials 55 | 56 | Following the documentation on the [authentication initiation](https://github.com/cedarcode/webauthn-ruby/blob/master/README.md#initiation-phase-1), 57 | you need to specify the [FIDO AppID extension](https://www.w3.org/TR/webauthn/#sctn-appid-extension) for U2F migratedq 58 | credentials. The WebAuthn standard explains: 59 | 60 | > The FIDO APIs use an alternative identifier for Relying Parties called an _AppID_, and any credentials created using 61 | > those APIs will be scoped to that identifier. Without this extension, they would need to be re-registered in order to 62 | > be scoped to an RP ID. 63 | 64 | For the earlier given example `domain` this means: 65 | - FIDO AppID: `https://login.example.com` 66 | - Valid RP IDs: `login.example.com` (default) and `example.com` 67 | 68 | You can request the use of the `appid` extension by setting the AppID in the configuration, like this: 69 | 70 | ```ruby 71 | WebAuthn.configure do |config| 72 | config.legacy_u2f_appid = "https://login.example.com" 73 | end 74 | ``` 75 | 76 | By doing this, the `appid` extension will be automatically requested when generating the options for get: 77 | 78 | ```ruby 79 | options = WebAuthn::Credential.options_for_get 80 | ``` 81 | 82 | On the frontend, in the resolved value from `navigator.credentials.get({ "publicKey": credentialRequestOptions })` add 83 | a call to [getClientExtensionResults()](https://www.w3.org/TR/webauthn/#dom-publickeycredential-getclientextensionresults) 84 | and send its result to your backend alongside the `id`/`rawId` and `response` values. If the authenticator used the AppID 85 | extension, the returned value will contain `{ "appid": true }`. 86 | 87 | During authentication verification phase, if you followed the [verification phase documentation](https://github.com/cedarcode/webauthn-ruby#verification-phase-1) and have set the AppID in the config, the method `PublicKeyCredentialWithAssertion#verify` will be smart enough to determine if it should use the AppID or the RP ID to verify the WebAuthn credential, depending on the output of the `appid` client extension: 88 | 89 | > If true, the AppID was used and thus, when verifying an assertion, the Relying Party MUST expect the `rpIdHash` to be 90 | > the hash of the _AppID_, not the RP ID. 91 | -------------------------------------------------------------------------------- /lib/cose/rsapkcs1_algorithm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose" 4 | require "cose/algorithm/signature_algorithm" 5 | require "cose/error" 6 | require "cose/key/rsa" 7 | require "openssl/signature_algorithm/rsapkcs1" 8 | 9 | class RSAPKCS1Algorithm < COSE::Algorithm::SignatureAlgorithm 10 | attr_reader :hash_function 11 | 12 | def initialize(*args, hash_function:) 13 | super(*args) 14 | 15 | @hash_function = hash_function 16 | end 17 | 18 | private 19 | 20 | def signature_algorithm_class 21 | OpenSSL::SignatureAlgorithm::RSAPKCS1 22 | end 23 | 24 | def valid_key?(key) 25 | to_cose_key(key).is_a?(COSE::Key::RSA) 26 | end 27 | 28 | def to_pkey(key) 29 | case key 30 | when COSE::Key::RSA 31 | key.to_pkey 32 | when OpenSSL::PKey::RSA 33 | key 34 | else 35 | raise(COSE::Error, "Incompatible key for algorithm") 36 | end 37 | end 38 | end 39 | 40 | COSE::Algorithm.register(RSAPKCS1Algorithm.new(-257, "RS256", hash_function: "SHA256")) 41 | COSE::Algorithm.register(RSAPKCS1Algorithm.new(-258, "RS384", hash_function: "SHA384")) 42 | COSE::Algorithm.register(RSAPKCS1Algorithm.new(-259, "RS512", hash_function: "SHA512")) 43 | 44 | # Patch openssl-signature_algorithm gem to support discouraged/deprecated RSA-PKCS#1 with SHA-1 45 | # (RS1 in JOSE/COSE terminology) algorithm needed for WebAuthn. 46 | OpenSSL::SignatureAlgorithm::RSAPKCS1.const_set( 47 | :ACCEPTED_HASH_FUNCTIONS, 48 | OpenSSL::SignatureAlgorithm::RSAPKCS1::ACCEPTED_HASH_FUNCTIONS + ["SHA1"] 49 | ) 50 | COSE::Algorithm.register(RSAPKCS1Algorithm.new(-65535, "RS1", hash_function: "SHA1")) 51 | -------------------------------------------------------------------------------- /lib/webauthn.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/json_serializer" 4 | require "webauthn/configuration" 5 | require "webauthn/credential" 6 | require "webauthn/credential_creation_options" 7 | require "webauthn/credential_request_options" 8 | require "webauthn/version" 9 | 10 | module WebAuthn 11 | TYPE_PUBLIC_KEY = "public-key" 12 | 13 | def self.generate_user_id 14 | configuration.encoder.encode(SecureRandom.random_bytes(64)) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cbor" 4 | require "forwardable" 5 | require "openssl" 6 | require "webauthn/attestation_statement" 7 | require "webauthn/authenticator_data" 8 | 9 | module WebAuthn 10 | class AttestationObject 11 | extend Forwardable 12 | 13 | def self.deserialize(attestation_object, relying_party) 14 | from_map(CBOR.decode(attestation_object), relying_party) 15 | end 16 | 17 | def self.from_map(map, relying_party) 18 | new( 19 | authenticator_data: WebAuthn::AuthenticatorData.deserialize(map["authData"]), 20 | attestation_statement: WebAuthn::AttestationStatement.from( 21 | map["fmt"], 22 | map["attStmt"], 23 | relying_party: relying_party 24 | ) 25 | ) 26 | end 27 | 28 | attr_reader :authenticator_data, :attestation_statement, :relying_party 29 | 30 | def initialize(authenticator_data:, attestation_statement:) 31 | @authenticator_data = authenticator_data 32 | @attestation_statement = attestation_statement 33 | end 34 | 35 | def valid_attested_credential? 36 | authenticator_data.attested_credential_data_included? && 37 | authenticator_data.attested_credential_data.valid? 38 | end 39 | 40 | def valid_attestation_statement?(client_data_hash) 41 | attestation_statement.valid?(authenticator_data, client_data_hash) 42 | end 43 | 44 | def_delegators :authenticator_data, :credential, :aaguid 45 | def_delegators :attestation_statement, :attestation_certificate_key_id 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/attestation_statement/android_key" 4 | require "webauthn/attestation_statement/android_safetynet" 5 | require "webauthn/attestation_statement/apple" 6 | require "webauthn/attestation_statement/fido_u2f" 7 | require "webauthn/attestation_statement/none" 8 | require "webauthn/attestation_statement/packed" 9 | require "webauthn/attestation_statement/tpm" 10 | require "webauthn/error" 11 | 12 | module WebAuthn 13 | module AttestationStatement 14 | class FormatNotSupportedError < Error; end 15 | 16 | ATTESTATION_FORMAT_NONE = "none" 17 | ATTESTATION_FORMAT_FIDO_U2F = "fido-u2f" 18 | ATTESTATION_FORMAT_PACKED = 'packed' 19 | ATTESTATION_FORMAT_ANDROID_SAFETYNET = "android-safetynet" 20 | ATTESTATION_FORMAT_ANDROID_KEY = "android-key" 21 | ATTESTATION_FORMAT_TPM = "tpm" 22 | ATTESTATION_FORMAT_APPLE = "apple" 23 | 24 | FORMAT_TO_CLASS = { 25 | ATTESTATION_FORMAT_NONE => WebAuthn::AttestationStatement::None, 26 | ATTESTATION_FORMAT_FIDO_U2F => WebAuthn::AttestationStatement::FidoU2f, 27 | ATTESTATION_FORMAT_PACKED => WebAuthn::AttestationStatement::Packed, 28 | ATTESTATION_FORMAT_ANDROID_SAFETYNET => WebAuthn::AttestationStatement::AndroidSafetynet, 29 | ATTESTATION_FORMAT_ANDROID_KEY => WebAuthn::AttestationStatement::AndroidKey, 30 | ATTESTATION_FORMAT_TPM => WebAuthn::AttestationStatement::TPM, 31 | ATTESTATION_FORMAT_APPLE => WebAuthn::AttestationStatement::Apple 32 | }.freeze 33 | 34 | def self.from(format, statement, relying_party: WebAuthn.configuration.relying_party) 35 | klass = FORMAT_TO_CLASS[format] 36 | 37 | if klass 38 | klass.new(statement, relying_party) 39 | else 40 | raise(FormatNotSupportedError, "Unsupported attestation format '#{format}'") 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/android_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "android_key_attestation" 4 | require "openssl" 5 | require "webauthn/attestation_statement/base" 6 | 7 | module WebAuthn 8 | module AttestationStatement 9 | class AndroidKey < Base 10 | def valid?(authenticator_data, client_data_hash) 11 | valid_signature?(authenticator_data, client_data_hash) && 12 | matching_public_key?(authenticator_data) && 13 | valid_attestation_challenge?(client_data_hash) && 14 | all_applications_fields_not_set? && 15 | valid_authorization_list_origin? && 16 | valid_authorization_list_purpose? && 17 | trustworthy?(aaguid: authenticator_data.aaguid) && 18 | [attestation_type, attestation_trust_path] 19 | end 20 | 21 | private 22 | 23 | def valid_attestation_challenge?(client_data_hash) 24 | android_key_attestation.verify_challenge(client_data_hash) 25 | rescue AndroidKeyAttestation::ChallengeMismatchError 26 | false 27 | end 28 | 29 | def valid_certificate_chain?(aaguid: nil, **_) 30 | android_key_attestation.verify_certificate_chain(root_certificates: root_certificates(aaguid: aaguid)) 31 | rescue AndroidKeyAttestation::CertificateVerificationError 32 | false 33 | end 34 | 35 | def all_applications_fields_not_set? 36 | !tee_enforced.all_applications && !software_enforced.all_applications 37 | end 38 | 39 | def valid_authorization_list_origin? 40 | tee_enforced.origin == :generated || software_enforced.origin == :generated 41 | end 42 | 43 | def valid_authorization_list_purpose? 44 | tee_enforced.purpose == [:sign] || software_enforced.purpose == [:sign] 45 | end 46 | 47 | def tee_enforced 48 | android_key_attestation.tee_enforced 49 | end 50 | 51 | def software_enforced 52 | android_key_attestation.software_enforced 53 | end 54 | 55 | def attestation_type 56 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC 57 | end 58 | 59 | def default_root_certificates 60 | AndroidKeyAttestation::Statement::GOOGLE_ROOT_CERTIFICATES 61 | end 62 | 63 | def android_key_attestation 64 | @android_key_attestation ||= AndroidKeyAttestation::Statement.new(*certificates) 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/android_safetynet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "safety_net_attestation" 4 | require "openssl" 5 | require "webauthn/attestation_statement/base" 6 | 7 | module WebAuthn 8 | module AttestationStatement 9 | # Implements https://www.w3.org/TR/webauthn-1/#sctn-android-safetynet-attestation 10 | class AndroidSafetynet < Base 11 | def valid?(authenticator_data, client_data_hash) 12 | valid_response?(authenticator_data, client_data_hash) && 13 | valid_version? && 14 | cts_profile_match? && 15 | trustworthy?(aaguid: authenticator_data.aaguid) && 16 | [attestation_type, attestation_trust_path] 17 | end 18 | 19 | private 20 | 21 | def valid_response?(authenticator_data, client_data_hash) 22 | nonce = Digest::SHA256.base64digest(authenticator_data.data + client_data_hash) 23 | 24 | begin 25 | attestation_response 26 | .verify(nonce, trusted_certificates: root_certificates(aaguid: authenticator_data.aaguid), time: time) 27 | rescue SafetyNetAttestation::Error 28 | false 29 | end 30 | end 31 | 32 | # TODO: improve once the spec has clarifications https://github.com/w3c/webauthn/issues/968 33 | def valid_version? 34 | !statement["ver"].empty? 35 | end 36 | 37 | def cts_profile_match? 38 | attestation_response.cts_profile_match? 39 | end 40 | 41 | def valid_certificate_chain?(**_) 42 | # Already performed as part of #valid_response? 43 | true 44 | end 45 | 46 | def attestation_type 47 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC 48 | end 49 | 50 | # SafetyNetAttestation returns full chain including root, WebAuthn expects only the x5c certificates 51 | def certificates 52 | attestation_response.certificate_chain[0..-2] 53 | end 54 | 55 | def attestation_response 56 | @attestation_response ||= SafetyNetAttestation::Statement.new(statement["response"]) 57 | end 58 | 59 | def default_root_certificates 60 | SafetyNetAttestation::Statement::GOOGLE_ROOT_CERTIFICATES 61 | end 62 | 63 | def time 64 | Time.now 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/apple.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "webauthn/attestation_statement/base" 5 | 6 | module WebAuthn 7 | module AttestationStatement 8 | class Apple < Base 9 | # Source: https://www.apple.com/certificateauthority/private/ 10 | ROOT_CERTIFICATE = 11 | OpenSSL::X509::Certificate.new(<<~PEM) 12 | -----BEGIN CERTIFICATE----- 13 | MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w 14 | HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ 15 | bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx 16 | NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG 17 | A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49 18 | AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k 19 | xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/ 20 | pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk 21 | 2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA 22 | MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3 23 | jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B 24 | 1bWeT0vT 25 | -----END CERTIFICATE----- 26 | PEM 27 | 28 | NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2" 29 | 30 | def valid?(authenticator_data, client_data_hash) 31 | valid_nonce?(authenticator_data, client_data_hash) && 32 | matching_public_key?(authenticator_data) && 33 | trustworthy? && 34 | [attestation_type, attestation_trust_path] 35 | end 36 | 37 | private 38 | 39 | def valid_nonce?(authenticator_data, client_data_hash) 40 | extension = cred_cert&.find_extension(NONCE_EXTENSION_OID) 41 | 42 | if extension 43 | sequence = OpenSSL::ASN1.decode(extension.value_der) 44 | 45 | sequence.tag == OpenSSL::ASN1::SEQUENCE && 46 | sequence.value.size == 1 && 47 | sequence.value[0].value[0].value == 48 | OpenSSL::Digest::SHA256.digest(authenticator_data.data + client_data_hash) 49 | end 50 | end 51 | 52 | def attestation_type 53 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_ANONCA 54 | end 55 | 56 | def cred_cert 57 | attestation_certificate 58 | end 59 | 60 | def default_root_certificates 61 | [ROOT_CERTIFICATE] 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "cose/error" 5 | require "cose/rsapkcs1_algorithm" 6 | require "openssl" 7 | require "webauthn/authenticator_data/attested_credential_data" 8 | require "webauthn/error" 9 | 10 | module WebAuthn 11 | module AttestationStatement 12 | class UnsupportedAlgorithm < Error; end 13 | 14 | ATTESTATION_TYPE_NONE = "None" 15 | ATTESTATION_TYPE_BASIC = "Basic" 16 | ATTESTATION_TYPE_SELF = "Self" 17 | ATTESTATION_TYPE_ATTCA = "AttCA" 18 | ATTESTATION_TYPE_BASIC_OR_ATTCA = "Basic_or_AttCA" 19 | ATTESTATION_TYPE_ANONCA = "AnonCA" 20 | 21 | ATTESTATION_TYPES_WITH_ROOT = [ 22 | ATTESTATION_TYPE_BASIC, 23 | ATTESTATION_TYPE_BASIC_OR_ATTCA, 24 | ATTESTATION_TYPE_ATTCA, 25 | ATTESTATION_TYPE_ANONCA 26 | ].freeze 27 | 28 | class Base 29 | AAGUID_EXTENSION_OID = "1.3.6.1.4.1.45724.1.1.4" 30 | 31 | def initialize(statement, relying_party = WebAuthn.configuration.relying_party) 32 | @statement = statement 33 | @relying_party = relying_party 34 | end 35 | 36 | def valid?(_authenticator_data, _client_data_hash) 37 | raise NotImplementedError 38 | end 39 | 40 | def format 41 | WebAuthn::AttestationStatement::FORMAT_TO_CLASS.key(self.class) 42 | end 43 | 44 | def attestation_certificate 45 | certificates&.first 46 | end 47 | 48 | def attestation_certificate_key_id 49 | attestation_certificate.subject_key_identifier&.unpack1("H*") 50 | end 51 | 52 | private 53 | 54 | attr_reader :statement, :relying_party 55 | 56 | def matching_aaguid?(attested_credential_data_aaguid) 57 | extension = attestation_certificate&.find_extension(AAGUID_EXTENSION_OID) 58 | if extension 59 | aaguid_value = OpenSSL::ASN1.decode(extension.value_der).value 60 | aaguid_value == attested_credential_data_aaguid 61 | else 62 | true 63 | end 64 | end 65 | 66 | def matching_public_key?(authenticator_data) 67 | attestation_certificate.public_key.to_der == authenticator_data.credential.public_key_object.to_der 68 | end 69 | 70 | def certificates 71 | @certificates ||= 72 | raw_certificates&.map do |raw_certificate| 73 | OpenSSL::X509::Certificate.new(raw_certificate) 74 | end 75 | end 76 | 77 | def algorithm 78 | statement["alg"] 79 | end 80 | 81 | def raw_certificates 82 | statement["x5c"] 83 | end 84 | 85 | def signature 86 | statement["sig"] 87 | end 88 | 89 | def attestation_trust_path 90 | if certificates&.any? 91 | certificates 92 | end 93 | end 94 | 95 | def trustworthy?(aaguid: nil, attestation_certificate_key_id: nil) 96 | if ATTESTATION_TYPES_WITH_ROOT.include?(attestation_type) 97 | relying_party.acceptable_attestation_types.include?(attestation_type) && 98 | valid_certificate_chain?(aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id) 99 | else 100 | relying_party.acceptable_attestation_types.include?(attestation_type) 101 | end 102 | end 103 | 104 | def valid_certificate_chain?(aaguid: nil, attestation_certificate_key_id: nil) 105 | root_certificates = root_certificates( 106 | aaguid: aaguid, 107 | attestation_certificate_key_id: attestation_certificate_key_id 108 | ) 109 | 110 | if certificates&.one? && root_certificates.include?(attestation_certificate) 111 | return true 112 | end 113 | 114 | attestation_root_certificates_store( 115 | aaguid: aaguid, 116 | attestation_certificate_key_id: attestation_certificate_key_id 117 | ).verify(attestation_certificate, attestation_trust_path) 118 | end 119 | 120 | def attestation_root_certificates_store(aaguid: nil, attestation_certificate_key_id: nil) 121 | OpenSSL::X509::Store.new.tap do |store| 122 | root_certificates( 123 | aaguid: aaguid, 124 | attestation_certificate_key_id: attestation_certificate_key_id 125 | ).each do |cert| 126 | store.add_cert(cert) 127 | end 128 | end 129 | end 130 | 131 | def root_certificates(aaguid: nil, attestation_certificate_key_id: nil) 132 | root_certificates = 133 | relying_party.attestation_root_certificates_finders.reduce([]) do |certs, finder| 134 | if certs.empty? 135 | finder.find( 136 | attestation_format: format, 137 | aaguid: aaguid, 138 | attestation_certificate_key_id: attestation_certificate_key_id 139 | ) || [] 140 | else 141 | certs 142 | end 143 | end 144 | 145 | if root_certificates.empty? && respond_to?(:default_root_certificates, true) 146 | default_root_certificates 147 | else 148 | root_certificates 149 | end 150 | end 151 | 152 | def valid_signature?(authenticator_data, client_data_hash, public_key = attestation_certificate.public_key) 153 | raise("Incompatible algorithm and key") unless cose_algorithm.compatible_key?(public_key) 154 | 155 | cose_algorithm.verify( 156 | public_key, 157 | signature, 158 | verification_data(authenticator_data, client_data_hash) 159 | ) 160 | rescue COSE::Error 161 | false 162 | end 163 | 164 | def verification_data(authenticator_data, client_data_hash) 165 | authenticator_data.data + client_data_hash 166 | end 167 | 168 | def cose_algorithm 169 | @cose_algorithm ||= 170 | COSE::Algorithm.find(algorithm).tap do |alg| 171 | alg && relying_party.algorithms.include?(alg.name) || 172 | raise(UnsupportedAlgorithm, "Unsupported algorithm #{algorithm}") 173 | end 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/fido_u2f.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose" 4 | require "openssl" 5 | require "webauthn/attestation_statement/base" 6 | require "webauthn/attestation_statement/fido_u2f/public_key" 7 | 8 | module WebAuthn 9 | module AttestationStatement 10 | class FidoU2f < Base 11 | VALID_ATTESTATION_CERTIFICATE_COUNT = 1 12 | VALID_ATTESTATION_CERTIFICATE_ALGORITHM = COSE::Algorithm.by_name("ES256") 13 | VALID_ATTESTATION_CERTIFICATE_KEY_CURVE = COSE::Key::Curve.by_name("P-256") 14 | 15 | def valid?(authenticator_data, client_data_hash) 16 | valid_format? && 17 | valid_certificate_public_key? && 18 | valid_credential_public_key?(authenticator_data.credential.public_key) && 19 | valid_aaguid?(authenticator_data.attested_credential_data.raw_aaguid) && 20 | valid_signature?(authenticator_data, client_data_hash) && 21 | trustworthy?(attestation_certificate_key_id: attestation_certificate_key_id) && 22 | [attestation_type, attestation_trust_path] 23 | end 24 | 25 | private 26 | 27 | def valid_format? 28 | !!(raw_certificates && signature) && 29 | raw_certificates.length == VALID_ATTESTATION_CERTIFICATE_COUNT 30 | end 31 | 32 | def valid_certificate_public_key? 33 | certificate_public_key.is_a?(OpenSSL::PKey::EC) && 34 | certificate_public_key.group.curve_name == VALID_ATTESTATION_CERTIFICATE_KEY_CURVE.pkey_name && 35 | certificate_public_key.check_key 36 | end 37 | 38 | def valid_credential_public_key?(public_key_bytes) 39 | public_key_u2f(public_key_bytes).valid? 40 | end 41 | 42 | def certificate_public_key 43 | attestation_certificate.public_key 44 | end 45 | 46 | def valid_aaguid?(attested_credential_data_aaguid) 47 | attested_credential_data_aaguid == WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID 48 | end 49 | 50 | def algorithm 51 | VALID_ATTESTATION_CERTIFICATE_ALGORITHM.id 52 | end 53 | 54 | def verification_data(authenticator_data, client_data_hash) 55 | "\x00" + 56 | authenticator_data.rp_id_hash + 57 | client_data_hash + 58 | authenticator_data.credential.id + 59 | public_key_u2f(authenticator_data.credential.public_key).to_uncompressed_point 60 | end 61 | 62 | def public_key_u2f(cose_key_data) 63 | PublicKey.new(cose_key_data) 64 | end 65 | 66 | def attestation_type 67 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC_OR_ATTCA 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/fido_u2f/public_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "cose/key/ec2" 5 | require "webauthn/attestation_statement/base" 6 | 7 | module WebAuthn 8 | module AttestationStatement 9 | class FidoU2f < Base 10 | class PublicKey 11 | COORDINATE_LENGTH = 32 12 | UNCOMPRESSED_FORM_INDICATOR = "\x04" 13 | 14 | def self.uncompressed_point?(data) 15 | data.size && 16 | data.length == UNCOMPRESSED_FORM_INDICATOR.length + COORDINATE_LENGTH * 2 && 17 | data[0] == UNCOMPRESSED_FORM_INDICATOR 18 | end 19 | 20 | def initialize(data) 21 | @data = data 22 | end 23 | 24 | def valid? 25 | data.size >= COORDINATE_LENGTH * 2 && 26 | cose_key.x.length == COORDINATE_LENGTH && 27 | cose_key.y.length == COORDINATE_LENGTH && 28 | cose_key.alg == COSE::Algorithm.by_name("ES256").id 29 | end 30 | 31 | def to_uncompressed_point 32 | UNCOMPRESSED_FORM_INDICATOR + cose_key.x + cose_key.y 33 | end 34 | 35 | private 36 | 37 | attr_reader :data 38 | 39 | def cose_key 40 | @cose_key ||= COSE::Key::EC2.deserialize(data) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/none.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/attestation_statement/base" 4 | 5 | module WebAuthn 6 | module AttestationStatement 7 | class None < Base 8 | def valid?(*_args) 9 | if statement == {} && trustworthy? 10 | [WebAuthn::AttestationStatement::ATTESTATION_TYPE_NONE, nil] 11 | else 12 | false 13 | end 14 | end 15 | 16 | private 17 | 18 | def attestation_type 19 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_NONE 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/packed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "webauthn/attestation_statement/base" 5 | 6 | module WebAuthn 7 | # Implements https://www.w3.org/TR/2018/CR-webauthn-20180807/#packed-attestation 8 | module AttestationStatement 9 | class Packed < Base 10 | # Follows "Verification procedure" 11 | def valid?(authenticator_data, client_data_hash) 12 | valid_format? && 13 | valid_algorithm?(authenticator_data.credential) && 14 | valid_ec_public_keys?(authenticator_data.credential) && 15 | meet_certificate_requirement? && 16 | matching_aaguid?(authenticator_data.attested_credential_data.raw_aaguid) && 17 | valid_signature?(authenticator_data, client_data_hash) && 18 | trustworthy?(aaguid: authenticator_data.aaguid) && 19 | [attestation_type, attestation_trust_path] 20 | end 21 | 22 | private 23 | 24 | def valid_algorithm?(credential) 25 | !self_attestation? || algorithm == COSE::Key.deserialize(credential.public_key).alg 26 | end 27 | 28 | def self_attestation? 29 | !raw_certificates 30 | end 31 | 32 | def valid_format? 33 | algorithm && signature 34 | end 35 | 36 | def valid_ec_public_keys?(credential) 37 | (certificates&.map(&:public_key) || [credential.public_key_object]) 38 | .select { |pkey| pkey.is_a?(OpenSSL::PKey::EC) } 39 | .all? { |pkey| pkey.check_key } 40 | end 41 | 42 | # Check https://www.w3.org/TR/2018/CR-webauthn-20180807/#packed-attestation-cert-requirements 43 | def meet_certificate_requirement? 44 | if attestation_certificate 45 | subject = attestation_certificate.subject.to_a 46 | 47 | attestation_certificate.version == 2 && 48 | subject.assoc('OU')&.at(1) == "Authenticator Attestation" && 49 | attestation_certificate.find_extension('basicConstraints')&.value == 'CA:FALSE' 50 | else 51 | true 52 | end 53 | end 54 | 55 | def attestation_type 56 | if attestation_trust_path 57 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC_OR_ATTCA # FIXME: use metadata if available 58 | else 59 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_SELF 60 | end 61 | end 62 | 63 | def valid_signature?(authenticator_data, client_data_hash) 64 | super( 65 | authenticator_data, 66 | client_data_hash, 67 | attestation_certificate&.public_key || authenticator_data.credential.public_key_object 68 | ) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/webauthn/attestation_statement/tpm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "openssl" 5 | require "tpm/key_attestation" 6 | require "webauthn/attestation_statement/base" 7 | 8 | module WebAuthn 9 | module AttestationStatement 10 | class TPM < Base 11 | TPM_V2 = "2.0" 12 | 13 | COSE_ALG_TO_TPM = { 14 | "RS1" => { signature: ::TPM::ALG_RSASSA, hash: ::TPM::ALG_SHA1 }, 15 | "RS256" => { signature: ::TPM::ALG_RSASSA, hash: ::TPM::ALG_SHA256 }, 16 | "PS256" => { signature: ::TPM::ALG_RSAPSS, hash: ::TPM::ALG_SHA256 }, 17 | "ES256" => { signature: ::TPM::ALG_ECDSA, hash: ::TPM::ALG_SHA256 }, 18 | }.freeze 19 | 20 | def valid?(authenticator_data, client_data_hash) 21 | attestation_type == ATTESTATION_TYPE_ATTCA && 22 | ver == TPM_V2 && 23 | valid_key_attestation?( 24 | authenticator_data.data + client_data_hash, 25 | authenticator_data.credential.public_key_object, 26 | authenticator_data.aaguid 27 | ) && 28 | matching_aaguid?(authenticator_data.attested_credential_data.raw_aaguid) && 29 | trustworthy?(aaguid: authenticator_data.aaguid) && 30 | [attestation_type, attestation_trust_path] 31 | end 32 | 33 | private 34 | 35 | def valid_key_attestation?(certified_extra_data, key, aaguid) 36 | key_attestation = 37 | ::TPM::KeyAttestation.new( 38 | statement["certInfo"], 39 | signature, 40 | statement["pubArea"], 41 | certificates, 42 | OpenSSL::Digest.digest(cose_algorithm.hash_function, certified_extra_data), 43 | signature_algorithm: tpm_algorithm[:signature], 44 | hash_algorithm: tpm_algorithm[:hash], 45 | trusted_certificates: root_certificates(aaguid: aaguid) 46 | ) 47 | 48 | key_attestation.valid? && key_attestation.key && key_attestation.key.to_pem == key.to_pem 49 | end 50 | 51 | def valid_certificate_chain?(**_) 52 | # Already performed as part of #valid_key_attestation? 53 | true 54 | end 55 | 56 | def default_root_certificates 57 | ::TPM::KeyAttestation::TRUSTED_CERTIFICATES 58 | end 59 | 60 | def tpm_algorithm 61 | COSE_ALG_TO_TPM[cose_algorithm.name] || raise("Unsupported algorithm #{cose_algorithm.name}") 62 | end 63 | 64 | def ver 65 | statement["ver"] 66 | end 67 | 68 | def cose_algorithm 69 | @cose_algorithm ||= COSE::Algorithm.find(algorithm) 70 | end 71 | 72 | def attestation_type 73 | if raw_certificates 74 | ATTESTATION_TYPE_ATTCA 75 | else 76 | raise "Attestation type invalid" 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_assertion_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/authenticator_data" 4 | require "webauthn/authenticator_response" 5 | require "webauthn/encoder" 6 | require "webauthn/public_key" 7 | 8 | module WebAuthn 9 | class SignatureVerificationError < VerificationError; end 10 | class SignCountVerificationError < VerificationError; end 11 | 12 | class AuthenticatorAssertionResponse < AuthenticatorResponse 13 | def self.from_client(response, relying_party: WebAuthn.configuration.relying_party) 14 | encoder = relying_party.encoder 15 | 16 | user_handle = 17 | if response["userHandle"] 18 | encoder.decode(response["userHandle"]) 19 | end 20 | 21 | new( 22 | authenticator_data: encoder.decode(response["authenticatorData"]), 23 | client_data_json: encoder.decode(response["clientDataJSON"]), 24 | signature: encoder.decode(response["signature"]), 25 | user_handle: user_handle, 26 | relying_party: relying_party 27 | ) 28 | end 29 | 30 | attr_reader :user_handle 31 | 32 | def initialize(authenticator_data:, signature:, user_handle: nil, **options) 33 | super(**options) 34 | 35 | @authenticator_data_bytes = authenticator_data 36 | @signature = signature 37 | @user_handle = user_handle 38 | end 39 | 40 | def verify( 41 | expected_challenge, 42 | expected_origin = nil, 43 | public_key:, 44 | sign_count:, 45 | user_presence: nil, 46 | user_verification: nil, 47 | rp_id: nil 48 | ) 49 | super( 50 | expected_challenge, 51 | expected_origin, 52 | user_presence: user_presence, 53 | user_verification: user_verification, 54 | rp_id: rp_id 55 | ) 56 | verify_item(:signature, WebAuthn::PublicKey.deserialize(public_key)) 57 | verify_item(:sign_count, sign_count) 58 | 59 | true 60 | end 61 | 62 | def authenticator_data 63 | @authenticator_data ||= WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) 64 | end 65 | 66 | private 67 | 68 | attr_reader :authenticator_data_bytes, :signature 69 | 70 | def valid_signature?(webauthn_public_key) 71 | webauthn_public_key.verify(signature, authenticator_data_bytes + client_data.hash) 72 | end 73 | 74 | def valid_sign_count?(stored_sign_count) 75 | normalized_sign_count = stored_sign_count || 0 76 | if authenticator_data.sign_count.nonzero? || normalized_sign_count.nonzero? 77 | authenticator_data.sign_count > normalized_sign_count 78 | else 79 | true 80 | end 81 | end 82 | 83 | def type 84 | WebAuthn::TYPES[:get] 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_attestation_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cbor" 4 | require "forwardable" 5 | require "uri" 6 | require "openssl" 7 | 8 | require "webauthn/attestation_object" 9 | require "webauthn/authenticator_response" 10 | require "webauthn/client_data" 11 | require "webauthn/encoder" 12 | 13 | module WebAuthn 14 | class AttestationStatementVerificationError < VerificationError; end 15 | class AttestedCredentialVerificationError < VerificationError; end 16 | 17 | class AuthenticatorAttestationResponse < AuthenticatorResponse 18 | extend Forwardable 19 | 20 | def self.from_client(response, relying_party: WebAuthn.configuration.relying_party) 21 | encoder = relying_party.encoder 22 | 23 | new( 24 | attestation_object: encoder.decode(response["attestationObject"]), 25 | transports: response["transports"], 26 | client_data_json: encoder.decode(response["clientDataJSON"]), 27 | relying_party: relying_party 28 | ) 29 | end 30 | 31 | attr_reader :attestation_type, :attestation_trust_path, :transports 32 | 33 | def initialize(attestation_object:, transports: [], **options) 34 | super(**options) 35 | 36 | @attestation_object_bytes = attestation_object 37 | @transports = transports 38 | @relying_party = relying_party 39 | end 40 | 41 | def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_verification: nil, rp_id: nil) 42 | super 43 | 44 | verify_item(:attested_credential) 45 | if relying_party.verify_attestation_statement 46 | verify_item(:attestation_statement) 47 | end 48 | 49 | true 50 | end 51 | 52 | def attestation_object 53 | @attestation_object ||= WebAuthn::AttestationObject.deserialize(attestation_object_bytes, relying_party) 54 | end 55 | 56 | def_delegators( 57 | :attestation_object, 58 | :aaguid, 59 | :attestation_statement, 60 | :attestation_certificate_key_id, 61 | :authenticator_data, 62 | :credential 63 | ) 64 | 65 | alias_method :attestation_certificate_key, :attestation_certificate_key_id 66 | 67 | private 68 | 69 | attr_reader :attestation_object_bytes, :relying_party 70 | 71 | def type 72 | WebAuthn::TYPES[:create] 73 | end 74 | 75 | def valid_attested_credential? 76 | attestation_object.valid_attested_credential? && 77 | relying_party.algorithms.include?(authenticator_data.credential.algorithm) 78 | end 79 | 80 | def valid_attestation_statement? 81 | @attestation_type, @attestation_trust_path = attestation_object.valid_attestation_statement?(client_data.hash) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bindata" 4 | require "webauthn/authenticator_data/attested_credential_data" 5 | require "webauthn/error" 6 | 7 | module WebAuthn 8 | class AuthenticatorDataFormatError < WebAuthn::Error; end 9 | 10 | class AuthenticatorData < BinData::Record 11 | RP_ID_HASH_LENGTH = 32 12 | FLAGS_LENGTH = 1 13 | SIGN_COUNT_LENGTH = 4 14 | 15 | endian :big 16 | 17 | count_bytes_remaining :data_length 18 | string :rp_id_hash, length: RP_ID_HASH_LENGTH 19 | struct :flags do 20 | bit1 :extension_data_included 21 | bit1 :attested_credential_data_included 22 | bit1 :reserved_for_future_use_2 23 | bit1 :backup_state 24 | bit1 :backup_eligibility 25 | bit1 :user_verified 26 | bit1 :reserved_for_future_use_1 27 | bit1 :user_present 28 | end 29 | bit32 :sign_count 30 | count_bytes_remaining :trailing_bytes_length 31 | string :trailing_bytes, length: :trailing_bytes_length 32 | 33 | def self.deserialize(data) 34 | read(data) 35 | rescue EOFError 36 | raise AuthenticatorDataFormatError 37 | end 38 | 39 | def data 40 | to_binary_s 41 | end 42 | 43 | def valid? 44 | (!attested_credential_data_included? || attested_credential_data.valid?) && 45 | (!extension_data_included? || extension_data) && 46 | valid_length? 47 | end 48 | 49 | def user_flagged? 50 | user_present? || user_verified? 51 | end 52 | 53 | def user_present? 54 | flags.user_present == 1 55 | end 56 | 57 | def user_verified? 58 | flags.user_verified == 1 59 | end 60 | 61 | def credential_backup_eligible? 62 | flags.backup_eligibility == 1 63 | end 64 | 65 | def credential_backed_up? 66 | flags.backup_state == 1 67 | end 68 | 69 | def attested_credential_data_included? 70 | flags.attested_credential_data_included == 1 71 | end 72 | 73 | def extension_data_included? 74 | flags.extension_data_included == 1 75 | end 76 | 77 | def credential 78 | if attested_credential_data_included? 79 | attested_credential_data.credential 80 | end 81 | end 82 | 83 | def attested_credential_data 84 | @attested_credential_data ||= 85 | AttestedCredentialData.deserialize(trailing_bytes) 86 | rescue AttestedCredentialDataFormatError 87 | raise AuthenticatorDataFormatError 88 | end 89 | 90 | def extension_data 91 | @extension_data ||= CBOR.decode(raw_extension_data) 92 | end 93 | 94 | def aaguid 95 | raw_aaguid = attested_credential_data.raw_aaguid 96 | 97 | unless raw_aaguid == WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID 98 | attested_credential_data.aaguid 99 | end 100 | end 101 | 102 | private 103 | 104 | def valid_length? 105 | data_length == base_length + attested_credential_data_length + extension_data_length 106 | end 107 | 108 | def raw_extension_data 109 | if extension_data_included? 110 | if attested_credential_data_included? 111 | trailing_bytes[attested_credential_data.length..-1] 112 | else 113 | trailing_bytes.snapshot 114 | end 115 | end 116 | end 117 | 118 | def attested_credential_data_length 119 | if attested_credential_data_included? 120 | attested_credential_data.length 121 | else 122 | 0 123 | end 124 | end 125 | 126 | def extension_data_length 127 | if extension_data_included? 128 | raw_extension_data.length 129 | else 130 | 0 131 | end 132 | end 133 | 134 | def base_length 135 | RP_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_data/attested_credential_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bindata" 4 | require "cose/key" 5 | require "webauthn/error" 6 | 7 | module WebAuthn 8 | class AttestedCredentialDataFormatError < WebAuthn::Error; end 9 | 10 | class AuthenticatorData < BinData::Record 11 | class AttestedCredentialData < BinData::Record 12 | AAGUID_LENGTH = 16 13 | ZEROED_AAGUID = 0.chr * AAGUID_LENGTH 14 | 15 | ID_LENGTH_LENGTH = 2 16 | 17 | endian :big 18 | 19 | string :raw_aaguid, length: AAGUID_LENGTH 20 | bit16 :id_length 21 | string :id, read_length: :id_length 22 | count_bytes_remaining :trailing_bytes_length 23 | string :trailing_bytes, length: :trailing_bytes_length 24 | 25 | Credential = 26 | Struct.new(:id, :public_key, :algorithm, keyword_init: true) do 27 | def public_key_object 28 | COSE::Key.deserialize(public_key).to_pkey 29 | end 30 | end 31 | 32 | def self.deserialize(data) 33 | read(data) 34 | rescue EOFError 35 | raise AttestedCredentialDataFormatError 36 | end 37 | 38 | def valid? 39 | valid_credential_public_key? 40 | end 41 | 42 | def aaguid 43 | raw_aaguid.unpack("H8H4H4H4H12").join("-") 44 | end 45 | 46 | def credential 47 | @credential ||= 48 | if valid? 49 | Credential.new(id: id, public_key: public_key, algorithm: algorithm) 50 | end 51 | end 52 | 53 | def length 54 | if valid? 55 | AAGUID_LENGTH + ID_LENGTH_LENGTH + id_length + public_key_length 56 | end 57 | end 58 | 59 | private 60 | 61 | def algorithm 62 | COSE::Algorithm.find(cose_key.alg).name 63 | end 64 | 65 | def valid_credential_public_key? 66 | !!cose_key.alg 67 | end 68 | 69 | def cose_key 70 | @cose_key ||= COSE::Key.deserialize(public_key) 71 | end 72 | 73 | def public_key 74 | trailing_bytes[0..public_key_length - 1] 75 | end 76 | 77 | def public_key_length 78 | @public_key_length ||= 79 | CBOR.encode(CBOR::Unpacker.new(StringIO.new(trailing_bytes)).each.first).length 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/webauthn/authenticator_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/authenticator_data" 4 | require "webauthn/client_data" 5 | require "webauthn/error" 6 | 7 | module WebAuthn 8 | TYPES = { create: "webauthn.create", get: "webauthn.get" }.freeze 9 | 10 | class VerificationError < Error; end 11 | 12 | class AuthenticatorDataVerificationError < VerificationError; end 13 | class ChallengeVerificationError < VerificationError; end 14 | class OriginVerificationError < VerificationError; end 15 | class RpIdVerificationError < VerificationError; end 16 | class TokenBindingVerificationError < VerificationError; end 17 | class TypeVerificationError < VerificationError; end 18 | class UserPresenceVerificationError < VerificationError; end 19 | class UserVerifiedVerificationError < VerificationError; end 20 | 21 | class AuthenticatorResponse 22 | def initialize(client_data_json:, relying_party: WebAuthn.configuration.relying_party) 23 | @client_data_json = client_data_json 24 | @relying_party = relying_party 25 | end 26 | 27 | def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_verification: nil, rp_id: nil) 28 | expected_origin ||= relying_party.allowed_origins || raise("Unspecified expected origin") 29 | 30 | rp_id ||= relying_party.id 31 | 32 | verify_item(:type) 33 | verify_item(:token_binding) 34 | verify_item(:challenge, expected_challenge) 35 | verify_item(:origin, expected_origin) 36 | verify_item(:authenticator_data) 37 | 38 | verify_item( 39 | :rp_id, 40 | rp_id || rp_id_from_origin(expected_origin) 41 | ) 42 | 43 | # Fallback to RP configuration unless user_presence is passed in explicitely 44 | if user_presence.nil? && !relying_party.silent_authentication || user_presence 45 | verify_item(:user_presence) 46 | end 47 | 48 | if user_verification 49 | verify_item(:user_verified) 50 | end 51 | 52 | true 53 | end 54 | 55 | def valid?(*args, **keyword_arguments) 56 | verify(*args, **keyword_arguments) 57 | rescue WebAuthn::VerificationError 58 | false 59 | end 60 | 61 | def client_data 62 | @client_data ||= WebAuthn::ClientData.new(client_data_json) 63 | end 64 | 65 | private 66 | 67 | attr_reader :client_data_json, :relying_party 68 | 69 | def verify_item(item, *args) 70 | if send("valid_#{item}?", *args) 71 | true 72 | else 73 | camelized_item = item.to_s.split('_').map { |w| w.capitalize }.join 74 | error_const_name = "WebAuthn::#{camelized_item}VerificationError" 75 | raise Object.const_get(error_const_name) 76 | end 77 | end 78 | 79 | def valid_type? 80 | client_data.type == type 81 | end 82 | 83 | def valid_token_binding? 84 | client_data.valid_token_binding_format? 85 | end 86 | 87 | def valid_challenge?(expected_challenge) 88 | OpenSSL.secure_compare(client_data.challenge, expected_challenge) 89 | end 90 | 91 | def valid_origin?(expected_origin) 92 | return false unless expected_origin 93 | 94 | expected_origin.include?(client_data.origin) 95 | end 96 | 97 | def valid_rp_id?(rp_id) 98 | return false unless rp_id 99 | 100 | OpenSSL::Digest::SHA256.digest(rp_id) == authenticator_data.rp_id_hash 101 | end 102 | 103 | def valid_authenticator_data? 104 | authenticator_data.valid? 105 | rescue WebAuthn::AuthenticatorDataFormatError 106 | false 107 | end 108 | 109 | def valid_user_presence? 110 | authenticator_data.user_flagged? 111 | end 112 | 113 | def valid_user_verified? 114 | authenticator_data.user_verified? 115 | end 116 | 117 | def rp_id_from_origin(expected_origin) 118 | URI.parse(expected_origin.first).host if expected_origin.size == 1 119 | end 120 | 121 | def type 122 | raise NotImplementedError, "Please define #type method in subclass" 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/webauthn/client_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "openssl" 5 | require "webauthn/encoder" 6 | require "webauthn/error" 7 | 8 | module WebAuthn 9 | class ClientDataMissingError < Error; end 10 | 11 | class ClientData 12 | VALID_TOKEN_BINDING_STATUSES = ["present", "supported", "not-supported"].freeze 13 | 14 | def initialize(client_data_json) 15 | @client_data_json = client_data_json 16 | end 17 | 18 | def type 19 | data["type"] 20 | end 21 | 22 | def challenge 23 | WebAuthn.standard_encoder.decode(data["challenge"]) 24 | end 25 | 26 | def origin 27 | data["origin"] 28 | end 29 | 30 | def token_binding 31 | data["tokenBinding"] 32 | end 33 | 34 | def valid_token_binding_format? 35 | if token_binding 36 | token_binding.is_a?(Hash) && VALID_TOKEN_BINDING_STATUSES.include?(token_binding["status"]) 37 | else 38 | true 39 | end 40 | end 41 | 42 | def hash 43 | OpenSSL::Digest::SHA256.digest(client_data_json) 44 | end 45 | 46 | private 47 | 48 | attr_reader :client_data_json 49 | 50 | def data 51 | @data ||= 52 | if client_data_json 53 | JSON.parse(client_data_json) 54 | else 55 | raise ClientDataMissingError, "Client Data JSON is missing" 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/webauthn/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | require 'webauthn/relying_party' 5 | 6 | module WebAuthn 7 | def self.configuration 8 | @configuration ||= Configuration.new 9 | end 10 | 11 | def self.configure 12 | yield(configuration) 13 | end 14 | 15 | class Configuration 16 | extend Forwardable 17 | 18 | def_delegators :@relying_party, 19 | :algorithms, 20 | :algorithms=, 21 | :encoding, 22 | :encoding=, 23 | :origin, 24 | :origin=, 25 | :allowed_origins, 26 | :allowed_origins=, 27 | :verify_attestation_statement, 28 | :verify_attestation_statement=, 29 | :credential_options_timeout, 30 | :credential_options_timeout=, 31 | :silent_authentication, 32 | :silent_authentication=, 33 | :acceptable_attestation_types, 34 | :acceptable_attestation_types=, 35 | :attestation_root_certificates_finders, 36 | :attestation_root_certificates_finders=, 37 | :encoder, 38 | :encoder=, 39 | :legacy_u2f_appid, 40 | :legacy_u2f_appid= 41 | 42 | attr_reader :relying_party 43 | 44 | def initialize 45 | @relying_party = RelyingParty.new 46 | end 47 | 48 | def rp_name 49 | relying_party.name 50 | end 51 | 52 | def rp_name=(name) 53 | relying_party.name = name 54 | end 55 | 56 | def rp_id 57 | relying_party.id 58 | end 59 | 60 | def rp_id=(id) 61 | relying_party.id = id 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/webauthn/credential.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/public_key_credential/creation_options" 4 | require "webauthn/public_key_credential/request_options" 5 | require "webauthn/public_key_credential_with_assertion" 6 | require "webauthn/public_key_credential_with_attestation" 7 | 8 | module WebAuthn 9 | module Credential 10 | def self.options_for_create(**keyword_arguments) 11 | WebAuthn::PublicKeyCredential::CreationOptions.new(**keyword_arguments) 12 | end 13 | 14 | def self.options_for_get(**keyword_arguments) 15 | WebAuthn::PublicKeyCredential::RequestOptions.new(**keyword_arguments) 16 | end 17 | 18 | def self.from_create(credential, relying_party: WebAuthn.configuration.relying_party) 19 | WebAuthn::PublicKeyCredentialWithAttestation.from_client(credential, relying_party: relying_party) 20 | end 21 | 22 | def self.from_get(credential, relying_party: WebAuthn.configuration.relying_party) 23 | WebAuthn::PublicKeyCredentialWithAssertion.from_client(credential, relying_party: relying_party) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/webauthn/credential_creation_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "webauthn/credential_options" 5 | require "webauthn/credential_rp_entity" 6 | require "webauthn/credential_user_entity" 7 | 8 | module WebAuthn 9 | def self.credential_creation_options(rp_name: nil, user_name: "web-user", display_name: "web-user", user_id: "1") 10 | warn( 11 | "DEPRECATION WARNING: `WebAuthn.credential_creation_options` is deprecated."\ 12 | " Please use `WebAuthn::Credential.options_for_create` instead." 13 | ) 14 | 15 | CredentialCreationOptions.new( 16 | rp_name: rp_name, user_id: user_id, user_name: user_name, user_display_name: display_name 17 | ).to_h 18 | end 19 | 20 | class CredentialCreationOptions < CredentialOptions 21 | DEFAULT_RP_NAME = "web-server" 22 | 23 | attr_accessor :attestation, :authenticator_selection, :exclude_credentials, :extensions 24 | 25 | def initialize( 26 | attestation: nil, 27 | authenticator_selection: nil, 28 | exclude_credentials: nil, 29 | extensions: nil, 30 | user_id:, 31 | user_name:, 32 | user_display_name: nil, 33 | rp_name: nil 34 | ) 35 | super() 36 | 37 | @attestation = attestation 38 | @authenticator_selection = authenticator_selection 39 | @exclude_credentials = exclude_credentials 40 | @extensions = extensions 41 | @user_id = user_id 42 | @user_name = user_name 43 | @user_display_name = user_display_name 44 | @rp_name = rp_name 45 | end 46 | 47 | def to_h 48 | options = { 49 | challenge: challenge, 50 | pubKeyCredParams: pub_key_cred_params, 51 | timeout: timeout, 52 | user: { id: user.id, name: user.name, displayName: user.display_name }, 53 | rp: { name: rp.name } 54 | } 55 | 56 | if attestation 57 | options[:attestation] = attestation 58 | end 59 | 60 | if authenticator_selection 61 | options[:authenticatorSelection] = authenticator_selection 62 | end 63 | 64 | if exclude_credentials 65 | options[:excludeCredentials] = exclude_credentials 66 | end 67 | 68 | if extensions 69 | options[:extensions] = extensions 70 | end 71 | 72 | options 73 | end 74 | 75 | def pub_key_cred_params 76 | configuration.algorithms.map do |alg_name| 77 | { type: "public-key", alg: COSE::Algorithm.by_name(alg_name).id } 78 | end 79 | end 80 | 81 | def rp 82 | @rp ||= CredentialRPEntity.new(name: rp_name || configuration.rp_name || DEFAULT_RP_NAME) 83 | end 84 | 85 | def user 86 | @user ||= CredentialUserEntity.new(id: user_id, name: user_name, display_name: user_display_name) 87 | end 88 | 89 | private 90 | 91 | attr_reader :user_id, :user_name, :user_display_name, :rp_name 92 | 93 | def configuration 94 | WebAuthn.configuration 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/webauthn/credential_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | class CredentialEntity 5 | attr_reader :name 6 | 7 | def initialize(name:) 8 | @name = name 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/webauthn/credential_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module WebAuthn 6 | class CredentialOptions 7 | CHALLENGE_LENGTH = 32 8 | 9 | def challenge 10 | @challenge ||= SecureRandom.random_bytes(CHALLENGE_LENGTH) 11 | end 12 | 13 | def timeout 14 | @timeout = WebAuthn.configuration.credential_options_timeout 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/webauthn/credential_request_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/credential_options" 4 | 5 | module WebAuthn 6 | def self.credential_request_options 7 | warn( 8 | "DEPRECATION WARNING: `WebAuthn.credential_request_options` is deprecated."\ 9 | " Please use `WebAuthn::Credential.options_for_get` instead." 10 | ) 11 | 12 | CredentialRequestOptions.new.to_h 13 | end 14 | 15 | class CredentialRequestOptions < CredentialOptions 16 | attr_accessor :allow_credentials, :extensions, :user_verification 17 | 18 | def initialize(allow_credentials: [], extensions: nil, user_verification: nil) 19 | super() 20 | 21 | @allow_credentials = allow_credentials 22 | @extensions = extensions 23 | @user_verification = user_verification 24 | end 25 | 26 | def to_h 27 | options = { 28 | challenge: challenge, 29 | timeout: timeout, 30 | allowCredentials: allow_credentials 31 | } 32 | 33 | if extensions 34 | options[:extensions] = extensions 35 | end 36 | 37 | if user_verification 38 | options[:userVerification] = user_verification 39 | end 40 | 41 | options 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/webauthn/credential_rp_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/credential_entity" 4 | 5 | module WebAuthn 6 | class CredentialRPEntity < CredentialEntity 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/webauthn/credential_user_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/credential_entity" 4 | 5 | module WebAuthn 6 | class CredentialUserEntity < CredentialEntity 7 | attr_reader :id, :display_name 8 | 9 | def initialize(id:, display_name: nil, **keyword_arguments) 10 | super(**keyword_arguments) 11 | 12 | @id = id 13 | @display_name = display_name || name 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/webauthn/encoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/encoders" 4 | 5 | module WebAuthn 6 | class Encoder 7 | extend Forwardable 8 | 9 | # https://www.w3.org/TR/webauthn-2/#base64url-encoding 10 | STANDARD_ENCODING = :base64url 11 | 12 | def_delegators :@encoder_klass, :encode, :decode 13 | 14 | def initialize(encoding = STANDARD_ENCODING) 15 | @encoder_klass = Encoders.lookup(encoding) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/webauthn/encoders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | def self.standard_encoder 5 | @standard_encoder ||= Encoders.lookup(Encoder::STANDARD_ENCODING) 6 | end 7 | 8 | module Encoders 9 | class << self 10 | def lookup(encoding) 11 | case encoding 12 | when :base64 13 | Base64Encoder 14 | when :base64url 15 | Base64UrlEncoder 16 | when nil, false 17 | NullEncoder 18 | else 19 | raise "Unsupported or unknown encoding: #{encoding}" 20 | end 21 | end 22 | end 23 | 24 | class Base64Encoder 25 | def self.encode(data) 26 | [data].pack("m0") # Base64.strict_encode64(data) 27 | end 28 | 29 | def self.decode(data) 30 | data.unpack1("m0") # Base64.strict_decode64(data) 31 | end 32 | end 33 | 34 | class Base64UrlEncoder 35 | def self.encode(data) 36 | data = [data].pack("m0") # Base64.urlsafe_encode64(data, padding: false) 37 | data.chomp!("==") or data.chomp!("=") 38 | data.tr!("+/", "-_") 39 | data 40 | end 41 | 42 | def self.decode(data) 43 | if !data.end_with?("=") && data.length % 4 != 0 # Base64.urlsafe_decode64(data) 44 | data = data.ljust((data.length + 3) & ~3, "=") 45 | end 46 | 47 | data = data.tr("-_", "+/") 48 | data.unpack1("m0") 49 | end 50 | end 51 | 52 | class NullEncoder 53 | def self.encode(data) 54 | data 55 | end 56 | 57 | def self.decode(data) 58 | data 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/webauthn/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | class Error < StandardError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/webauthn/fake_authenticator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cbor" 4 | require "openssl" 5 | require "securerandom" 6 | require "webauthn/fake_authenticator/attestation_object" 7 | require "webauthn/fake_authenticator/authenticator_data" 8 | 9 | module WebAuthn 10 | class FakeAuthenticator 11 | def initialize 12 | @credentials = {} 13 | end 14 | 15 | def make_credential( 16 | rp_id:, 17 | client_data_hash:, 18 | user_present: true, 19 | user_verified: false, 20 | backup_eligibility: false, 21 | backup_state: false, 22 | attested_credential_data: true, 23 | algorithm: nil, 24 | sign_count: nil, 25 | extensions: nil 26 | ) 27 | credential_id, credential_key, credential_sign_count = new_credential(algorithm) 28 | sign_count ||= credential_sign_count 29 | 30 | credentials[rp_id] ||= {} 31 | credentials[rp_id][credential_id] = { 32 | credential_key: credential_key, 33 | sign_count: sign_count + 1 34 | } 35 | 36 | AttestationObject.new( 37 | client_data_hash: client_data_hash, 38 | rp_id_hash: hashed(rp_id), 39 | credential_id: credential_id, 40 | credential_key: credential_key, 41 | user_present: user_present, 42 | user_verified: user_verified, 43 | backup_eligibility: backup_eligibility, 44 | backup_state: backup_state, 45 | attested_credential_data: attested_credential_data, 46 | sign_count: sign_count, 47 | extensions: extensions 48 | ).serialize 49 | end 50 | 51 | def get_assertion( 52 | rp_id:, 53 | client_data_hash:, 54 | user_present: true, 55 | user_verified: false, 56 | backup_eligibility: false, 57 | backup_state: false, 58 | aaguid: AuthenticatorData::AAGUID, 59 | sign_count: nil, 60 | extensions: nil, 61 | allow_credentials: nil 62 | ) 63 | credential_options = credentials[rp_id] 64 | 65 | if credential_options 66 | allow_credentials ||= credential_options.keys 67 | credential_id = (credential_options.keys & allow_credentials).first 68 | unless credential_id 69 | raise "No matching credentials (allowed=#{allow_credentials}) " \ 70 | "found for RP #{rp_id} among credentials=#{credential_options}" 71 | end 72 | 73 | credential = credential_options[credential_id] 74 | credential_key = credential[:credential_key] 75 | credential_sign_count = credential[:sign_count] 76 | 77 | authenticator_data = AuthenticatorData.new( 78 | rp_id_hash: hashed(rp_id), 79 | user_present: user_present, 80 | user_verified: user_verified, 81 | backup_eligibility: backup_eligibility, 82 | backup_state: backup_state, 83 | aaguid: aaguid, 84 | credential: nil, 85 | sign_count: sign_count || credential_sign_count, 86 | extensions: extensions 87 | ).serialize 88 | 89 | signature_digest_algorithm = 90 | case credential_key 91 | when OpenSSL::PKey::RSA, OpenSSL::PKey::EC 92 | 'SHA256' 93 | when OpenSSL::PKey::PKey 94 | nil 95 | end 96 | signature = credential_key.sign(signature_digest_algorithm, authenticator_data + client_data_hash) 97 | credential[:sign_count] += 1 98 | 99 | { 100 | credential_id: credential_id, 101 | authenticator_data: authenticator_data, 102 | signature: signature 103 | } 104 | else 105 | raise "No credentials found for RP #{rp_id}" 106 | end 107 | end 108 | 109 | private 110 | 111 | attr_reader :credentials 112 | 113 | def new_credential(algorithm) 114 | algorithm ||= 'ES256' 115 | credential_key = 116 | case algorithm 117 | when 'ES256' 118 | OpenSSL::PKey::EC.generate('prime256v1') 119 | when 'RS256' 120 | OpenSSL::PKey::RSA.new(2048) 121 | when 'EdDSA' 122 | OpenSSL::PKey.generate_key("ED25519") 123 | else 124 | raise "Unsupported algorithm #{algorithm}" 125 | end 126 | 127 | [SecureRandom.random_bytes(16), credential_key, 0] 128 | end 129 | 130 | def hashed(target) 131 | OpenSSL::Digest::SHA256.digest(target) 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/webauthn/fake_authenticator/attestation_object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cbor" 4 | require "webauthn/fake_authenticator/authenticator_data" 5 | 6 | module WebAuthn 7 | class FakeAuthenticator 8 | class AttestationObject 9 | def initialize( 10 | client_data_hash:, 11 | rp_id_hash:, 12 | credential_id:, 13 | credential_key:, 14 | user_present: true, 15 | user_verified: false, 16 | backup_eligibility: false, 17 | backup_state: false, 18 | attested_credential_data: true, 19 | sign_count: 0, 20 | extensions: nil 21 | ) 22 | @client_data_hash = client_data_hash 23 | @rp_id_hash = rp_id_hash 24 | @credential_id = credential_id 25 | @credential_key = credential_key 26 | @user_present = user_present 27 | @user_verified = user_verified 28 | @backup_eligibility = backup_eligibility 29 | @backup_state = backup_state 30 | @attested_credential_data = attested_credential_data 31 | @sign_count = sign_count 32 | @extensions = extensions 33 | end 34 | 35 | def serialize 36 | CBOR.encode( 37 | "fmt" => "none", 38 | "attStmt" => {}, 39 | "authData" => authenticator_data.serialize 40 | ) 41 | end 42 | 43 | private 44 | 45 | attr_reader( 46 | :client_data_hash, 47 | :rp_id_hash, 48 | :credential_id, 49 | :credential_key, 50 | :user_present, 51 | :user_verified, 52 | :backup_eligibility, 53 | :backup_state, 54 | :attested_credential_data, 55 | :sign_count, 56 | :extensions 57 | ) 58 | 59 | def authenticator_data 60 | @authenticator_data ||= 61 | begin 62 | credential_data = 63 | if attested_credential_data 64 | { id: credential_id, public_key: credential_public_key } 65 | end 66 | 67 | AuthenticatorData.new( 68 | rp_id_hash: rp_id_hash, 69 | credential: credential_data, 70 | user_present: user_present, 71 | user_verified: user_verified, 72 | backup_eligibility: backup_eligibility, 73 | backup_state: backup_state, 74 | sign_count: 0, 75 | extensions: extensions 76 | ) 77 | end 78 | end 79 | 80 | def credential_public_key 81 | case credential_key 82 | when OpenSSL::PKey::RSA, OpenSSL::PKey::EC 83 | credential_key.public_key 84 | when OpenSSL::PKey::PKey 85 | OpenSSL::PKey.read(credential_key.public_to_der) 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/webauthn/fake_authenticator/authenticator_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/key" 4 | require "cbor" 5 | require "securerandom" 6 | 7 | module WebAuthn 8 | class FakeAuthenticator 9 | class AuthenticatorData 10 | AAGUID = SecureRandom.random_bytes(16) 11 | 12 | attr_reader :sign_count 13 | 14 | def initialize( 15 | rp_id_hash:, 16 | credential: { 17 | id: SecureRandom.random_bytes(16), 18 | public_key: OpenSSL::PKey::EC.generate("prime256v1").public_key 19 | }, 20 | sign_count: 0, 21 | user_present: true, 22 | user_verified: !user_present, 23 | backup_eligibility: false, 24 | backup_state: false, 25 | aaguid: AAGUID, 26 | extensions: { "fakeExtension" => "fakeExtensionValue" } 27 | ) 28 | @rp_id_hash = rp_id_hash 29 | @credential = credential 30 | @sign_count = sign_count 31 | @user_present = user_present 32 | @user_verified = user_verified 33 | @backup_eligibility = backup_eligibility 34 | @backup_state = backup_state 35 | @aaguid = aaguid 36 | @extensions = extensions 37 | end 38 | 39 | def serialize 40 | rp_id_hash + flags + serialized_sign_count + attested_credential_data + extension_data 41 | end 42 | 43 | private 44 | 45 | attr_reader :rp_id_hash, 46 | :credential, 47 | :user_present, 48 | :user_verified, 49 | :extensions, 50 | :backup_eligibility, 51 | :backup_state 52 | 53 | def flags 54 | [ 55 | [ 56 | bit(:user_present), 57 | reserved_for_future_use_bit, 58 | bit(:user_verified), 59 | bit(:backup_eligibility), 60 | bit(:backup_state), 61 | reserved_for_future_use_bit, 62 | attested_credential_data_included_bit, 63 | extension_data_included_bit 64 | ].join 65 | ].pack("b*") 66 | end 67 | 68 | def serialized_sign_count 69 | [sign_count].pack('L>') 70 | end 71 | 72 | def attested_credential_data 73 | @attested_credential_data ||= 74 | if credential 75 | @aaguid + 76 | [credential[:id].length].pack("n*") + 77 | credential[:id] + 78 | cose_credential_public_key 79 | else 80 | "" 81 | end 82 | end 83 | 84 | def extension_data 85 | if extensions 86 | CBOR.encode(extensions) 87 | else 88 | "" 89 | end 90 | end 91 | 92 | def bit(flag) 93 | if context[flag] 94 | "1" 95 | else 96 | "0" 97 | end 98 | end 99 | 100 | def attested_credential_data_included_bit 101 | if attested_credential_data.empty? 102 | "0" 103 | else 104 | "1" 105 | end 106 | end 107 | 108 | def extension_data_included_bit 109 | if extension_data.empty? 110 | "0" 111 | else 112 | "1" 113 | end 114 | end 115 | 116 | def reserved_for_future_use_bit 117 | "0" 118 | end 119 | 120 | def context 121 | { 122 | user_present: user_present, 123 | user_verified: user_verified, 124 | backup_eligibility: backup_eligibility, 125 | backup_state: backup_state 126 | } 127 | end 128 | 129 | def cose_credential_public_key 130 | case credential[:public_key] 131 | when OpenSSL::PKey::RSA 132 | key = COSE::Key::RSA.from_pkey(credential[:public_key]) 133 | key.alg = -257 134 | when OpenSSL::PKey::EC::Point 135 | alg = { 136 | COSE::Key::Curve.by_name("P-256").id => -7, 137 | COSE::Key::Curve.by_name("P-384").id => -35, 138 | COSE::Key::Curve.by_name("P-521").id => -36 139 | } 140 | 141 | key = COSE::Key::EC2.from_pkey(credential[:public_key]) 142 | key.alg = alg[key.crv] 143 | when OpenSSL::PKey::PKey 144 | key = COSE::Key::OKP.from_pkey(credential[:public_key]) 145 | key.alg = -8 146 | end 147 | 148 | key.serialize 149 | end 150 | 151 | def key_bytes(public_key) 152 | public_key.to_bn.to_s(2) 153 | end 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/webauthn/fake_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "securerandom" 5 | require "webauthn/authenticator_data" 6 | require "webauthn/encoder" 7 | require "webauthn/fake_authenticator" 8 | 9 | module WebAuthn 10 | class FakeClient 11 | TYPES = { create: "webauthn.create", get: "webauthn.get" }.freeze 12 | 13 | attr_reader :origin, :token_binding, :encoding 14 | 15 | def initialize( 16 | origin = fake_origin, 17 | token_binding: nil, 18 | authenticator: WebAuthn::FakeAuthenticator.new, 19 | encoding: WebAuthn.configuration.encoding 20 | ) 21 | @origin = origin 22 | @token_binding = token_binding 23 | @authenticator = authenticator 24 | @encoding = encoding 25 | end 26 | 27 | def create( 28 | challenge: fake_challenge, 29 | rp_id: nil, 30 | user_present: true, 31 | user_verified: false, 32 | backup_eligibility: false, 33 | backup_state: false, 34 | attested_credential_data: true, 35 | credential_algorithm: nil, 36 | extensions: nil 37 | ) 38 | rp_id ||= URI.parse(origin).host 39 | 40 | client_data_json = data_json_for(:create, encoder.decode(challenge)) 41 | client_data_hash = hashed(client_data_json) 42 | 43 | attestation_object = authenticator.make_credential( 44 | rp_id: rp_id, 45 | client_data_hash: client_data_hash, 46 | user_present: user_present, 47 | user_verified: user_verified, 48 | backup_eligibility: backup_eligibility, 49 | backup_state: backup_state, 50 | attested_credential_data: attested_credential_data, 51 | algorithm: credential_algorithm, 52 | extensions: extensions 53 | ) 54 | 55 | id = 56 | if attested_credential_data 57 | WebAuthn::AuthenticatorData 58 | .deserialize(CBOR.decode(attestation_object)["authData"]) 59 | .attested_credential_data 60 | .id 61 | else 62 | "id-for-pk-without-attested-credential-data" 63 | end 64 | 65 | { 66 | "type" => "public-key", 67 | "id" => internal_encoder.encode(id), 68 | "rawId" => encoder.encode(id), 69 | "authenticatorAttachment" => 'platform', 70 | "clientExtensionResults" => extensions, 71 | "response" => { 72 | "attestationObject" => encoder.encode(attestation_object), 73 | "clientDataJSON" => encoder.encode(client_data_json), 74 | "transports" => ["internal"], 75 | } 76 | } 77 | end 78 | 79 | def get(challenge: fake_challenge, 80 | rp_id: nil, 81 | user_present: true, 82 | user_verified: false, 83 | backup_eligibility: false, 84 | backup_state: true, 85 | sign_count: nil, 86 | extensions: nil, 87 | user_handle: nil, 88 | allow_credentials: nil) 89 | rp_id ||= URI.parse(origin).host 90 | 91 | client_data_json = data_json_for(:get, encoder.decode(challenge)) 92 | client_data_hash = hashed(client_data_json) 93 | 94 | if allow_credentials 95 | allow_credentials = allow_credentials.map { |credential| encoder.decode(credential) } 96 | end 97 | 98 | assertion = authenticator.get_assertion( 99 | rp_id: rp_id, 100 | client_data_hash: client_data_hash, 101 | user_present: user_present, 102 | user_verified: user_verified, 103 | backup_eligibility: backup_eligibility, 104 | backup_state: backup_state, 105 | sign_count: sign_count, 106 | extensions: extensions, 107 | allow_credentials: allow_credentials 108 | ) 109 | 110 | { 111 | "type" => "public-key", 112 | "id" => internal_encoder.encode(assertion[:credential_id]), 113 | "rawId" => encoder.encode(assertion[:credential_id]), 114 | "clientExtensionResults" => extensions, 115 | "authenticatorAttachment" => 'platform', 116 | "response" => { 117 | "clientDataJSON" => encoder.encode(client_data_json), 118 | "authenticatorData" => encoder.encode(assertion[:authenticator_data]), 119 | "signature" => encoder.encode(assertion[:signature]), 120 | "userHandle" => user_handle ? encoder.encode(user_handle) : nil 121 | } 122 | } 123 | end 124 | 125 | private 126 | 127 | attr_reader :authenticator 128 | 129 | def data_json_for(method, challenge) 130 | data = { 131 | type: type_for(method), 132 | challenge: internal_encoder.encode(challenge), 133 | origin: origin 134 | } 135 | 136 | if token_binding 137 | data[:tokenBinding] = token_binding 138 | end 139 | 140 | data.to_json 141 | end 142 | 143 | def encoder 144 | @encoder ||= WebAuthn::Encoder.new(encoding) 145 | end 146 | 147 | def internal_encoder 148 | WebAuthn.standard_encoder 149 | end 150 | 151 | def hashed(data) 152 | OpenSSL::Digest::SHA256.digest(data) 153 | end 154 | 155 | def fake_challenge 156 | encoder.encode(SecureRandom.random_bytes(32)) 157 | end 158 | 159 | def fake_origin 160 | "http://localhost#{rand(1000)}.test" 161 | end 162 | 163 | def type_for(method) 164 | TYPES[method] 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/webauthn/json_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | module JSONSerializer 5 | # Argument wildcard for Ruby on Rails controller automatic object JSON serialization 6 | def as_json(*) 7 | deep_camelize_keys(to_hash) 8 | end 9 | 10 | private 11 | 12 | def to_hash 13 | attributes.each_with_object({}) do |attribute_name, hash| 14 | value = send(attribute_name) 15 | 16 | if value.respond_to?(:as_json) 17 | value = value.as_json 18 | end 19 | 20 | if value 21 | hash[attribute_name] = value 22 | end 23 | end 24 | end 25 | 26 | def deep_camelize_keys(object) 27 | case object 28 | when Hash 29 | object.each_with_object({}) do |(key, value), result| 30 | result[camelize(key)] = deep_camelize_keys(value) 31 | end 32 | when Array 33 | object.map { |element| deep_camelize_keys(element) } 34 | else 35 | object 36 | end 37 | end 38 | 39 | def camelize(term) 40 | first_term, *rest = term.to_s.split('_') 41 | 42 | [first_term, *rest.map(&:capitalize)].join.to_sym 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/webauthn/public_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "cose/error" 5 | require "cose/key" 6 | require "cose/rsapkcs1_algorithm" 7 | require "webauthn/attestation_statement/fido_u2f/public_key" 8 | 9 | module WebAuthn 10 | class PublicKey 11 | class UnsupportedAlgorithm < Error; end 12 | 13 | def self.deserialize(public_key) 14 | cose_key = 15 | if WebAuthn::AttestationStatement::FidoU2f::PublicKey.uncompressed_point?(public_key) 16 | # Gem version v1.11.0 and lower, used to behave so that Credential#public_key 17 | # returned an EC P-256 uncompressed point. 18 | # 19 | # Because of https://github.com/cedarcode/webauthn-ruby/issues/137 this was changed 20 | # and Credential#public_key started returning the unchanged COSE_Key formatted 21 | # credentialPublicKey (as in https://www.w3.org/TR/webauthn/#credentialpublickey). 22 | # 23 | # Given that the credential public key is expected to be stored long-term by the gem 24 | # user and later be passed as the public_key argument in the 25 | # AuthenticatorAssertionResponse.verify call, we then need to support the two formats. 26 | COSE::Key::EC2.new( 27 | alg: COSE::Algorithm.by_name("ES256").id, 28 | crv: 1, 29 | x: public_key[1..32], 30 | y: public_key[33..-1] 31 | ) 32 | else 33 | COSE::Key.deserialize(public_key) 34 | end 35 | 36 | new(cose_key: cose_key) 37 | end 38 | 39 | attr_reader :cose_key 40 | 41 | def initialize(cose_key:) 42 | @cose_key = cose_key 43 | end 44 | 45 | def pkey 46 | @cose_key.to_pkey 47 | end 48 | 49 | def alg 50 | @cose_key.alg 51 | end 52 | 53 | def verify(signature, verification_data) 54 | cose_algorithm.verify(pkey, signature, verification_data) 55 | rescue COSE::Error 56 | false 57 | end 58 | 59 | private 60 | 61 | def cose_algorithm 62 | @cose_algorithm ||= COSE::Algorithm.find(alg) || raise( 63 | UnsupportedAlgorithm, 64 | "The public key algorithm #{alg} is not among the available COSE algorithms" 65 | ) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/encoder" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class InvalidChallengeError < Error; end 8 | 9 | attr_reader :type, :id, :raw_id, :client_extension_outputs, :authenticator_attachment, :response 10 | 11 | def self.from_client(credential, relying_party: WebAuthn.configuration.relying_party) 12 | new( 13 | type: credential["type"], 14 | id: credential["id"], 15 | raw_id: relying_party.encoder.decode(credential["rawId"]), 16 | client_extension_outputs: credential["clientExtensionResults"], 17 | authenticator_attachment: credential["authenticatorAttachment"], 18 | response: response_class.from_client(credential["response"], relying_party: relying_party), 19 | relying_party: relying_party 20 | ) 21 | end 22 | 23 | def initialize( 24 | type:, 25 | id:, 26 | raw_id:, 27 | response:, 28 | authenticator_attachment: nil, 29 | client_extension_outputs: {}, 30 | relying_party: WebAuthn.configuration.relying_party 31 | ) 32 | @type = type 33 | @id = id 34 | @raw_id = raw_id 35 | @client_extension_outputs = client_extension_outputs 36 | @authenticator_attachment = authenticator_attachment 37 | @response = response 38 | @relying_party = relying_party 39 | end 40 | 41 | def verify(challenge, *_args) 42 | unless valid_class?(challenge) 43 | msg = "challenge must be a String. input challenge class: #{challenge.class}" 44 | 45 | raise(InvalidChallengeError, msg) 46 | end 47 | 48 | valid_type? || raise("invalid type") 49 | valid_id? || raise("invalid id") 50 | 51 | true 52 | end 53 | 54 | def sign_count 55 | authenticator_data&.sign_count 56 | end 57 | 58 | def authenticator_extension_outputs 59 | authenticator_data.extension_data if authenticator_data&.extension_data_included? 60 | end 61 | 62 | def backup_eligible? 63 | authenticator_data&.credential_backup_eligible? 64 | end 65 | 66 | def backed_up? 67 | authenticator_data&.credential_backed_up? 68 | end 69 | 70 | private 71 | 72 | attr_reader :relying_party 73 | 74 | def valid_type? 75 | type == TYPE_PUBLIC_KEY 76 | end 77 | 78 | def valid_id? 79 | raw_id && id && raw_id == WebAuthn.standard_encoder.decode(id) 80 | end 81 | 82 | def valid_class?(challenge) 83 | challenge.is_a?(String) 84 | end 85 | 86 | def authenticator_data 87 | response&.authenticator_data 88 | end 89 | 90 | def encoder 91 | relying_party.encoder 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/creation_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cose/algorithm" 4 | require "webauthn/public_key_credential/options" 5 | require "webauthn/public_key_credential/rp_entity" 6 | require "webauthn/public_key_credential/user_entity" 7 | 8 | module WebAuthn 9 | class PublicKeyCredential 10 | class CreationOptions < Options 11 | attr_accessor( 12 | :attestation, 13 | :authenticator_selection, 14 | :exclude, 15 | :algs, 16 | :rp, 17 | :user 18 | ) 19 | 20 | def initialize( 21 | attestation: nil, 22 | authenticator_selection: nil, 23 | exclude_credentials: nil, 24 | exclude: nil, 25 | pub_key_cred_params: nil, 26 | algs: nil, 27 | rp: {}, 28 | user:, 29 | **keyword_arguments 30 | ) 31 | super(**keyword_arguments) 32 | 33 | @attestation = attestation 34 | @authenticator_selection = authenticator_selection 35 | @exclude_credentials = exclude_credentials 36 | @exclude = exclude 37 | @pub_key_cred_params = pub_key_cred_params 38 | @algs = algs 39 | 40 | @rp = 41 | if rp.is_a?(Hash) 42 | rp[:name] ||= relying_party.name 43 | rp[:id] ||= relying_party.id 44 | 45 | RPEntity.new(**rp) 46 | else 47 | rp 48 | end 49 | 50 | @user = 51 | if user.is_a?(Hash) 52 | UserEntity.new(**user) 53 | else 54 | user 55 | end 56 | end 57 | 58 | def exclude_credentials 59 | @exclude_credentials || exclude_credentials_from_exclude 60 | end 61 | 62 | def pub_key_cred_params 63 | @pub_key_cred_params || pub_key_cred_params_from_algs 64 | end 65 | 66 | private 67 | 68 | def attributes 69 | super.concat([:rp, :user, :pub_key_cred_params, :attestation, :authenticator_selection, :exclude_credentials]) 70 | end 71 | 72 | def exclude_credentials_from_exclude 73 | if exclude 74 | as_public_key_descriptors(exclude) 75 | end 76 | end 77 | 78 | def pub_key_cred_params_from_algs 79 | Array(algs || relying_party.algorithms).map do |alg| 80 | alg_id = 81 | if alg.is_a?(String) || alg.is_a?(Symbol) 82 | COSE::Algorithm.by_name(alg.to_s).id 83 | else 84 | alg 85 | end 86 | 87 | { type: TYPE_PUBLIC_KEY, alg: alg_id } 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | class PublicKeyCredential 5 | class Entity 6 | include JSONSerializer 7 | 8 | attr_reader :name 9 | 10 | def initialize(name:) 11 | @name = name 12 | end 13 | 14 | private 15 | 16 | def attributes 17 | [:name] 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class Options 8 | include JSONSerializer 9 | 10 | CHALLENGE_LENGTH = 32 11 | 12 | attr_reader :timeout, :extensions, :relying_party 13 | 14 | def initialize(timeout: nil, extensions: nil, relying_party: WebAuthn.configuration.relying_party) 15 | @relying_party = relying_party 16 | @timeout = timeout || default_timeout 17 | @extensions = default_extensions.merge(extensions || {}) 18 | end 19 | 20 | def challenge 21 | encoder.encode(raw_challenge) 22 | end 23 | 24 | private 25 | 26 | def attributes 27 | [:challenge, :timeout, :extensions] 28 | end 29 | 30 | def encoder 31 | relying_party.encoder 32 | end 33 | 34 | def raw_challenge 35 | @raw_challenge ||= SecureRandom.random_bytes(CHALLENGE_LENGTH) 36 | end 37 | 38 | def default_timeout 39 | relying_party.credential_options_timeout 40 | end 41 | 42 | def default_extensions 43 | {} 44 | end 45 | 46 | def as_public_key_descriptors(ids) 47 | Array(ids).map { |id| { type: TYPE_PUBLIC_KEY, id: id } } 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/request_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/public_key_credential/options" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class RequestOptions < Options 8 | attr_accessor :rp_id, :allow, :user_verification 9 | 10 | def initialize(rp_id: nil, allow_credentials: nil, allow: nil, user_verification: nil, **keyword_arguments) 11 | super(**keyword_arguments) 12 | 13 | @rp_id = rp_id || relying_party.id 14 | @allow_credentials = allow_credentials 15 | @allow = allow 16 | @user_verification = user_verification 17 | end 18 | 19 | def allow_credentials 20 | @allow_credentials || allow_credentials_from_allow || [] 21 | end 22 | 23 | private 24 | 25 | def attributes 26 | super.concat([:allow_credentials, :rp_id, :user_verification]) 27 | end 28 | 29 | def default_extensions 30 | extensions = super || {} 31 | 32 | if relying_party.legacy_u2f_appid 33 | extensions.merge!(appid: relying_party.legacy_u2f_appid) 34 | end 35 | 36 | extensions 37 | end 38 | 39 | def allow_credentials_from_allow 40 | if allow 41 | as_public_key_descriptors(allow) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/rp_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/public_key_credential/entity" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class RPEntity < Entity 8 | attr_reader :id 9 | 10 | def initialize(id: nil, **keyword_arguments) 11 | super(**keyword_arguments) 12 | 13 | @id = id 14 | end 15 | 16 | private 17 | 18 | def attributes 19 | super.concat([:id]) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential/user_entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/public_key_credential/entity" 4 | 5 | module WebAuthn 6 | class PublicKeyCredential 7 | class UserEntity < Entity 8 | attr_reader :id, :display_name 9 | 10 | def initialize(id:, display_name: nil, **keyword_arguments) 11 | super(**keyword_arguments) 12 | 13 | @id = id 14 | @display_name = display_name || name 15 | end 16 | 17 | private 18 | 19 | def attributes 20 | super.concat([:id, :display_name]) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential_with_assertion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/authenticator_assertion_response" 4 | require "webauthn/public_key_credential" 5 | 6 | module WebAuthn 7 | class PublicKeyCredentialWithAssertion < PublicKeyCredential 8 | def self.response_class 9 | WebAuthn::AuthenticatorAssertionResponse 10 | end 11 | 12 | def verify(challenge, public_key:, sign_count:, user_presence: nil, user_verification: nil) 13 | super 14 | 15 | response.verify( 16 | encoder.decode(challenge), 17 | public_key: encoder.decode(public_key), 18 | sign_count: sign_count, 19 | user_presence: user_presence, 20 | user_verification: user_verification, 21 | rp_id: appid_extension_output ? appid : nil 22 | ) 23 | 24 | true 25 | end 26 | 27 | def user_handle 28 | if raw_user_handle 29 | encoder.encode(raw_user_handle) 30 | end 31 | end 32 | 33 | def raw_user_handle 34 | response.user_handle 35 | end 36 | 37 | private 38 | 39 | def appid_extension_output 40 | return if client_extension_outputs.nil? 41 | 42 | client_extension_outputs['appid'] 43 | end 44 | 45 | def appid 46 | URI.parse(relying_party.legacy_u2f_appid || raise("Unspecified legacy U2F AppID")).to_s 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/webauthn/public_key_credential_with_attestation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webauthn/authenticator_attestation_response" 4 | require "webauthn/public_key_credential" 5 | 6 | module WebAuthn 7 | class PublicKeyCredentialWithAttestation < PublicKeyCredential 8 | def self.response_class 9 | WebAuthn::AuthenticatorAttestationResponse 10 | end 11 | 12 | def verify(challenge, user_presence: nil, user_verification: nil) 13 | super 14 | 15 | response.verify(encoder.decode(challenge), user_presence: user_presence, user_verification: user_verification) 16 | 17 | true 18 | end 19 | 20 | def public_key 21 | if raw_public_key 22 | encoder.encode(raw_public_key) 23 | end 24 | end 25 | 26 | def raw_public_key 27 | response&.authenticator_data&.credential&.public_key 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/webauthn/relying_party.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "openssl" 4 | require "webauthn/credential" 5 | require "webauthn/encoder" 6 | require "webauthn/error" 7 | 8 | module WebAuthn 9 | class RootCertificateFinderNotSupportedError < Error; end 10 | 11 | class RelyingParty 12 | DEFAULT_ALGORITHMS = ["ES256", "PS256", "RS256"].compact.freeze 13 | 14 | def self.if_pss_supported(algorithm) 15 | OpenSSL::PKey::RSA.instance_methods.include?(:verify_pss) ? algorithm : nil 16 | end 17 | 18 | def initialize( 19 | algorithms: DEFAULT_ALGORITHMS.dup, 20 | encoding: WebAuthn::Encoder::STANDARD_ENCODING, 21 | allowed_origins: nil, 22 | origin: nil, 23 | id: nil, 24 | name: nil, 25 | verify_attestation_statement: true, 26 | credential_options_timeout: 120000, 27 | silent_authentication: false, 28 | acceptable_attestation_types: ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA'], 29 | attestation_root_certificates_finders: [], 30 | legacy_u2f_appid: nil 31 | ) 32 | @algorithms = algorithms 33 | @encoding = encoding 34 | @allowed_origins = allowed_origins 35 | @id = id 36 | @name = name 37 | @verify_attestation_statement = verify_attestation_statement 38 | @credential_options_timeout = credential_options_timeout 39 | @silent_authentication = silent_authentication 40 | @acceptable_attestation_types = acceptable_attestation_types 41 | @legacy_u2f_appid = legacy_u2f_appid 42 | self.origin = origin 43 | self.attestation_root_certificates_finders = attestation_root_certificates_finders 44 | end 45 | 46 | attr_accessor :algorithms, 47 | :encoding, 48 | :allowed_origins, 49 | :id, 50 | :name, 51 | :verify_attestation_statement, 52 | :credential_options_timeout, 53 | :silent_authentication, 54 | :acceptable_attestation_types, 55 | :legacy_u2f_appid 56 | 57 | attr_reader :attestation_root_certificates_finders, :origin 58 | 59 | # This is the user-data encoder. 60 | # Used to decode user input and to encode data provided to the user. 61 | def encoder 62 | @encoder ||= WebAuthn::Encoder.new(encoding) 63 | end 64 | 65 | def attestation_root_certificates_finders=(finders) 66 | if !finders.respond_to?(:each) 67 | finders = [finders] 68 | end 69 | 70 | finders.each do |finder| 71 | unless finder.respond_to?(:find) 72 | raise RootCertificateFinderNotSupportedError, "Finder must implement `find` method" 73 | end 74 | end 75 | 76 | @attestation_root_certificates_finders = finders 77 | end 78 | 79 | def options_for_registration(**keyword_arguments) 80 | WebAuthn::Credential.options_for_create( 81 | **keyword_arguments, 82 | relying_party: self 83 | ) 84 | end 85 | 86 | def verify_registration(raw_credential, challenge, user_presence: nil, user_verification: nil) 87 | webauthn_credential = WebAuthn::Credential.from_create(raw_credential, relying_party: self) 88 | 89 | if webauthn_credential.verify(challenge, user_presence: user_presence, user_verification: user_verification) 90 | webauthn_credential 91 | end 92 | end 93 | 94 | def options_for_authentication(**keyword_arguments) 95 | WebAuthn::Credential.options_for_get( 96 | **keyword_arguments, 97 | relying_party: self 98 | ) 99 | end 100 | 101 | def verify_authentication( 102 | raw_credential, 103 | challenge, 104 | user_presence: nil, 105 | user_verification: nil, 106 | public_key: nil, 107 | sign_count: nil 108 | ) 109 | webauthn_credential = WebAuthn::Credential.from_get(raw_credential, relying_party: self) 110 | 111 | stored_credential = yield(webauthn_credential) if block_given? 112 | 113 | if webauthn_credential.verify( 114 | challenge, 115 | public_key: public_key || stored_credential.public_key, 116 | sign_count: sign_count || stored_credential.sign_count, 117 | user_presence: user_presence, 118 | user_verification: user_verification 119 | ) 120 | block_given? ? [webauthn_credential, stored_credential] : webauthn_credential 121 | end 122 | end 123 | 124 | # DEPRECATED: This method will be removed in future. 125 | def origin=(new_origin) 126 | return if new_origin.nil? 127 | 128 | warn( 129 | "DEPRECATION WARNING: `WebAuthn.origin` is deprecated and will be removed in future. "\ 130 | "Please use `WebAuthn.allowed_origins` instead "\ 131 | "that also allows configuring multiple origins per Relying Party" 132 | ) 133 | 134 | @allowed_origins ||= Array(new_origin) # rubocop:disable Naming/MemoizedInstanceVariableName 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/webauthn/u2f_migrator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'webauthn/fake_client' 4 | require 'webauthn/attestation_statement/fido_u2f' 5 | 6 | module WebAuthn 7 | class U2fMigrator 8 | def initialize(app_id:, certificate:, key_handle:, public_key:, counter:) 9 | @app_id = app_id 10 | @certificate = certificate 11 | @key_handle = key_handle 12 | @public_key = public_key 13 | @counter = counter 14 | end 15 | 16 | def authenticator_data 17 | @authenticator_data ||= WebAuthn::FakeAuthenticator::AuthenticatorData.new( 18 | rp_id_hash: OpenSSL::Digest::SHA256.digest(@app_id.to_s), 19 | credential: { 20 | id: credential_id, 21 | public_key: credential_cose_key 22 | }, 23 | sign_count: @counter, 24 | user_present: true, 25 | user_verified: false, 26 | aaguid: WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID 27 | ) 28 | end 29 | 30 | def credential 31 | @credential ||= 32 | begin 33 | hash = authenticator_data.send(:credential) 34 | WebAuthn::AuthenticatorData::AttestedCredentialData::Credential.new( 35 | id: hash[:id], 36 | public_key: hash[:public_key].serialize 37 | ) 38 | end 39 | end 40 | 41 | def attestation_type 42 | WebAuthn::AttestationStatement::ATTESTATION_TYPE_BASIC_OR_ATTCA 43 | end 44 | 45 | def attestation_trust_path 46 | @attestation_trust_path ||= [ 47 | OpenSSL::X509::Certificate.new(WebAuthn::Encoders::Base64Encoder.decode(@certificate)) 48 | ] 49 | end 50 | 51 | private 52 | 53 | # https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-client-to-authenticator-protocol-v2.0-rd-20180702.html#u2f-authenticatorMakeCredential-interoperability 54 | # Let credentialId be a credentialIdLength byte array initialized with CTAP1/U2F response key handle bytes. 55 | def credential_id 56 | WebAuthn::Encoders::Base64UrlEncoder.decode(@key_handle) 57 | end 58 | 59 | # Let x9encodedUserPublicKey be the user public key returned in the U2F registration response message [U2FRawMsgs]. 60 | # Let coseEncodedCredentialPublicKey be the result of converting x9encodedUserPublicKey’s value from ANS X9.62 / 61 | # Sec-1 v2 uncompressed curve point representation [SEC1V2] to COSE_Key representation ([RFC8152] Section 7). 62 | def credential_cose_key 63 | decoded_public_key = WebAuthn::Encoders::Base64Encoder.decode(@public_key) 64 | if WebAuthn::AttestationStatement::FidoU2f::PublicKey.uncompressed_point?(decoded_public_key) 65 | COSE::Key::EC2.new( 66 | alg: COSE::Algorithm.by_name("ES256").id, 67 | crv: 1, 68 | x: decoded_public_key[1..32], 69 | y: decoded_public_key[33..-1] 70 | ) 71 | else 72 | raise "expected U2F public key to be in uncompressed point format" 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/webauthn/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebAuthn 4 | VERSION = "3.4.1" 5 | end 6 | -------------------------------------------------------------------------------- /spec/conformance/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.2 2 | -------------------------------------------------------------------------------- /spec/conformance/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | ruby "~> 3.4.2" 6 | 7 | gem "byebug" 8 | gem "fido_metadata", github: 'bdewater/fido_metadata' 9 | gem "puma", "~> 6.6" 10 | gem "rack-contrib" 11 | gem "rackup", "~> 2.2" 12 | gem "rubyzip" 13 | gem "sinatra", "~> 4.0" 14 | gem "sinatra-contrib" 15 | gem "webauthn", path: File.join("..", "..") 16 | gem "webrick", "~> 1.9" 17 | -------------------------------------------------------------------------------- /spec/conformance/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/bdewater/fido_metadata.git 3 | revision: fcc1fc1a92f9b0eda5900485d773336494b2c1c6 4 | specs: 5 | fido_metadata (0.3.0) 6 | jwt (~> 2.0) 7 | 8 | PATH 9 | remote: ../.. 10 | specs: 11 | webauthn (3.4.0) 12 | android_key_attestation (~> 0.3.0) 13 | bindata (~> 2.4) 14 | cbor (~> 0.5.9) 15 | cose (~> 1.1) 16 | openssl (>= 2.2) 17 | safety_net_attestation (~> 0.4.0) 18 | tpm-key_attestation (~> 0.14.0) 19 | 20 | GEM 21 | remote: https://rubygems.org/ 22 | specs: 23 | android_key_attestation (0.3.0) 24 | base64 (0.2.0) 25 | bindata (2.5.0) 26 | byebug (11.1.3) 27 | cbor (0.5.9.8) 28 | cose (1.3.1) 29 | cbor (~> 0.5.9) 30 | openssl-signature_algorithm (~> 1.0) 31 | jwt (2.2.1) 32 | logger (1.6.6) 33 | multi_json (1.14.1) 34 | mustermann (3.0.3) 35 | ruby2_keywords (~> 0.0.1) 36 | nio4r (2.7.4) 37 | openssl (3.3.0) 38 | openssl-signature_algorithm (1.3.0) 39 | openssl (> 2.0) 40 | puma (6.6.0) 41 | nio4r (~> 2.0) 42 | rack (3.1.10) 43 | rack-contrib (2.5.0) 44 | rack (< 4) 45 | rack-protection (4.1.1) 46 | base64 (>= 0.1.0) 47 | logger (>= 1.6.0) 48 | rack (>= 3.0.0, < 4) 49 | rack-session (2.1.0) 50 | base64 (>= 0.1.0) 51 | rack (>= 3.0.0) 52 | rackup (2.2.1) 53 | rack (>= 3) 54 | ruby2_keywords (0.0.1) 55 | rubyzip (2.0.0) 56 | safety_net_attestation (0.4.0) 57 | jwt (~> 2.0) 58 | sinatra (4.1.1) 59 | logger (>= 1.6.0) 60 | mustermann (~> 3.0) 61 | rack (>= 3.0.0, < 4) 62 | rack-protection (= 4.1.1) 63 | rack-session (>= 2.0.0, < 3) 64 | tilt (~> 2.0) 65 | sinatra-contrib (4.1.1) 66 | multi_json (>= 0.0.2) 67 | mustermann (~> 3.0) 68 | rack-protection (= 4.1.1) 69 | sinatra (= 4.1.1) 70 | tilt (~> 2.0) 71 | tilt (2.0.10) 72 | tpm-key_attestation (0.14.0) 73 | bindata (~> 2.4) 74 | openssl (> 2.0) 75 | openssl-signature_algorithm (~> 1.0) 76 | webrick (1.9.1) 77 | 78 | PLATFORMS 79 | ruby 80 | 81 | DEPENDENCIES 82 | byebug 83 | fido_metadata! 84 | puma (~> 6.6) 85 | rack-contrib 86 | rackup (~> 2.2) 87 | rubyzip 88 | sinatra (~> 4.0) 89 | sinatra-contrib 90 | webauthn! 91 | webrick (~> 1.9) 92 | 93 | RUBY VERSION 94 | ruby 3.4.2p28 95 | 96 | BUNDLED WITH 97 | 2.6.5 98 | -------------------------------------------------------------------------------- /spec/conformance/MDSROOT.crt: -------------------------------------------------------------------------------- 1 | !!!!!DO NOT DYNAMICALLY FETCH THIS CERTIFICATE!!!!! 2 | !!!!!ADD THIS CERTIFICATE DIRECTLY TO YOUR CERTIFICATE STORAGE OR SOURCE CODE!!!!! 3 | 4 | FIDO Alliance Certification TEST Metadata Service Root Certificate 5 | Expected page status: Valid 6 | CN=FAKE Root FAKE 7 | OU=FAKE Metadata 3 BLOB Signing FAKE 8 | O=FIDO Alliance 9 | C=US 10 | Serial number=04 5A 1C 22 66 A1 4F 3F 1F 4D 29 55 12 23 15 11 | Valid from=01 February 2017 12 | Valid to=31 January 2045 13 | 14 | Base64 15 | -----BEGIN CERTIFICATE----- 16 | MIICaDCCAe6gAwIBAgIPBCqih0DiJLW7+UHXx/o1MAoGCCqGSM49BAMDMGcxCzAJ 17 | BgNVBAYTAlVTMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMScwJQYDVQQLDB5GQUtF 18 | IE1ldGFkYXRhIDMgQkxPQiBST09UIEZBS0UxFzAVBgNVBAMMDkZBS0UgUm9vdCBG 19 | QUtFMB4XDTE3MDIwMTAwMDAwMFoXDTQ1MDEzMTIzNTk1OVowZzELMAkGA1UEBhMC 20 | VVMxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxJzAlBgNVBAsMHkZBS0UgTWV0YWRh 21 | dGEgMyBCTE9CIFJPT1QgRkFLRTEXMBUGA1UEAwwORkFLRSBSb290IEZBS0UwdjAQ 22 | BgcqhkjOPQIBBgUrgQQAIgNiAASKYiz3YltC6+lmxhPKwA1WFZlIqnX8yL5RybSL 23 | TKFAPEQeTD9O6mOz+tg8wcSdnVxHzwnXiQKJwhrav70rKc2ierQi/4QUrdsPes8T 24 | EirZOkCVJurpDFbXZOgs++pa4XmjYDBeMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8E 25 | BTADAQH/MB0GA1UdDgQWBBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAfBgNVHSMEGDAW 26 | gBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAKBggqhkjOPQQDAwNoADBlAjEA/xFsgri0 27 | xubSa3y3v5ormpPqCwfqn9s0MLBAtzCIgxQ/zkzPKctkiwoPtDzI51KnAjAmeMyg 28 | X2S5Ht8+e+EQnezLJBJXtnkRWY+Zt491wgt/AwSs5PHHMv5QgjELOuMxQBc= 29 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /spec/conformance/README.md: -------------------------------------------------------------------------------- 1 | # FIDO2 conformance test server 2 | 3 | This is a [REST API](https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-server-v2.0-rd-20180702.html#transport-binding-profile) 4 | implementation for use with the FIDO2 Conformance Test Tool, which can be obtained after registration at 5 | https://fidoalliance.org/certification/functional-certification/conformance/ 6 | 7 | The code contained herein is _**not**_ representative of a production implementation of a WebAuthn relying party. 8 | 9 | ## Usage 10 | 11 | Install dependencies using Bundler: 12 | ``` 13 | cd spec/conformance 14 | bundle install 15 | ``` 16 | 17 | Start the server: 18 | ``` 19 | bundle exec ruby server.rb 20 | ``` 21 | 22 | Configure the FIDO2 Test Tool to use the following server URL: `http://localhost:4567` and run any of the server tests. 23 | 24 | For running the Metadata Service Tests, click "Download server metadata" and store the file in the same directory as 25 | `server.rb` before starting the server. 26 | -------------------------------------------------------------------------------- /spec/conformance/conformance_cache_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fido_metadata" 4 | require "fido_metadata/test_cache_store" 5 | require "zip" 6 | 7 | class ConformanceCacheStore < FidoMetadata::TestCacheStore 8 | FILENAME = "metadata.zip" 9 | 10 | def setup_authenticators 11 | puts("#{FILENAME} not found, this will affect Metadata Service Test results.") unless File.exist?(FILENAME) 12 | 13 | Zip::File.open(FILENAME).glob("metadataStatements/*.json") do |file| 14 | json = JSON.parse(file.get_input_stream.read) 15 | statement = FidoMetadata::Statement.from_json(json) 16 | identifier = statement.aaguid || statement.attestation_certificate_key_identifiers.first 17 | write("statement_#{identifier}", statement) 18 | end 19 | end 20 | 21 | def setup_metadata_store(endpoint) 22 | puts("Setting up metadata store TOC") 23 | 24 | response = Net::HTTP.post( 25 | URI("https://mds3.fido.tools/getEndpoints"), 26 | { endpoint: endpoint }.to_json, 27 | FidoMetadata::Client::DEFAULT_HEADERS 28 | ) 29 | 30 | response.value 31 | possible_endpoints = JSON.parse(response.body)["result"] 32 | 33 | client = FidoMetadata::Client.new 34 | 35 | json = 36 | possible_endpoints.each_with_index do |uri, index| 37 | puts("Trying endpoint #{index}: #{uri}") 38 | break client.download_toc(URI(uri), algorithms: ["ES256"], trusted_certs: conformance_certificates) 39 | rescue FidoMetadata::Client::DataIntegrityError, JWT::VerificationError, Net::HTTPFatalError 40 | nil 41 | end 42 | 43 | if json.is_a?(Hash) && json.keys == ["legalHeader", "no", "nextUpdate", "entries"] 44 | puts("TOC setup done!") 45 | toc = FidoMetadata::TableOfContents.from_json(json) 46 | write("metadata_toc", toc) 47 | else 48 | puts("Unable to setup TOC!") 49 | end 50 | end 51 | 52 | private 53 | 54 | def conformance_certificates 55 | file = File.read(File.join(__dir__, "MDSROOT.crt")) 56 | 57 | [OpenSSL::X509::Certificate.new(file)] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/conformance/conformance_patches.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tpm/key_attestation" 4 | 5 | ::TPM.send(:remove_const, "VENDOR_IDS") 6 | ::TPM::VENDOR_IDS = { "id:FFFFF1D0" => "FIDO Alliance" }.freeze 7 | -------------------------------------------------------------------------------- /spec/conformance/mds_finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fido_metadata' 4 | 5 | class MDSFinder 6 | extend Forwardable 7 | 8 | def_delegator :fido_metadata_configuration, :cache_backend, :cache_backend 9 | def_delegator :fido_metadata_configuration, :cache_backend=, :cache_backend= 10 | def_delegator :fido_metadata_configuration, :metadata_token, :token 11 | def_delegator :fido_metadata_configuration, :metadata_token=, :token= 12 | 13 | def find(aaguid: nil, attestation_certificate_key_id: nil, **_args) 14 | metadata_statement = 15 | if aaguid 16 | fido_metadata_store.fetch_statement(aaguid: aaguid) 17 | else 18 | fido_metadata_store.fetch_statement(attestation_certificate_key_id: attestation_certificate_key_id) 19 | end 20 | 21 | metadata_statement&.attestation_root_certificates || [] 22 | end 23 | 24 | private 25 | 26 | def fido_metadata_store 27 | @fido_metadata_store ||= FidoMetadata::Store.new 28 | end 29 | 30 | def fido_metadata_configuration 31 | FidoMetadata.configuration 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/conformance/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "webauthn" 5 | require "sinatra" 6 | require "rack/contrib" 7 | require "sinatra/cookies" 8 | require "byebug" 9 | 10 | use Rack::PostBodyContentTypeParser 11 | set show_exceptions: false 12 | 13 | require_relative 'mds_finder' 14 | require_relative 'conformance_cache_store' 15 | require_relative "conformance_patches" 16 | 17 | RP_NAME = "webauthn-ruby #{WebAuthn::VERSION} conformance test server" 18 | 19 | UNACCEPTABLE_STATUSES = [ 20 | "USER_VERIFICATION_BYPASS", 21 | "ATTESTATION_KEY_COMPROMISE", 22 | "USER_KEY_REMOTE_COMPROMISE", 23 | "USER_KEY_PHYSICAL_COMPROMISE", 24 | "REVOKED" 25 | ].freeze 26 | 27 | Credential = 28 | Struct.new(:id, :public_key, :sign_count) do 29 | @credentials = {} 30 | 31 | def self.register(username, id:, public_key:, sign_count:) 32 | @credentials[username] ||= [] 33 | @credentials[username] << Credential.new(id, public_key, sign_count) 34 | end 35 | 36 | def self.registered_for(username) 37 | @credentials[username] || [] 38 | end 39 | end 40 | 41 | host = ENV["HOST"] || "localhost" 42 | 43 | mds_finder = 44 | MDSFinder.new.tap do |mds| 45 | mds.cache_backend = ConformanceCacheStore.new 46 | mds.cache_backend.setup_authenticators 47 | mds.cache_backend.setup_metadata_store("http://#{host}:#{settings.port}") 48 | end 49 | 50 | relying_party = WebAuthn::RelyingParty.new( 51 | origin: "http://#{host}:#{settings.port}", 52 | name: RP_NAME, 53 | algorithms: %w(ES256 ES384 ES512 PS256 PS384 PS512 RS256 RS384 RS512 RS1 EdDSA), 54 | silent_authentication: true, 55 | attestation_root_certificates_finders: mds_finder 56 | ) 57 | 58 | post "/attestation/options" do 59 | options = relying_party.options_for_registration( 60 | attestation: params["attestation"], 61 | authenticator_selection: params["authenticatorSelection"], 62 | extensions: params["extensions"], 63 | exclude: Credential.registered_for(params["username"]).map(&:id), 64 | user: { id: "1", name: params["username"], display_name: params["displayName"] } 65 | ) 66 | 67 | cookies["attestation_username"] = params["username"] 68 | cookies["attestation_challenge"] = options.challenge 69 | 70 | if params["authenticatorSelection"] && params["authenticatorSelection"]["userVerification"] 71 | cookies["attestation_user_verification"] = params["authenticatorSelection"]["userVerification"] 72 | end 73 | 74 | render_ok(options.as_json) 75 | end 76 | 77 | post "/attestation/result" do 78 | webauthn_credential = relying_party.verify_registration( 79 | params, 80 | cookies["attestation_challenge"], 81 | user_verification: cookies["attestation_user_verification"] == "required" 82 | ) 83 | 84 | if (aaguid = webauthn_credential.response.aaguid) 85 | metadata_entry = fido_metadata_store.fetch_entry(aaguid: aaguid) 86 | 87 | if metadata_entry 88 | if metadata_entry.status_reports.any? { |status_report| UNACCEPTABLE_STATUSES.include?(status_report.status) } 89 | raise("bad authenticator status") 90 | end 91 | end 92 | end 93 | 94 | Credential.register( 95 | cookies["attestation_username"], 96 | id: webauthn_credential.id, 97 | public_key: webauthn_credential.public_key, 98 | sign_count: webauthn_credential.sign_count, 99 | ) 100 | 101 | cookies["attestation_challenge"] = nil 102 | cookies["attestation_username"] = nil 103 | cookies["attestation_user_verification"] = nil 104 | 105 | render_ok 106 | end 107 | 108 | post "/assertion/options" do 109 | options = relying_party.options_for_authentication( 110 | extensions: params["extensions"], 111 | user_verification: params["userVerification"], 112 | allow: Credential.registered_for(params["username"]).map(&:id) 113 | ) 114 | 115 | cookies["assertion_username"] = params["username"] 116 | cookies["assertion_user_verification"] = params["userVerification"] 117 | cookies["assertion_challenge"] = options.challenge 118 | 119 | render_ok(options.as_json) 120 | end 121 | 122 | post "/assertion/result" do 123 | webauthn_credential = WebAuthn::Credential.from_get(params) 124 | 125 | user_credential = 126 | Credential.registered_for(cookies["assertion_username"]).detect do |uc| 127 | uc.id == webauthn_credential.id 128 | end 129 | 130 | webauthn_credential = relying_party.verify_authentication( 131 | params, 132 | cookies["assertion_challenge"], 133 | public_key: user_credential.public_key, 134 | sign_count: user_credential.sign_count, 135 | user_verification: cookies["assertion_user_verification"] == "required" 136 | ) 137 | 138 | user_credential.sign_count = webauthn_credential.sign_count 139 | cookies["assertion_challenge"] = nil 140 | cookies["assertion_username"] = nil 141 | cookies["assertion_user_verification"] = nil 142 | 143 | render_ok 144 | end 145 | 146 | error 500 do 147 | error = env["sinatra.error"] 148 | render_error(<<~MSG) 149 | #{error.class}: #{error.message} 150 | #{error.backtrace.take(10).join("\n")} 151 | MSG 152 | end 153 | 154 | private 155 | 156 | def render_ok(params = {}) 157 | JSON.dump({ status: "ok", errorMessage: "" }.merge!(params)) 158 | end 159 | 160 | def render_error(message) 161 | JSON.dump(status: "error", errorMessage: message) 162 | end 163 | 164 | def fido_metadata_store 165 | @fido_metadata_store ||= FidoMetadata::Store.new 166 | end 167 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "webauthn" 5 | require "cbor" 6 | 7 | require "byebug" 8 | require "webauthn/fake_client" 9 | 10 | RSpec.configure do |config| 11 | # Enable flags like --only-failures and --next-failure 12 | config.example_status_persistence_file_path = ".rspec_status" 13 | 14 | # Disable RSpec exposing methods globally on `Module` and `main` 15 | config.disable_monkey_patching! 16 | 17 | config.expect_with :rspec do |c| 18 | c.syntax = :expect 19 | end 20 | 21 | config.after do 22 | WebAuthn.instance_variable_set(:@configuration, nil) 23 | end 24 | end 25 | 26 | def create_credential( 27 | client: WebAuthn::FakeClient.new, 28 | rp_id: nil, 29 | relying_party: WebAuthn.configuration.relying_party 30 | ) 31 | rp_id ||= relying_party.id || URI.parse(client.origin).host 32 | 33 | create_result = client.create(rp_id: rp_id) 34 | 35 | attestation_object = 36 | if client.encoding 37 | relying_party.encoder.decode(create_result["response"]["attestationObject"]) 38 | else 39 | create_result["response"]["attestationObject"] 40 | end 41 | 42 | client_data_json = 43 | if client.encoding 44 | relying_party.encoder.decode(create_result["response"]["clientDataJSON"]) 45 | else 46 | create_result["response"]["clientDataJSON"] 47 | end 48 | 49 | response = 50 | WebAuthn::AuthenticatorAttestationResponse 51 | .new( 52 | attestation_object: attestation_object, 53 | client_data_json: client_data_json, 54 | relying_party: relying_party 55 | ) 56 | 57 | credential_public_key = response.credential.public_key 58 | 59 | [create_result["id"], credential_public_key, response.authenticator_data.sign_count] 60 | end 61 | 62 | def fake_origin 63 | "http://localhost" 64 | end 65 | 66 | def fake_challenge 67 | SecureRandom.random_bytes(32) 68 | end 69 | 70 | def fake_cose_credential_key(algorithm: -7, x_coordinate: nil, y_coordinate: nil) 71 | crv_p256 = 1 72 | 73 | COSE::Key::EC2.new( 74 | alg: algorithm, 75 | crv: crv_p256, 76 | x: x_coordinate || SecureRandom.random_bytes(32), 77 | y: y_coordinate || SecureRandom.random_bytes(32) 78 | ).serialize 79 | end 80 | 81 | def key_bytes(public_key) 82 | public_key.to_bn.to_s(2) 83 | end 84 | 85 | # Borrowed from activesupport 86 | def silence_warnings 87 | old_verbose, $VERBOSE = $VERBOSE, nil 88 | yield 89 | ensure 90 | $VERBOSE = old_verbose 91 | end 92 | 93 | class RootCertificateFinder 94 | def initialize(certificate, return_empty) 95 | @certificate = certificate 96 | @return_empty = return_empty 97 | end 98 | 99 | def find(*) 100 | if @return_empty 101 | [] 102 | elsif @certificate.is_a?(OpenSSL::X509::Certificate) 103 | [@certificate] 104 | else 105 | certificate_path = File.expand_path( 106 | File.join(__dir__, 'support', 'roots', @certificate) 107 | ) 108 | [OpenSSL::X509::Certificate.new(File.read(certificate_path))] 109 | end 110 | end 111 | end 112 | 113 | def finder_for(certificate_file, return_empty: false) 114 | RootCertificateFinder.new(certificate_file, return_empty) 115 | end 116 | 117 | def create_rsa_key 118 | key_bits = 1024 # NOTE: Use 2048 or more in real life! We use 1024 here just for making the test fast. 119 | 120 | OpenSSL::PKey::RSA.new(key_bits) 121 | end 122 | 123 | def create_ec_key 124 | OpenSSL::PKey::EC.generate("prime256v1") 125 | end 126 | 127 | X509_V3 = 2 128 | 129 | def create_root_certificate(key, not_before: Time.now - 1, not_after: Time.now + 60) 130 | certificate = OpenSSL::X509::Certificate.new 131 | 132 | certificate.version = X509_V3 133 | certificate.subject = OpenSSL::X509::Name.parse("CN=Root-#{rand(1_000_000)}") 134 | certificate.issuer = certificate.subject 135 | certificate.public_key = key 136 | certificate.not_before = not_before 137 | certificate.not_after = not_after 138 | 139 | extension_factory = OpenSSL::X509::ExtensionFactory.new 140 | extension_factory.subject_certificate = certificate 141 | extension_factory.issuer_certificate = certificate 142 | 143 | certificate.extensions = [ 144 | extension_factory.create_extension("basicConstraints", "CA:TRUE", true), 145 | extension_factory.create_extension("keyUsage", "keyCertSign,cRLSign", true), 146 | ] 147 | 148 | certificate.sign(key, "SHA256") 149 | 150 | certificate 151 | end 152 | 153 | def issue_certificate( 154 | ca_certificate, 155 | ca_key, 156 | key, 157 | version: X509_V3, 158 | name: "CN=Cert-#{rand(1_000_000)}", 159 | not_before: Time.now - 1, 160 | not_after: Time.now + 60, 161 | extensions: nil 162 | ) 163 | certificate = OpenSSL::X509::Certificate.new 164 | 165 | certificate.version = version 166 | certificate.subject = OpenSSL::X509::Name.parse(name) 167 | certificate.issuer = ca_certificate.subject 168 | certificate.not_before = not_before 169 | certificate.not_after = not_after 170 | certificate.public_key = key 171 | 172 | if extensions 173 | certificate.extensions = extensions 174 | end 175 | 176 | certificate.sign(ca_key, "SHA256") 177 | 178 | certificate 179 | end 180 | 181 | def fake_certificate_chain_validation_time(attestation_statement, time) 182 | allow(attestation_statement).to receive(:attestation_root_certificates_store) 183 | .and_wrap_original do |m, *_args, **kwargs| 184 | store = m.call(**kwargs) 185 | store.time = time 186 | store 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /spec/support/roots/android_key_root.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDFTCCArygAwIBAgIJAPYG0nMp5fspMAoGCCqGSM49BAMCMIHcMT0wOwYDVQQD 3 | DDRGQUtFIEFuZHJvaWQgS2V5c3RvcmUgU29mdHdhcmUgQXR0ZXN0YXRpb24gUm9v 4 | dCBGQUtFMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxs 5 | aWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMSIwIAYDVQQLDBlBdXRo 6 | ZW50aWNhdG9yIEF0dGVzdGF0aW9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkx 7 | EjAQBgNVBAcMCVdha2VmaWVsZDAgFw0xOTA0MjUwNTQ5MzJaGA8yMDc0MDEyNjA1 8 | NDkzMlowgdwxPTA7BgNVBAMMNEZBS0UgQW5kcm9pZCBLZXlzdG9yZSBTb2Z0d2Fy 9 | ZSBBdHRlc3RhdGlvbiBSb290IEZBS0UxMTAvBgkqhkiG9w0BCQEWImNvbmZvcm1h 10 | bmNlLXRvb2xzQGZpZG9hbGxpYW5jZS5vcmcxFjAUBgNVBAoMDUZJRE8gQWxsaWFu 11 | Y2UxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xCzAJBgNVBAYT 12 | AlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMFkwEwYHKoZIzj0C 13 | AQYIKoZIzj0DAQcDQgAEaH+BmKuo1XRtjS3UJVLrjh0bNl/lLhU7VX89N8+kuI8r 14 | nZCfXVD5k3qpOFSY6oTtrcZ1aQRSyR4sZ8EC6Hoi7aNjMGEwDwYDVR0TAQH/BAUw 15 | AwEB/zAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0OBBYEFFKaGzLgVqrNUQ/vX4A3Bovy 16 | kSMdMB8GA1UdIwQYMBaAFFKaGzLgVqrNUQ/vX4A3BovykSMdMAoGCCqGSM49BAMC 17 | A0cAMEQCIAfWMAOOjxO6noErB+wtRBmrDruNIApw70G7kBhliXLkAiBM8sYUVClP 18 | sJkCUHI5Wix3ybY31L5J51VS+f1gACNn+Q== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /spec/support/roots/android_safetynet_root.crt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-ruby/02258ff79027607b7d065eec9b66ebeab869443f/spec/support/roots/android_safetynet_root.crt -------------------------------------------------------------------------------- /spec/support/roots/feitian_ft_fido_0200.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIBfjCCASWgAwIBAgIBATAKBggqhkjOPQQDAjAXMRUwEwYDVQQDDAxGVCBGSURP 3 | IDAyMDAwIBcNMTYwNTAxMDAwMDAwWhgPMjA1MDA1MDEwMDAwMDBaMBcxFTATBgNV 4 | BAMMDEZUIEZJRE8gMDIwMDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNBmrRqV 5 | OxztTJVN19vtdqcL7tKQeol2nnM2/yYgvksZnr50SKbVgIEkzHQVOu80LVEE3lVh 6 | eO1HjggxAlT6o4WjYDBeMB0GA1UdDgQWBBRJFWQt1bvG3jM6XgmV/IcjNtO/CzAf 7 | BgNVHSMEGDAWgBRJFWQt1bvG3jM6XgmV/IcjNtO/CzAMBgNVHRMEBTADAQH/MA4G 8 | A1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNHADBEAiAwfPqgIWIUB+QBBaVGsdHy 9 | 0s5RMxlkzpSX/zSyTZmUpQIgB2wJ6nZRM8oX/nA43Rh6SJovM2XwCCH//+LirBAb 10 | B0M= 11 | -----END CERTIFICATE----- 12 | -------------------------------------------------------------------------------- /spec/support/roots/microsoft_tpm_root_certificate_authority_2014.cer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-ruby/02258ff79027607b7d065eec9b66ebeab869443f/spec/support/roots/microsoft_tpm_root_certificate_authority_2014.cer -------------------------------------------------------------------------------- /spec/support/roots/yubico_u2f_root.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ 3 | dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw 4 | MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290 5 | IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 6 | AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk 7 | 5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep 8 | 8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw 9 | nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT 10 | 9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw 11 | LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ 12 | hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN 13 | BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4 14 | MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt 15 | hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k 16 | LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U 17 | sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc 18 | U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/android_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "json" 6 | require "openssl" 7 | require "webauthn/attestation_statement/android_key" 8 | 9 | RSpec.describe "AndroidKey attestation" do 10 | describe "#valid?" do 11 | let(:credential_key) { create_ec_key } 12 | let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) } 13 | 14 | let(:authenticator_data_bytes) do 15 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 16 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 17 | credential: { id: "0".b * 16, public_key: credential_key.public_key }, 18 | ).serialize 19 | end 20 | 21 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 22 | let(:to_be_signed) { authenticator_data.data + client_data_hash } 23 | 24 | let(:algorithm) { -7 } 25 | let(:attestation_key) { credential_key } 26 | let(:signature) { attestation_key.sign("SHA256", to_be_signed) } 27 | let(:attestation_certificate_attestation_challenge) { OpenSSL::ASN1::OctetString.new(client_data_hash) } 28 | let(:attestation_certificate_purpose) { OpenSSL::ASN1::Set.new([OpenSSL::ASN1::Integer.new(2)], 1, :EXPLICIT) } 29 | let(:attestation_certificate_origin) { OpenSSL::ASN1::Integer.new(0, 702, :EXPLICIT) } 30 | 31 | let(:attestation_certificate_tee_enforced) do 32 | OpenSSL::ASN1::Sequence.new([attestation_certificate_purpose, attestation_certificate_origin]) 33 | end 34 | 35 | let(:attestation_certificate_software_enforced) { OpenSSL::ASN1::Sequence.new([]) } 36 | 37 | let(:attestation_certificate_extension) do 38 | OpenSSL::ASN1::Sequence.new( 39 | [ 40 | OpenSSL::ASN1::Integer.new(3), 41 | OpenSSL::ASN1::Integer.new(0), 42 | OpenSSL::ASN1::Integer.new(0), 43 | OpenSSL::ASN1::Integer.new(0), 44 | attestation_certificate_attestation_challenge, 45 | OpenSSL::ASN1::OctetString.new(""), 46 | attestation_certificate_software_enforced, 47 | attestation_certificate_tee_enforced 48 | ] 49 | ).to_der 50 | end 51 | 52 | let(:attestation_certificate_extensions) do 53 | [OpenSSL::X509::Extension.new("1.3.6.1.4.1.11129.2.1.17", attestation_certificate_extension, false)] 54 | end 55 | 56 | let(:attestation_certificate) do 57 | issue_certificate( 58 | root_certificate, 59 | root_key, 60 | attestation_key, 61 | extensions: attestation_certificate_extensions 62 | ).to_der 63 | end 64 | 65 | let(:statement) do 66 | WebAuthn::AttestationStatement::AndroidKey.new( 67 | "alg" => algorithm, 68 | "sig" => signature, 69 | "x5c" => [attestation_certificate] 70 | ) 71 | end 72 | 73 | let(:root_key) { create_ec_key } 74 | let(:root_certificate) { create_root_certificate(root_key) } 75 | let(:google_certificates) { [root_certificate] } 76 | 77 | around do |example| 78 | silence_warnings do 79 | original_google_certificates = AndroidKeyAttestation::Statement::GOOGLE_ROOT_CERTIFICATES 80 | AndroidKeyAttestation::Statement::GOOGLE_ROOT_CERTIFICATES = google_certificates 81 | example.run 82 | AndroidKeyAttestation::Statement::GOOGLE_ROOT_CERTIFICATES = original_google_certificates 83 | end 84 | end 85 | 86 | it "works if everything's fine" do 87 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 88 | end 89 | 90 | context "when RSA algorithm" do 91 | let(:algorithm) { -257 } 92 | let(:credential_key) { create_rsa_key } 93 | 94 | it "works" do 95 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 96 | end 97 | end 98 | 99 | context "when signature is invalid" do 100 | context "because is signed with a different alg" do 101 | let(:algorithm) { -36 } 102 | 103 | it "fails" do 104 | expect { 105 | statement.valid?(authenticator_data, client_data_hash) 106 | }.to raise_error("Unsupported algorithm -36") 107 | end 108 | end 109 | 110 | context "because it was signed with a different key" do 111 | let(:signature) { create_ec_key.sign("SHA256", to_be_signed) } 112 | 113 | it "fails" do 114 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 115 | end 116 | end 117 | 118 | context "because it was signed over different data" do 119 | let(:to_be_signed) { "other data" } 120 | 121 | it "fails" do 122 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 123 | end 124 | end 125 | 126 | context "because it is corrupted" do 127 | let(:signature) { "corrupted signature".b } 128 | 129 | it "fails" do 130 | expect { statement.valid?(authenticator_data, client_data_hash) }.to raise_error(OpenSSL::PKey::PKeyError) 131 | end 132 | end 133 | end 134 | 135 | context "when the attestation key doesn't match the credential key" do 136 | let(:attestation_key) { create_ec_key } 137 | 138 | it "fails" do 139 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 140 | end 141 | end 142 | 143 | context "when the attestation certificate doesn't meet requirements" do 144 | context "because attestationChallenge is invalid" do 145 | let(:attestation_certificate_attestation_challenge) { OpenSSL::ASN1::OctetString.new(client_data_hash[0..-2]) } 146 | 147 | it "fails" do 148 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 149 | end 150 | end 151 | 152 | context "because allApplications field is present teeEnforced" do 153 | let(:attestation_certificate_tee_enforced) do 154 | OpenSSL::ASN1::Sequence.new( 155 | [ 156 | attestation_certificate_purpose, 157 | attestation_certificate_origin, 158 | OpenSSL::ASN1::Null.new(nil, 600, :EXPLICIT) 159 | ] 160 | ) 161 | end 162 | 163 | it "fails" do 164 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 165 | end 166 | end 167 | 168 | context "because allApplications field is present softwareEnforced" do 169 | let(:attestation_certificate_software_enforced) do 170 | OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Null.new(nil, 600, :EXPLICIT)]) 171 | end 172 | 173 | it "fails" do 174 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 175 | end 176 | end 177 | 178 | context "because AuthorizationList.purpose is invalid" do 179 | let(:attestation_certificate_purpose) { OpenSSL::ASN1::Set.new([OpenSSL::ASN1::Integer.new(3)], 1, :EXPLICIT) } 180 | 181 | it "fails" do 182 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 183 | end 184 | end 185 | 186 | context "because AuthorizationList.origin is invalid" do 187 | let(:attestation_certificate_origin) { OpenSSL::ASN1::Integer.new(1, 702, :EXPLICIT) } 188 | 189 | it "fails" do 190 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 191 | end 192 | end 193 | end 194 | 195 | context "when the attestation certificate is not signed by Google" do 196 | let(:google_certificates) { [create_root_certificate(create_ec_key)] } 197 | 198 | it "fails" do 199 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 200 | end 201 | 202 | it "returns true if they are configured" do 203 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for(root_certificate) 204 | 205 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/android_safetynet_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "jwt" 6 | require "openssl" 7 | require "webauthn/attestation_statement/android_safetynet" 8 | 9 | RSpec.describe WebAuthn::AttestationStatement::AndroidSafetynet do 10 | describe "#valid?" do 11 | let(:statement) { described_class.new("ver" => version, "response" => response) } 12 | let(:version) { "2.0" } 13 | 14 | let(:response) do 15 | JWT.encode( 16 | payload, 17 | attestation_key, 18 | "RS256", 19 | x5c: [WebAuthn::Encoders::Base64Encoder.encode(leaf_certificate.to_der)] 20 | ) 21 | end 22 | 23 | let(:payload) do 24 | { "nonce" => nonce, "ctsProfileMatch" => cts_profile_match, "timestampMs" => timestamp.to_i * 1000 } 25 | end 26 | let(:timestamp) { Time.now } 27 | let(:cts_profile_match) { true } 28 | let(:nonce) do 29 | WebAuthn::Encoders::Base64Encoder.encode( 30 | OpenSSL::Digest::SHA256.digest(authenticator_data_bytes + client_data_hash) 31 | ) 32 | end 33 | let(:attestation_key) { create_rsa_key } 34 | 35 | let(:leaf_certificate) do 36 | issue_certificate(root_certificate, root_key, attestation_key, name: "CN=attest.android.com") 37 | end 38 | 39 | let(:root_key) { create_ec_key } 40 | let(:root_certificate) { create_root_certificate(root_key) } 41 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 42 | 43 | let(:authenticator_data_bytes) do 44 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 45 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 46 | credential: { id: "0".b * 16, public_key: credential_key.public_key }, 47 | ).serialize 48 | end 49 | 50 | let(:credential_key) { create_rsa_key } 51 | let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) } 52 | 53 | let(:google_certificates) { [root_certificate] } 54 | 55 | around do |example| 56 | silence_warnings do 57 | original_google_certificates = SafetyNetAttestation::Statement::GOOGLE_ROOT_CERTIFICATES 58 | SafetyNetAttestation::Statement::GOOGLE_ROOT_CERTIFICATES = google_certificates 59 | example.run 60 | SafetyNetAttestation::Statement::GOOGLE_ROOT_CERTIFICATES = original_google_certificates 61 | end 62 | end 63 | 64 | it "returns true when everything's in place" do 65 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 66 | end 67 | 68 | context "when nonce is not set to the base64 of the SHA256 of authData + clientDataHash" do 69 | let(:nonce) { WebAuthn::Encoders::Base64Encoder.encode(OpenSSL::Digest.digest("SHA256", "something else")) } 70 | 71 | it "returns false" do 72 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 73 | end 74 | end 75 | 76 | context "when ctsProfileMatch is not true" do 77 | let(:cts_profile_match) { false } 78 | 79 | it "returns false" do 80 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 81 | end 82 | end 83 | 84 | context "when the attestation certificate is not signed by Google" do 85 | let(:google_certificates) { [create_root_certificate(create_ec_key)] } 86 | 87 | it "fails" do 88 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 89 | end 90 | 91 | it "returns true if they are configured" do 92 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for(root_certificate) 93 | 94 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/apple_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "openssl" 6 | require "webauthn/attestation_statement/apple" 7 | 8 | RSpec.describe "Apple attestation" do 9 | describe "#valid?" do 10 | let(:credential_key) { create_ec_key } 11 | let(:root_key) { create_ec_key } 12 | let(:root_certificate) { create_root_certificate(root_key) } 13 | 14 | let(:cred_cert) do 15 | issue_certificate(root_certificate, root_key, credential_key, extensions: [cred_cert_extension]) 16 | end 17 | 18 | let(:statement) { WebAuthn::AttestationStatement::Apple.new("x5c" => [cred_cert.to_der]) } 19 | 20 | let(:authenticator_data_bytes) do 21 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 22 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 23 | credential: { id: "0".b * 16, public_key: credential_key.public_key } 24 | ).serialize 25 | end 26 | 27 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 28 | let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) } 29 | 30 | let(:nonce) { Digest::SHA256.digest(authenticator_data.data + client_data_hash) } 31 | let(:cred_cert_extension) do 32 | OpenSSL::X509::Extension.new( 33 | "1.2.840.113635.100.8.2", 34 | OpenSSL::ASN1::Sequence.new( 35 | [OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::OctetString.new(nonce)])] 36 | ) 37 | ) 38 | end 39 | 40 | around do |example| 41 | silence_warnings do 42 | original_apple_certificate = WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE 43 | WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE = root_certificate 44 | example.run 45 | WebAuthn::AttestationStatement::Apple::ROOT_CERTIFICATE = original_apple_certificate 46 | end 47 | end 48 | 49 | it "works if everything's fine" do 50 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 51 | end 52 | 53 | context "when nonce is invalid" do 54 | let(:nonce) { Digest::SHA256.digest("Invalid") } 55 | 56 | it "fails" do 57 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 58 | end 59 | end 60 | 61 | context "when the credential public key is invalid" do 62 | let(:cred_cert) do 63 | issue_certificate(root_certificate, root_key, create_ec_key, extensions: [cred_cert_extension]) 64 | end 65 | 66 | it "fails" do 67 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/fido_u2f_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "json" 6 | require "openssl" 7 | require "webauthn/attestation_statement/fido_u2f" 8 | 9 | RSpec.describe "FidoU2f attestation" do 10 | describe "#valid?" do 11 | let(:credential_public_key) { create_ec_key.public_key } 12 | let(:client_data_hash) { OpenSSL::Digest::SHA256.digest({}.to_json) } 13 | 14 | let(:authenticator_data_bytes) do 15 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 16 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 17 | credential: { id: "0".b * 16, public_key: credential_public_key }, 18 | aaguid: WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID 19 | ).serialize 20 | end 21 | 22 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 23 | let(:to_be_signed) do 24 | "\x00" + 25 | authenticator_data.rp_id_hash + 26 | client_data_hash + 27 | authenticator_data.credential.id + 28 | credential_public_key.to_bn.to_s(2) 29 | end 30 | 31 | let(:attestation_key) { create_ec_key } 32 | let(:signature) { attestation_key.sign("SHA256", to_be_signed) } 33 | 34 | let(:attestation_certificate) do 35 | issue_certificate(root_certificate, root_key, attestation_key) 36 | end 37 | 38 | let(:statement) do 39 | WebAuthn::AttestationStatement::FidoU2f.new( 40 | "sig" => signature, 41 | "x5c" => [attestation_certificate.to_der] 42 | ) 43 | end 44 | 45 | let(:root_key) { create_ec_key } 46 | 47 | let(:root_certificate) do 48 | create_root_certificate(root_key) 49 | end 50 | 51 | before do 52 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for(root_certificate) 53 | end 54 | 55 | it "works if everything's fine" do 56 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 57 | end 58 | 59 | context 'when the attestation certificate is the only certificate in the certificate chain' do 60 | context "and it's equal to one of the root certificates" do 61 | before do 62 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for(attestation_certificate) 63 | end 64 | 65 | it "works" do 66 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_truthy 67 | end 68 | end 69 | end 70 | 71 | context "when signature is invalid" do 72 | context "because it was signed with a different signing key (self attested)" do 73 | let(:signature) { create_ec_key.sign("SHA256", to_be_signed) } 74 | 75 | it "fails" do 76 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 77 | end 78 | end 79 | 80 | context "because it was signed over different data" do 81 | let(:to_be_signed) { "other data" } 82 | 83 | it "fails" do 84 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 85 | end 86 | end 87 | 88 | context "because it is corrupted" do 89 | let(:signature) { "corrupted signature".b } 90 | 91 | it "fails" do 92 | expect { statement.valid?(authenticator_data, client_data_hash) }.to raise_error(OpenSSL::PKey::PKeyError) 93 | end 94 | end 95 | end 96 | 97 | context "when the attested credential public key is invalid" do 98 | context "because the coordinates are longer than expected" do 99 | let(:credential_public_key) do 100 | WebAuthn.configuration.algorithms << "ES384" 101 | 102 | OpenSSL::PKey::EC.generate("secp384r1").public_key 103 | end 104 | 105 | it "fails" do 106 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 107 | end 108 | end 109 | end 110 | 111 | context "when the attestation certificate is invalid" do 112 | context "because there are too many" do 113 | let(:statement) do 114 | WebAuthn::AttestationStatement::FidoU2f.new( 115 | "sig" => signature, 116 | "x5c" => [attestation_certificate.to_der, attestation_certificate.to_der] 117 | ) 118 | end 119 | 120 | it "fails" do 121 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 122 | end 123 | end 124 | 125 | context "because it is not of the correct type" do 126 | let(:attestation_key) { create_rsa_key } 127 | 128 | it "fails" do 129 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 130 | end 131 | end 132 | 133 | context "because it is not of the correct curve" do 134 | let(:attestation_key) { OpenSSL::PKey::EC.generate("secp384r1") } 135 | 136 | it "fails" do 137 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 138 | end 139 | end 140 | end 141 | 142 | context "when the AAGUID is invalid" do 143 | let(:authenticator_data_bytes) do 144 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 145 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "RP"), 146 | credential: { id: "0".b * 16, public_key: credential_public_key }, 147 | aaguid: SecureRandom.random_bytes(16) 148 | ).serialize 149 | end 150 | 151 | it "fails" do 152 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 153 | end 154 | end 155 | 156 | context "when the certificate chain is invalid" do 157 | context "when finder doesn't have correct certificate" do 158 | before do 159 | WebAuthn.configuration.attestation_root_certificates_finders = finder_for( 160 | nil, 161 | return_empty: true 162 | ) 163 | end 164 | 165 | it "returns false" do 166 | expect(statement.valid?(authenticator_data, client_data_hash)).to be_falsy 167 | end 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /spec/webauthn/attestation_statement/none_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "webauthn/attestation_statement/none" 6 | 7 | RSpec.describe "none attestation" do 8 | let(:authenticator_data_bytes) do 9 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 10 | rp_id_hash: OpenSSL::Digest.digest("SHA256", "localhost"), 11 | aaguid: 0.chr * 16, 12 | ).serialize 13 | end 14 | let(:authenticator_data) { WebAuthn::AuthenticatorData.deserialize(authenticator_data_bytes) } 15 | 16 | describe "#valid?" do 17 | it "returns true if the statement is an empty map" do 18 | expect(WebAuthn::AttestationStatement::None.new({}).valid?(authenticator_data, nil)).to be_truthy 19 | end 20 | 21 | it "returns attestation info" do 22 | attestation_statement = WebAuthn::AttestationStatement::None.new({}) 23 | expect(attestation_statement.valid?(authenticator_data, nil)).to eq( 24 | ["None", nil] 25 | ) 26 | end 27 | 28 | it "returns false if the statement is something else" do 29 | expect(WebAuthn::AttestationStatement::None.new(nil).valid?(authenticator_data, nil)).to be_falsy 30 | expect(WebAuthn::AttestationStatement::None.new("").valid?(authenticator_data, nil)).to be_falsy 31 | expect(WebAuthn::AttestationStatement::None.new([]).valid?(authenticator_data, nil)).to be_falsy 32 | expect(WebAuthn::AttestationStatement::None.new("a" => "b").valid?(authenticator_data, nil)).to be_falsy 33 | end 34 | 35 | it "returns false if None is not among the acceptable attestation types" do 36 | WebAuthn.configuration.acceptable_attestation_types = ['AttCA'] 37 | 38 | expect(WebAuthn::AttestationStatement::None.new({}).valid?(authenticator_data, nil)).to be_falsy 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/webauthn/authenticator_data/attested_credential_data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WebAuthn::AuthenticatorData::AttestedCredentialData do 6 | let(:cose_key_data) { fake_cose_credential_key } 7 | 8 | def raw_attested_credential_data(options = {}) 9 | options = { 10 | aaguid: SecureRandom.random_bytes(16), 11 | id: SecureRandom.random_bytes(16), 12 | public_key: cose_key_data 13 | }.merge(options) 14 | 15 | options[:aaguid] + [options[:id].length].pack("n*") + options[:id] + options[:public_key] 16 | end 17 | 18 | describe "#valid?" do 19 | it "returns false if public key is missing" do 20 | raw_data = raw_attested_credential_data(public_key: CBOR.encode("")) 21 | 22 | attested_credential_data = 23 | WebAuthn::AuthenticatorData::AttestedCredentialData.deserialize(raw_data) 24 | 25 | expect { attested_credential_data.valid? }.to raise_error(COSE::UnknownKeyType) 26 | expect { attested_credential_data.credential }.to raise_error(COSE::UnknownKeyType) 27 | end 28 | 29 | it "returns false if public key doesn't contain the alg parameter" do 30 | raw_data = raw_attested_credential_data(public_key: fake_cose_credential_key(algorithm: nil)) 31 | 32 | attested_credential_data = 33 | WebAuthn::AuthenticatorData::AttestedCredentialData.deserialize(raw_data) 34 | 35 | expect(attested_credential_data.valid?).to be_falsy 36 | expect(attested_credential_data.credential).to eq(nil) 37 | end 38 | 39 | it "returns true if all data is present" do 40 | raw_data = raw_attested_credential_data(id: "this-is-a-credential-id") 41 | attested_credential_data = WebAuthn::AuthenticatorData::AttestedCredentialData.deserialize(raw_data) 42 | 43 | expect(attested_credential_data.valid?).to be_truthy 44 | expect(attested_credential_data.credential.id).to eq("this-is-a-credential-id") 45 | expect(attested_credential_data.credential.public_key).to eq(cose_key_data) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/webauthn/authenticator_data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WebAuthn::AuthenticatorData do 6 | let(:serialized_authenticator_data) do 7 | WebAuthn::FakeAuthenticator::AuthenticatorData.new( 8 | rp_id_hash: rp_id_hash, 9 | sign_count: sign_count, 10 | user_present: user_present, 11 | user_verified: user_verified, 12 | backup_eligibility: backup_eligibility, 13 | backup_state: backup_state, 14 | ).serialize 15 | end 16 | 17 | let(:rp_id_hash) { OpenSSL::Digest.digest("SHA256", "localhost") } 18 | let(:sign_count) { 42 } 19 | let(:user_present) { true } 20 | let(:user_verified) { false } 21 | let(:backup_eligibility) { false } 22 | let(:backup_state) { false } 23 | 24 | let(:authenticator_data) { described_class.deserialize(serialized_authenticator_data) } 25 | 26 | describe "#valid?" do 27 | it "returns true" do 28 | expect(authenticator_data.valid?).to be_truthy 29 | end 30 | 31 | it "returns false if leftover bytes" do 32 | data = WebAuthn::FakeAuthenticator::AuthenticatorData.new( 33 | rp_id_hash: rp_id_hash, 34 | sign_count: sign_count, 35 | user_present: user_present, 36 | user_verified: user_verified, 37 | extensions: nil 38 | ).serialize 39 | 40 | authenticator_data = WebAuthn::AuthenticatorData.deserialize(data + CBOR.encode("k" => "v")) 41 | 42 | expect(authenticator_data.valid?).to be_falsy 43 | end 44 | end 45 | 46 | describe "#rp_id_hash" do 47 | subject { authenticator_data.rp_id_hash } 48 | 49 | it { is_expected.to eq(rp_id_hash) } 50 | end 51 | 52 | describe "#sign_count" do 53 | subject { authenticator_data.sign_count } 54 | 55 | it { is_expected.to eq(42) } 56 | end 57 | 58 | describe "#user_present?" do 59 | subject { authenticator_data.user_present? } 60 | 61 | context "when UP flag is set" do 62 | let(:user_present) { true } 63 | 64 | it { is_expected.to be_truthy } 65 | end 66 | 67 | context "when UP flag is not set" do 68 | let(:user_present) { false } 69 | 70 | it { is_expected.to be_falsy } 71 | end 72 | end 73 | 74 | describe "#user_verified?" do 75 | subject { authenticator_data.user_verified? } 76 | 77 | context "when UV flag is set" do 78 | let(:user_verified) { true } 79 | 80 | it { is_expected.to be_truthy } 81 | end 82 | 83 | context "when UV flag is not set" do 84 | let(:user_verified) { false } 85 | 86 | it { is_expected.to be_falsy } 87 | end 88 | end 89 | 90 | describe "#user_flagged?" do 91 | subject { authenticator_data.user_flagged? } 92 | 93 | context "when both UP and UV flag are set" do 94 | let(:user_present) { true } 95 | let(:user_verified) { true } 96 | 97 | it { is_expected.to be_truthy } 98 | end 99 | 100 | context "when only UP is set" do 101 | let(:user_present) { true } 102 | let(:user_verified) { false } 103 | 104 | it { is_expected.to be_truthy } 105 | end 106 | 107 | context "when only UV flag is set" do 108 | let(:user_present) { false } 109 | let(:user_verified) { true } 110 | 111 | it { is_expected.to be_truthy } 112 | end 113 | 114 | context "when both UP and UV flag are not set" do 115 | let(:user_present) { false } 116 | let(:user_verified) { false } 117 | 118 | it { is_expected.to be_falsy } 119 | end 120 | end 121 | 122 | describe "#credential_backup_eligible?" do 123 | subject { authenticator_data.credential_backup_eligible? } 124 | 125 | context "when BE flag is set" do 126 | let(:backup_eligibility) { true } 127 | 128 | it { is_expected.to be_truthy } 129 | end 130 | 131 | context "when BE flag is not set" do 132 | let(:backup_eligibility) { false } 133 | 134 | it { is_expected.to be_falsy } 135 | end 136 | end 137 | 138 | describe "#credential_backed_up?" do 139 | subject { authenticator_data.credential_backed_up? } 140 | 141 | context "when BS flag is set" do 142 | let(:backup_state) { true } 143 | 144 | it { is_expected.to be_truthy } 145 | end 146 | 147 | context "when BS flag is not set" do 148 | let(:backup_state) { false } 149 | 150 | it { is_expected.to be_falsy } 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/webauthn/credential_creation_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "webauthn/credential_creation_options" 5 | 6 | RSpec.describe WebAuthn::CredentialCreationOptions do 7 | let(:creation_options) do 8 | WebAuthn::CredentialCreationOptions.new(user_id: "1", user_name: "User", user_display_name: "User Display") 9 | end 10 | 11 | it "has a challenge" do 12 | expect(creation_options.challenge.class).to eq(String) 13 | expect(creation_options.challenge.encoding).to eq(Encoding::ASCII_8BIT) 14 | expect(creation_options.challenge.length).to eq(32) 15 | end 16 | 17 | context "public key params" do 18 | it "has default public key params" do 19 | params = creation_options.pub_key_cred_params 20 | 21 | array = [ 22 | { type: "public-key", alg: -7 }, 23 | { type: "public-key", alg: -37 }, 24 | { type: "public-key", alg: -257 }, 25 | ] 26 | 27 | expect(params).to match_array(array) 28 | end 29 | 30 | context "when extra alg added" do 31 | before do 32 | WebAuthn.configuration.algorithms << "RS1" 33 | end 34 | 35 | it "is added to public key params" do 36 | params = creation_options.pub_key_cred_params 37 | 38 | array = [ 39 | { type: "public-key", alg: -7 }, 40 | { type: "public-key", alg: -37 }, 41 | { type: "public-key", alg: -257 }, 42 | { type: "public-key", alg: -65535 }, 43 | ] 44 | 45 | expect(params).to match_array(array) 46 | end 47 | end 48 | end 49 | 50 | context "Relying Party info" do 51 | it "has relying party name default" do 52 | expect(creation_options.rp.name).to eq("web-server") 53 | end 54 | 55 | context "when configured" do 56 | before do 57 | WebAuthn.configuration.rp_name = "Example Inc." 58 | end 59 | 60 | it "has the configured values" do 61 | expect(creation_options.rp.name).to eq("Example Inc.") 62 | end 63 | end 64 | end 65 | 66 | it "has user info" do 67 | expect(creation_options.user.id).to eq("1") 68 | expect(creation_options.user.name).to eq("User") 69 | expect(creation_options.user.display_name).to eq("User Display") 70 | end 71 | 72 | context "client timeout" do 73 | it "has a default client timeout" do 74 | expect(creation_options.timeout).to(eq(120000)) 75 | end 76 | 77 | context "when client timeout is configured" do 78 | before do 79 | WebAuthn.configuration.credential_options_timeout = 60000 80 | end 81 | 82 | it "updates the client timeout" do 83 | expect(creation_options.timeout).to(eq(60000)) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/webauthn/credential_request_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "webauthn/credential_request_options" 5 | 6 | RSpec.describe WebAuthn::CredentialRequestOptions do 7 | let(:request_options) { WebAuthn::CredentialRequestOptions.new } 8 | 9 | it "has a challenge" do 10 | expect(request_options.challenge.class).to eq(String) 11 | expect(request_options.challenge.encoding).to eq(Encoding::ASCII_8BIT) 12 | expect(request_options.challenge.length).to eq(32) 13 | end 14 | 15 | it "has allowCredentials param with an empty array" do 16 | expect(request_options.allow_credentials).to match_array([]) 17 | end 18 | 19 | context "client timeout" do 20 | it "has a default client timeout" do 21 | expect(request_options.timeout).to(eq(120000)) 22 | end 23 | 24 | context "when client timeout is configured" do 25 | before do 26 | WebAuthn.configuration.credential_options_timeout = 60000 27 | end 28 | 29 | it "updates the client timeout" do 30 | expect(request_options.timeout).to(eq(60000)) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/webauthn/credential_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "webauthn/configuration" 6 | require "webauthn/credential" 7 | 8 | RSpec.describe "Credential" do 9 | let(:origin) { fake_origin } 10 | 11 | before do 12 | WebAuthn.configuration.allowed_origins = [origin] 13 | end 14 | 15 | describe ".from_create" do 16 | let(:challenge) do 17 | WebAuthn::Credential.options_for_create(user: { id: "1", name: "User" }).challenge 18 | end 19 | 20 | let(:client) { WebAuthn::FakeClient.new(origin) } 21 | 22 | before do 23 | WebAuthn.configuration.encoding = encoding 24 | end 25 | 26 | context "when encoding is base64url" do 27 | let(:encoding) { :base64url } 28 | 29 | it "works" do 30 | credential = WebAuthn::Credential.from_create(client.create(challenge: challenge)) 31 | 32 | expect(credential.verify(challenge)).to be_truthy 33 | 34 | expect(credential.id).not_to be_empty 35 | expect(credential.public_key).not_to be_empty 36 | expect(credential.public_key.class).to eq(String) 37 | expect(credential.public_key.encoding).not_to eq(Encoding::BINARY) 38 | expect(credential.sign_count).to eq(0) 39 | end 40 | end 41 | 42 | context "when encoding is base64" do 43 | let(:encoding) { :base64 } 44 | 45 | it "works" do 46 | credential = WebAuthn::Credential.from_create(client.create(challenge: challenge)) 47 | 48 | expect(credential.verify(challenge)).to be_truthy 49 | 50 | expect(credential.id).not_to be_empty 51 | expect(credential.public_key).not_to be_empty 52 | expect(credential.public_key.class).to eq(String) 53 | expect(credential.public_key.encoding).not_to eq(Encoding::BINARY) 54 | expect(credential.sign_count).to eq(0) 55 | end 56 | end 57 | 58 | context "when not encoding" do 59 | let(:encoding) { false } 60 | 61 | it "works" do 62 | credential = WebAuthn::Credential.from_create(client.create(challenge: challenge)) 63 | 64 | expect(credential.verify(challenge)).to be_truthy 65 | 66 | expect(credential.id).not_to be_empty 67 | expect(credential.public_key).not_to be_empty 68 | expect(credential.public_key.class).to eq(String) 69 | expect(credential.public_key.encoding).to eq(Encoding::BINARY) 70 | expect(credential.sign_count).to eq(0) 71 | end 72 | end 73 | end 74 | 75 | describe ".from_get" do 76 | let(:challenge) do 77 | WebAuthn::Credential.options_for_get.challenge 78 | end 79 | 80 | let(:client) { WebAuthn::FakeClient.new(origin) } 81 | 82 | let(:credential_from_create) do 83 | WebAuthn::Credential.from_create(created_credential) 84 | end 85 | 86 | let(:created_credential) { client.create } 87 | 88 | let(:public_key) { credential_from_create.public_key } 89 | let(:sign_count) { credential_from_create.sign_count } 90 | 91 | before do 92 | WebAuthn.configuration.encoding = encoding 93 | 94 | # Client needs to have a created credential before getting one 95 | created_credential 96 | end 97 | 98 | context "when encoding is base64url" do 99 | let(:encoding) { :base64url } 100 | 101 | it "works" do 102 | credential = WebAuthn::Credential.from_get(client.get(challenge: challenge)) 103 | 104 | expect(credential.verify(challenge, public_key: public_key, sign_count: sign_count)).to be_truthy 105 | 106 | expect(credential.id).not_to be_empty 107 | expect(credential.user_handle).to be_nil 108 | expect(credential.sign_count).to eq(1) 109 | end 110 | end 111 | 112 | context "when encoding is base64" do 113 | let(:encoding) { :base64 } 114 | 115 | it "works" do 116 | credential = WebAuthn::Credential.from_get(client.get(challenge: challenge)) 117 | 118 | expect(credential.verify(challenge, public_key: public_key, sign_count: sign_count)).to be_truthy 119 | 120 | expect(credential.id).not_to be_empty 121 | expect(credential.user_handle).to be_nil 122 | expect(credential.sign_count).to eq(1) 123 | end 124 | end 125 | 126 | context "when not encoding" do 127 | let(:encoding) { false } 128 | 129 | it "works" do 130 | credential = WebAuthn::Credential.from_get(client.get(challenge: challenge)) 131 | 132 | expect(credential.verify(challenge, public_key: public_key, sign_count: sign_count)).to be_truthy 133 | 134 | expect(credential.id).not_to be_empty 135 | expect(credential.user_handle).to be_nil 136 | expect(credential.sign_count).to eq(1) 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /spec/webauthn/fake_client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "FakeClient" do 6 | let(:client) { WebAuthn::FakeClient.new } 7 | 8 | context "#get" do 9 | let!(:credential_1) { client.create } 10 | let!(:credential_2) { client.create } 11 | 12 | it "returns the first matching credential when allow_credentials is nil" do 13 | assertion = client.get 14 | expect(assertion["id"]).to eq(credential_1["id"]) 15 | end 16 | 17 | it "returns the matching credential when allow_credentials is passed" do 18 | allow_credentials = [credential_2["id"]] 19 | assertion = client.get(allow_credentials: allow_credentials) 20 | expect(assertion["id"]).to eq(credential_2["id"]) 21 | end 22 | 23 | it "raises an error when no matching allow_credential can be found" do 24 | # base64(abc) is surely not a valid credential id (too short) 25 | allow_credentials = ["YWJj"] 26 | expect { client.get(allow_credentials: allow_credentials) }.to \ 27 | raise_error(RuntimeError, /No matching credentials \(allowed=\["abc"\]\) found for RP/) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/webauthn/public_key_credential/creation_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "webauthn/public_key_credential/creation_options" 5 | 6 | RSpec.describe WebAuthn::PublicKeyCredential::CreationOptions do 7 | let(:user_id) { WebAuthn.generate_user_id } 8 | let(:creation_options) do 9 | WebAuthn::PublicKeyCredential::CreationOptions.new( 10 | user: { id: user_id, name: "User", display_name: "User Display" } 11 | ) 12 | end 13 | 14 | it "has a challenge" do 15 | expect(creation_options.challenge.class).to eq(String) 16 | expect(creation_options.challenge.encoding).to eq(Encoding::ASCII) 17 | expect(creation_options.challenge.length).to be >= 32 18 | end 19 | 20 | context "public key params" do 21 | it "has default public key params" do 22 | params = creation_options.pub_key_cred_params 23 | 24 | array = [ 25 | { type: "public-key", alg: -7 }, 26 | { type: "public-key", alg: -37 }, 27 | { type: "public-key", alg: -257 }, 28 | ] 29 | 30 | expect(params).to match_array(array) 31 | end 32 | 33 | context "when extra alg added" do 34 | before do 35 | WebAuthn.configuration.algorithms << "RS1" 36 | end 37 | 38 | it "is added to public key params" do 39 | params = creation_options.pub_key_cred_params 40 | 41 | array = [ 42 | { type: "public-key", alg: -7 }, 43 | { type: "public-key", alg: -37 }, 44 | { type: "public-key", alg: -257 }, 45 | { type: "public-key", alg: -65535 }, 46 | ] 47 | 48 | expect(params).to match_array(array) 49 | end 50 | end 51 | end 52 | 53 | context "Relying Party info" do 54 | it "has relying party name default to nothing" do 55 | expect(creation_options.rp.name).to eq(nil) 56 | end 57 | 58 | context "when configured" do 59 | before do 60 | WebAuthn.configuration.rp_name = "Example Inc." 61 | WebAuthn.configuration.rp_id = "example.com" 62 | end 63 | 64 | it "has the configured values" do 65 | expect(creation_options.rp.name).to eq("Example Inc.") 66 | expect(creation_options.rp.id).to eq("example.com") 67 | end 68 | end 69 | end 70 | 71 | it "has user info" do 72 | expect(creation_options.user.id).to eq(user_id) 73 | expect(creation_options.user.name).to eq("User") 74 | expect(creation_options.user.display_name).to eq("User Display") 75 | end 76 | 77 | context "client timeout" do 78 | it "has a default client timeout" do 79 | expect(creation_options.timeout).to(eq(120000)) 80 | end 81 | 82 | context "when client timeout is configured" do 83 | before do 84 | WebAuthn.configuration.credential_options_timeout = 60000 85 | end 86 | 87 | it "updates the client timeout" do 88 | expect(creation_options.timeout).to(eq(60000)) 89 | end 90 | end 91 | end 92 | 93 | it "has everything" do 94 | options = WebAuthn::PublicKeyCredential::CreationOptions.new( 95 | rp: { 96 | id: "rp-id", 97 | name: "rp-name" 98 | }, 99 | user: { 100 | id: "user-id", 101 | name: "user-name", 102 | display_name: "user-display-name" 103 | }, 104 | pub_key_cred_params: [{ type: "public-key", alg: -7 }], 105 | timeout: 10_000, 106 | exclude_credentials: [{ type: "public-key", id: "credential-id", transports: ["usb", "nfc"] }], 107 | authenticator_selection: { 108 | authenticator_attachment: "cross-platform", 109 | resident_key: "required", 110 | user_verification: "required" 111 | }, 112 | attestation: "direct", 113 | extensions: { whatever: "whatever" }, 114 | ) 115 | 116 | hash = options.as_json 117 | 118 | expect(hash[:rp]).to eq(id: "rp-id", name: "rp-name") 119 | expect(hash[:user]).to eq( 120 | id: "user-id", name: "user-name", displayName: "user-display-name" 121 | ) 122 | expect(hash[:pubKeyCredParams]).to eq([{ type: "public-key", alg: -7 }]) 123 | expect(hash[:timeout]).to eq(10_000) 124 | expect(hash[:excludeCredentials]).to eq([{ type: "public-key", id: "credential-id", transports: ["usb", "nfc"] }]) 125 | expect(hash[:authenticatorSelection]).to eq( 126 | authenticatorAttachment: "cross-platform", residentKey: "required", userVerification: "required" 127 | ) 128 | expect(hash[:attestation]).to eq("direct") 129 | expect(hash[:extensions]).to eq(whatever: "whatever") 130 | expect(hash[:challenge]).to be_truthy 131 | end 132 | 133 | it "has minimum required" do 134 | options = WebAuthn::PublicKeyCredential::CreationOptions.new( 135 | user: { 136 | id: "user-id", 137 | name: "user-name", 138 | } 139 | ) 140 | 141 | hash = options.as_json 142 | 143 | expect(hash[:rp]).to eq({}) 144 | expect(hash[:user]).to eq( 145 | id: "user-id", name: "user-name", displayName: 'user-name' 146 | ) 147 | expect(hash[:pubKeyCredParams]).to eq( 148 | [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -37 }, { type: "public-key", alg: -257 }] 149 | ) 150 | expect(hash[:timeout]).to eq(120_000) 151 | expect(hash[:challenge]).to be_truthy 152 | expect(hash[:extensions]).to eq({}) 153 | expect(hash).not_to have_key(:excludeCredentials) 154 | expect(hash).not_to have_key(:authenticatorSelection) 155 | expect(hash).not_to have_key(:attestation) 156 | end 157 | 158 | it "accepts shorthand for exclude_credentials" do 159 | options = WebAuthn::PublicKeyCredential::CreationOptions.new(user: { id: "id", name: "name" }, exclude: "id") 160 | 161 | expect(options.exclude).to eq("id") 162 | expect(options.exclude_credentials).to eq([{ type: "public-key", id: "id" }]) 163 | expect(options.as_json[:excludeCredentials]).to eq([{ type: "public-key", id: "id" }]) 164 | end 165 | 166 | it "accepts alg name for pub_key_cred_params" do 167 | options = WebAuthn::PublicKeyCredential::CreationOptions.new(user: { id: "id", name: "name" }, algs: "RS256") 168 | 169 | expect(options.algs).to eq("RS256") 170 | expect(options.pub_key_cred_params).to eq([{ type: "public-key", alg: -257 }]) 171 | expect(options.as_json[:pubKeyCredParams]).to eq([{ type: "public-key", alg: -257 }]) 172 | end 173 | 174 | it "accepts alg id for pub_key_cred_params" do 175 | options = WebAuthn::PublicKeyCredential::CreationOptions.new(user: { id: "id", name: "name" }, algs: -257) 176 | 177 | expect(options.algs).to eq(-257) 178 | expect(options.pub_key_cred_params).to eq([{ type: "public-key", alg: -257 }]) 179 | expect(options.as_json[:pubKeyCredParams]).to eq([{ type: "public-key", alg: -257 }]) 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /spec/webauthn/public_key_credential/request_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "webauthn/public_key_credential/request_options" 5 | 6 | RSpec.describe WebAuthn::PublicKeyCredential::RequestOptions do 7 | let(:request_options) { WebAuthn::PublicKeyCredential::RequestOptions.new } 8 | 9 | it "has a challenge" do 10 | expect(request_options.challenge.class).to eq(String) 11 | expect(request_options.challenge.encoding).to eq(Encoding::ASCII) 12 | expect(request_options.challenge.length).to be >= 32 13 | end 14 | 15 | it "has allowCredentials param with an empty array" do 16 | expect(request_options.allow_credentials).to match_array([]) 17 | end 18 | 19 | context "client timeout" do 20 | it "has a default client timeout" do 21 | expect(request_options.timeout).to(eq(120000)) 22 | end 23 | 24 | context "when client timeout is configured" do 25 | before do 26 | WebAuthn.configuration.credential_options_timeout = 60000 27 | end 28 | 29 | it "updates the client timeout" do 30 | expect(request_options.timeout).to(eq(60000)) 31 | end 32 | end 33 | end 34 | 35 | context "Relying Party info" do 36 | it "has relying party name default to nothing" do 37 | expect(request_options.rp_id).to eq(nil) 38 | end 39 | 40 | context "when configured" do 41 | before do 42 | WebAuthn.configuration.rp_id = "example.com" 43 | end 44 | 45 | it "has the configured values" do 46 | expect(request_options.rp_id).to eq("example.com") 47 | end 48 | end 49 | end 50 | 51 | it "has everything" do 52 | options = WebAuthn::PublicKeyCredential::RequestOptions.new( 53 | rp_id: "rp-id", 54 | timeout: 10_000, 55 | allow_credentials: [{ type: "public-key", id: "credential-id", transports: ["usb", "nfc"] }], 56 | user_verification: "required", 57 | extensions: { whatever: "whatever" }, 58 | ) 59 | 60 | hash = options.as_json 61 | 62 | expect(hash[:rpId]).to eq("rp-id") 63 | expect(hash[:timeout]).to eq(10_000) 64 | expect(hash[:allowCredentials]).to eq([{ type: "public-key", id: "credential-id", transports: ["usb", "nfc"] }]) 65 | expect(hash[:userVerification]).to eq("required") 66 | expect(hash[:extensions]).to eq(whatever: "whatever") 67 | expect(hash[:challenge]).to be_truthy 68 | end 69 | 70 | it "has minimum required" do 71 | options = WebAuthn::PublicKeyCredential::RequestOptions.new 72 | 73 | hash = options.as_json 74 | 75 | expect(hash[:timeout]).to eq(120_000) 76 | expect(hash[:allowCredentials]).to eq([]) 77 | expect(hash[:extensions]).to eq({}) 78 | expect(hash[:challenge]).to be_truthy 79 | expect(hash).not_to have_key(:userVerification) 80 | expect(hash).not_to have_key(:rpId) 81 | end 82 | 83 | it "accepts shorthand for allow_credentials" do 84 | options = WebAuthn::PublicKeyCredential::RequestOptions.new(allow: "id") 85 | 86 | expect(options.allow).to eq("id") 87 | expect(options.allow_credentials).to eq([{ type: "public-key", id: "id" }]) 88 | expect(options.as_json[:allowCredentials]).to eq([{ type: "public-key", id: "id" }]) 89 | end 90 | 91 | context "when legacy_u2f_appid" do 92 | context "is set in the configuration" do 93 | before do 94 | WebAuthn.configuration.legacy_u2f_appid = "https://u2f-login.example.com" 95 | end 96 | 97 | context "and appid extension is not requested in the options" do 98 | it "automatically adds it with the value in the configuration" do 99 | expect(request_options.extensions).not_to be_empty 100 | expect(request_options.extensions[:appid]).to eq("https://u2f-login.example.com") 101 | end 102 | end 103 | 104 | context "and appid extension is requested in the options" do 105 | let(:request_options) do 106 | WebAuthn::PublicKeyCredential::RequestOptions.new( 107 | extensions: { appid: "https://another-login.example.com" } 108 | ) 109 | end 110 | 111 | it "leaves the value that was originally requested" do 112 | expect(request_options.extensions).not_to be_empty 113 | expect(request_options.extensions[:appid]).to eq("https://another-login.example.com") 114 | end 115 | end 116 | end 117 | 118 | context "is not set in the configuration" do 119 | context "and appid extension is not requested in the options" do 120 | it "does not adds it automatically" do 121 | expect(request_options.extensions).to be_empty 122 | end 123 | end 124 | 125 | context "and appid extension is requested in the options" do 126 | let(:request_options) do 127 | WebAuthn::PublicKeyCredential::RequestOptions.new( 128 | extensions: { appid: "https://another-login.example.com" } 129 | ) 130 | end 131 | 132 | it "leaves the value that was originally requested" do 133 | expect(request_options.extensions).not_to be_empty 134 | expect(request_options.extensions[:appid]).to eq("https://another-login.example.com") 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/webauthn/public_key_credential_with_attestation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "securerandom" 6 | require "webauthn/authenticator_attestation_response" 7 | require "webauthn/configuration" 8 | require "webauthn/public_key_credential_with_attestation" 9 | 10 | RSpec.describe "PublicKeyCredentialWithAttestation" do 11 | describe "#verify" do 12 | let(:public_key_credential) do 13 | WebAuthn::PublicKeyCredentialWithAttestation.new( 14 | type: type, 15 | id: id, 16 | raw_id: raw_id, 17 | authenticator_attachment: authenticator_attachment, 18 | response: attestation_response 19 | ) 20 | end 21 | 22 | let(:type) { "public-key" } 23 | let(:id) { WebAuthn::Encoders::Base64UrlEncoder.encode(raw_id) } 24 | let(:raw_id) { SecureRandom.random_bytes(16) } 25 | let(:authenticator_attachment) { 'platform' } 26 | 27 | let(:attestation_response) do 28 | response = client.create(challenge: raw_challenge)["response"] 29 | 30 | WebAuthn::AuthenticatorAttestationResponse.new( 31 | attestation_object: response["attestationObject"], 32 | client_data_json: response["clientDataJSON"] 33 | ) 34 | end 35 | 36 | let(:client) { WebAuthn::FakeClient.new(origin, encoding: false) } 37 | let(:challenge) { WebAuthn::Encoders::Base64UrlEncoder.encode(raw_challenge) } 38 | let(:raw_challenge) { fake_challenge } 39 | let(:origin) { fake_origin } 40 | 41 | before do 42 | WebAuthn.configuration.allowed_origins = [origin] 43 | end 44 | 45 | it "works" do 46 | expect(public_key_credential.verify(challenge)).to be_truthy 47 | 48 | expect(public_key_credential.id).not_to be_empty 49 | expect(public_key_credential.public_key).not_to be_empty 50 | expect(public_key_credential.sign_count).to eq(0) 51 | end 52 | 53 | context "when type is invalid" do 54 | context "because it is missing" do 55 | let(:type) { nil } 56 | 57 | it "fails" do 58 | expect { public_key_credential.verify(challenge) }.to raise_error(RuntimeError) 59 | end 60 | end 61 | 62 | context "because it is something else" do 63 | let(:type) { "password" } 64 | 65 | it "fails" do 66 | expect { public_key_credential.verify(challenge) }.to raise_error(RuntimeError) 67 | end 68 | end 69 | end 70 | 71 | context "when id is invalid" do 72 | context "because it is missing" do 73 | let(:id) { nil } 74 | 75 | it "fails" do 76 | expect { public_key_credential.verify(challenge) }.to raise_error(RuntimeError) 77 | end 78 | end 79 | 80 | context "because it is not the base64url of raw id" do 81 | let(:id) { WebAuthn::Encoders::Base64UrlEncoder.encode(raw_id + "a") } 82 | 83 | it "fails" do 84 | expect { public_key_credential.verify(challenge) }.to raise_error(RuntimeError) 85 | end 86 | end 87 | end 88 | 89 | context "when challenge class is invalid" do 90 | it "raise error" do 91 | expect { 92 | public_key_credential.verify(nil) 93 | }.to raise_error(WebAuthn::PublicKeyCredential::InvalidChallengeError) 94 | end 95 | end 96 | 97 | context "when challenge value is invalid" do 98 | it "fails" do 99 | expect { 100 | public_key_credential.verify(WebAuthn::Encoders::Base64UrlEncoder.encode("another challenge")) 101 | }.to raise_error(WebAuthn::ChallengeVerificationError) 102 | end 103 | end 104 | 105 | context "when clientExtensionResults" do 106 | context "are not received" do 107 | let(:public_key_credential) do 108 | WebAuthn::PublicKeyCredentialWithAttestation.new( 109 | type: type, 110 | id: id, 111 | raw_id: raw_id, 112 | client_extension_outputs: nil, 113 | response: attestation_response 114 | ) 115 | end 116 | 117 | it "works" do 118 | expect(public_key_credential.verify(challenge)).to be_truthy 119 | 120 | expect(public_key_credential.client_extension_outputs).to be_nil 121 | end 122 | end 123 | 124 | context "are received" do 125 | let(:public_key_credential) do 126 | WebAuthn::PublicKeyCredentialWithAttestation.new( 127 | type: type, 128 | id: id, 129 | raw_id: raw_id, 130 | client_extension_outputs: { "appid" => "true" }, 131 | response: attestation_response 132 | ) 133 | end 134 | 135 | it "works" do 136 | expect(public_key_credential.verify(challenge)).to be_truthy 137 | 138 | expect(public_key_credential.client_extension_outputs).to eq({ "appid" => "true" }) 139 | end 140 | end 141 | end 142 | 143 | context "when authentication extension input" do 144 | context "is not received" do 145 | let(:attestation_response) do 146 | response = client.create(challenge: raw_challenge, extensions: nil)["response"] 147 | 148 | WebAuthn::AuthenticatorAttestationResponse.new( 149 | attestation_object: response["attestationObject"], 150 | client_data_json: response["clientDataJSON"] 151 | ) 152 | end 153 | 154 | it "works" do 155 | expect(public_key_credential.verify(challenge)).to be_truthy 156 | 157 | expect(public_key_credential.authenticator_extension_outputs).to be_nil 158 | end 159 | end 160 | 161 | context "is received" do 162 | let(:attestation_response) do 163 | response = client.create( 164 | challenge: raw_challenge, 165 | extensions: { "txAuthSimple" => "Could you please verify yourself?" } 166 | )["response"] 167 | 168 | WebAuthn::AuthenticatorAttestationResponse.new( 169 | attestation_object: response["attestationObject"], 170 | client_data_json: response["clientDataJSON"] 171 | ) 172 | end 173 | 174 | it "works" do 175 | expect(public_key_credential.verify(challenge)).to be_truthy 176 | 177 | expect(public_key_credential.authenticator_extension_outputs) 178 | .to eq({ "txAuthSimple" => "Could you please verify yourself?" }) 179 | end 180 | end 181 | end 182 | 183 | context "when user_presence" do 184 | context "is not set" do 185 | it "correcly delegates its value to the response" do 186 | expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: nil)) 187 | 188 | public_key_credential.verify(challenge) 189 | end 190 | end 191 | 192 | context "is set to false" do 193 | it "correcly delegates its value to the response" do 194 | expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: false)) 195 | 196 | public_key_credential.verify(challenge, user_presence: false) 197 | end 198 | end 199 | 200 | context "is set to true" do 201 | it "correcly delegates its value to the response" do 202 | expect(attestation_response).to receive(:verify).with(anything, hash_including(user_presence: true)) 203 | 204 | public_key_credential.verify(challenge, user_presence: true) 205 | end 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/webauthn/public_key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "webauthn/public_key" 6 | require "support/seeds" 7 | require "cose" 8 | require "openssl" 9 | 10 | RSpec.describe "PublicKey" do 11 | let(:uncompressed_point_public_key) do 12 | WebAuthn::Encoders::Base64Encoder.decode(seeds[:u2f_migration][:stored_credential][:public_key]) 13 | end 14 | let(:cose_public_key) do 15 | WebAuthn::Encoders::Base64UrlEncoder.decode( 16 | "pQECAyYgASFYIPJKd_-Rl0QtQwbLggjGC_EbUFIMriCkdc2yuaukkBuNIlggaBsBjCwnMzFL7OUGJNm4b-HVpFNUa_NbsHGARuYKHfU" 17 | ) 18 | end 19 | let(:webauthn_public_key) { WebAuthn::PublicKey.deserialize(public_key) } 20 | 21 | describe ".deserialize" do 22 | context "when invalid public key" do 23 | let(:public_key) { 'invalidinvalid' } 24 | 25 | it "should fail" do 26 | expect { webauthn_public_key }.to raise_error(COSE::MalformedKeyError) 27 | end 28 | end 29 | end 30 | 31 | describe "#pkey" do 32 | let(:pkey) { webauthn_public_key.pkey } 33 | 34 | context "when public key stored in uncompressed point format" do 35 | let(:public_key) { uncompressed_point_public_key } 36 | 37 | it "should return ssl pkey" do 38 | expect(pkey).to be_instance_of(OpenSSL::PKey::EC) 39 | end 40 | end 41 | 42 | context "when public key stored in cose format" do 43 | let(:public_key) { cose_public_key } 44 | 45 | it "should return ssl pkey" do 46 | expect(pkey).to be_instance_of(OpenSSL::PKey::EC) 47 | end 48 | end 49 | end 50 | 51 | describe "#cose_key" do 52 | let(:cose_key) { webauthn_public_key.cose_key } 53 | 54 | context "when public key stored in uncompressed point format" do 55 | let(:public_key) { uncompressed_point_public_key } 56 | 57 | it "should return EC2 cose key" do 58 | expect(cose_key).to be_instance_of(COSE::Key::EC2) 59 | end 60 | end 61 | 62 | context "when public key stored in cose format" do 63 | let(:public_key) { cose_public_key } 64 | 65 | it "should return cose key" do 66 | expect(cose_key).to be_a(COSE::Key::Base) 67 | end 68 | end 69 | end 70 | 71 | describe "#alg" do 72 | let(:alg) { webauthn_public_key.alg } 73 | 74 | context "when public key stored in uncompressed point format" do 75 | let(:public_key) { uncompressed_point_public_key } 76 | 77 | it "should return ES256 cose algorithm id" do 78 | expect(alg).to eq(COSE::Algorithm.by_name("ES256").id) 79 | end 80 | end 81 | 82 | context "when public key stored in cose format" do 83 | let(:public_key) { cose_public_key } 84 | 85 | it "should return cose algorithm id" do 86 | expect(alg).to be_a(Integer) 87 | end 88 | end 89 | end 90 | 91 | describe "#verify" do 92 | context "when public key stored in uncompressed point format" do 93 | let(:public_key) { uncompressed_point_public_key } 94 | 95 | context "when signature was signed with public key" do 96 | let(:signature) do 97 | WebAuthn::Encoders::Base64Encoder.decode(seeds[:u2f_migration][:assertion][:response][:signature]) 98 | end 99 | let(:authenticator_data) do 100 | WebAuthn::Encoders::Base64Encoder.decode(seeds[:u2f_migration][:assertion][:response][:authenticator_data]) 101 | end 102 | let(:client_data_hash) do 103 | WebAuthn::ClientData.new( 104 | WebAuthn::Encoders::Base64Encoder.decode(seeds[:u2f_migration][:assertion][:response][:client_data_json]) 105 | ).hash 106 | end 107 | let(:verification_data) { authenticator_data + client_data_hash } 108 | 109 | it "should verify" do 110 | expect( 111 | webauthn_public_key.verify(signature, verification_data) 112 | ).to be_truthy 113 | end 114 | end 115 | end 116 | 117 | context "when public key stored in cose format" do 118 | let(:signature) { key.sign(hash_algorithm, to_be_signed) } 119 | let(:to_be_signed) { "data" } 120 | let(:hash_algorithm) do 121 | COSE::Algorithm.find("ES256").hash_function 122 | end 123 | let(:cose_key) do 124 | cose_key = COSE::Key::EC2.from_pkey(key.public_key) 125 | cose_key.alg = -7 126 | 127 | cose_key 128 | end 129 | let(:key) { OpenSSL::PKey::EC.generate("prime256v1") } 130 | let(:webauthn_public_key) { WebAuthn::PublicKey.new(cose_key: cose_key) } 131 | 132 | it "works" do 133 | expect(webauthn_public_key.verify(signature, to_be_signed)).to be_truthy 134 | end 135 | 136 | context "when it was signed using a different hash algorithm" do 137 | let(:hash_algorithm) { "SHA1" } 138 | 139 | it "fails" do 140 | expect(webauthn_public_key.verify(signature, to_be_signed)).to be_falsy 141 | end 142 | end 143 | 144 | context "when it was signed with a different key" do 145 | let(:signature) do 146 | OpenSSL::PKey::EC.generate("prime256v1").sign( 147 | hash_algorithm, 148 | to_be_signed 149 | ) 150 | end 151 | 152 | it "fails" do 153 | expect(webauthn_public_key.verify(signature, to_be_signed)).to be_falsy 154 | end 155 | end 156 | 157 | context "when it was signed over different data" do 158 | let(:signature) { key.sign(hash_algorithm, "different data") } 159 | 160 | it "fails" do 161 | expect(webauthn_public_key.verify(signature, to_be_signed)).to be_falsy 162 | end 163 | end 164 | 165 | context "when public key algorithm is not in COSE" do 166 | let(:cose_key) do 167 | cose_key = COSE::Key::EC2.from_pkey(key.public_key) 168 | cose_key.alg = -1 169 | 170 | cose_key 171 | end 172 | 173 | it "fails" do 174 | expect { webauthn_public_key.verify(signature, to_be_signed) }.to( 175 | raise_error( 176 | WebAuthn::PublicKey::UnsupportedAlgorithm, 177 | "The public key algorithm -1 is not among the available COSE algorithms" 178 | ) 179 | ) 180 | end 181 | end 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/webauthn/u2f_migrator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/seeds" 5 | require "byebug" 6 | 7 | require "webauthn/u2f_migrator" 8 | 9 | RSpec.describe WebAuthn::U2fMigrator do 10 | subject(:u2f_migrator) do 11 | described_class.new( 12 | app_id: app_id, 13 | certificate: stored_credential[:certificate], 14 | key_handle: stored_credential[:key_handle], 15 | public_key: stored_credential[:public_key], 16 | counter: 41 17 | ) 18 | end 19 | 20 | let(:stored_credential) { seeds[:u2f_migration][:stored_credential] } 21 | let(:app_id) { URI("https://f69df4d9.ngrok.io") } 22 | 23 | it "returns the credential ID" do 24 | expect(WebAuthn::Encoders::Base64Encoder.encode(u2f_migrator.credential.id)) 25 | .to eq("1a9tIwwYiYNdmfmxVaksOkxKapK2HtDNSsL4MssbCHILhkMzA0xZYk5IHmBljyblTQ/SnsQea+QEMzgTN2L1Mw==") 26 | end 27 | 28 | it "returns the credential public key in COSE format" do 29 | public_key = COSE::Key.deserialize(u2f_migrator.credential.public_key) 30 | 31 | expect(public_key.alg).to eq(-7) 32 | expect(public_key.crv).to eq(1) 33 | expect(public_key.x).to eq(WebAuthn::Encoders::Base64Encoder.decode("FtOd9t3mxj6sLFkNCLzv5qS9l52MipHznrsZ+uwtHQY=")) 34 | expect(public_key.y).to eq(WebAuthn::Encoders::Base64Encoder.decode("np4zBpD5zhdSq1wKPvhzEoKJvFuYel1cpdTCzpahrBA=")) 35 | end 36 | 37 | it "returns the signature counter" do 38 | expect(u2f_migrator.authenticator_data.sign_count).to eq(41) 39 | end 40 | 41 | it "returns the 'Basic or AttCA' attestation type" do 42 | expect(u2f_migrator.attestation_type).to eq("Basic_or_AttCA") 43 | end 44 | 45 | it "returns the attestation certificate" do 46 | certificate = u2f_migrator.attestation_trust_path.first 47 | 48 | expect(certificate.subject.to_s).to eq("/CN=U2F Device") 49 | expect(certificate.issuer.to_s).to eq("/CN=U2F Issuer") 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/webauthn_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WebAuthn do 6 | it "has a version number" do 7 | expect(WebAuthn::VERSION).not_to be nil 8 | end 9 | 10 | describe "#credential_creation_options" do 11 | before do 12 | @credential_creation_options = silence_warnings { WebAuthn.credential_creation_options } 13 | end 14 | 15 | it "has a 32 byte length challenge" do 16 | expect(@credential_creation_options[:challenge].length).to eq(32) 17 | end 18 | 19 | it "has public key params" do 20 | params = @credential_creation_options[:pubKeyCredParams] 21 | 22 | array = [ 23 | { type: "public-key", alg: -7 }, 24 | { type: "public-key", alg: -37 }, 25 | { type: "public-key", alg: -257 }, 26 | ] 27 | 28 | expect(params).to match_array(array) 29 | end 30 | 31 | it "has user info" do 32 | user_info = @credential_creation_options[:user] 33 | expect(user_info[:name]).to eq("web-user") 34 | expect(user_info[:displayName]).to eq("web-user") 35 | expect(user_info[:id]).to eq("1") 36 | end 37 | 38 | context "Relying Party info" do 39 | it "has relying party name default" do 40 | expect(@credential_creation_options[:rp][:name]).to eq("web-server") 41 | end 42 | 43 | context "when configured" do 44 | before do 45 | WebAuthn.configuration.rp_name = "Example Inc." 46 | end 47 | 48 | it "has the configured values" do 49 | creation_options = silence_warnings { WebAuthn.credential_creation_options } 50 | 51 | expect(creation_options[:rp][:name]).to eq("Example Inc.") 52 | end 53 | end 54 | end 55 | end 56 | 57 | describe "#credential_request_options" do 58 | let(:credential_request_options) { silence_warnings { WebAuthn.credential_request_options } } 59 | 60 | it "has a 32 byte length challenge" do 61 | expect(credential_request_options[:challenge].length).to eq(32) 62 | end 63 | 64 | it "has allowCredentials param with an empty array" do 65 | expect(credential_request_options[:allowCredentials]).to match_array([]) 66 | end 67 | end 68 | 69 | describe "#generate_user_id" do 70 | let(:user_id) { WebAuthn.generate_user_id } 71 | let(:encoder) { WebAuthn::Encoder.new(encoding) } 72 | 73 | before do 74 | WebAuthn.configuration.encoding = encoding 75 | end 76 | 77 | context "when encoding is base64url" do 78 | let(:encoding) { :base64url } 79 | 80 | it "is encoded" do 81 | expect(user_id.class).to eq(String) 82 | expect(user_id.encoding).not_to eq(Encoding::BINARY) 83 | end 84 | 85 | it "is 64 bytes long" do 86 | expect(encoder.decode(user_id).length).to eq(64) 87 | end 88 | end 89 | 90 | context "when encoding is base64" do 91 | let(:encoding) { :base64 } 92 | 93 | it "is encoded" do 94 | expect(user_id.class).to eq(String) 95 | expect(user_id.encoding).not_to eq(Encoding::BINARY) 96 | end 97 | 98 | it "is 64 bytes long" do 99 | expect(encoder.decode(user_id).length).to eq(64) 100 | end 101 | end 102 | 103 | context "when not encoding" do 104 | let(:encoding) { false } 105 | 106 | it "is not encoded" do 107 | expect(user_id.class).to eq(String) 108 | expect(user_id.encoding).to eq(Encoding::BINARY) 109 | end 110 | 111 | it "is 64 bytes long" do 112 | expect(user_id.length).to eq(64) 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /webauthn.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "webauthn/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "webauthn" 9 | spec.version = WebAuthn::VERSION 10 | spec.authors = ["Gonzalo Rodriguez", "Braulio Martinez"] 11 | spec.email = ["gonzalo@cedarcode.com", "braulio@cedarcode.com"] 12 | 13 | spec.summary = "WebAuthn ruby server library" 14 | spec.description = 'WebAuthn ruby server library ― Make your application a W3C Web Authentication conformant 15 | Relying Party and allow your users to authenticate with U2F and FIDO2 authenticators.' 16 | spec.homepage = "https://github.com/cedarcode/webauthn-ruby" 17 | spec.license = "MIT" 18 | 19 | spec.metadata = { 20 | "bug_tracker_uri" => "https://github.com/cedarcode/webauthn-ruby/issues", 21 | "changelog_uri" => "https://github.com/cedarcode/webauthn-ruby/blob/master/CHANGELOG.md", 22 | "source_code_uri" => "https://github.com/cedarcode/webauthn-ruby" 23 | } 24 | 25 | spec.files = 26 | `git ls-files -z`.split("\x0").reject do |f| 27 | f.match(%r{^(test|spec|features|assets)/}) 28 | end 29 | 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | spec.required_ruby_version = ">= 2.5" 35 | 36 | spec.add_dependency "android_key_attestation", "~> 0.3.0" 37 | spec.add_dependency "bindata", "~> 2.4" 38 | spec.add_dependency "cbor", "~> 0.5.9" 39 | spec.add_dependency "cose", "~> 1.1" 40 | spec.add_dependency "openssl", ">= 2.2" 41 | spec.add_dependency "safety_net_attestation", "~> 0.4.0" 42 | spec.add_dependency "tpm-key_attestation", "~> 0.14.0" 43 | 44 | spec.add_development_dependency "bundler", ">= 1.17", "< 3.0" 45 | spec.add_development_dependency "byebug", "~> 11.0" 46 | spec.add_development_dependency "rake", "~> 13.0" 47 | spec.add_development_dependency "rspec", "~> 3.8" 48 | spec.add_development_dependency "rubocop", "~> 1" 49 | spec.add_development_dependency "rubocop-rake", "~> 0.5" 50 | spec.add_development_dependency "rubocop-rspec", ">= 2.2", "< 4.0" 51 | end 52 | --------------------------------------------------------------------------------