webauthn-slim 3.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/FUNDING.yml +1 -0
- data/.github/actions/install-openssl/action.yml +35 -0
- data/.github/actions/install-ruby/action.yml +76 -0
- data/.github/dependabot.yml +6 -0
- data/.github/workflows/build.yml +49 -0
- data/.github/workflows/git.yml +21 -0
- data/.github/workflows/release.yml +28 -0
- data/.gitignore +16 -0
- data/.rspec +2 -0
- data/.rubocop.yml +306 -0
- data/CHANGELOG.md +462 -0
- data/CONTRIBUTING.md +39 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +479 -0
- data/Rakefile +10 -0
- data/SECURITY.md +21 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/docs/advanced_configuration.md +173 -0
- data/docs/u2f_migration.md +90 -0
- data/lib/webauthn/attestation_object.rb +47 -0
- data/lib/webauthn/attestation_statement/apple.rb +65 -0
- data/lib/webauthn/attestation_statement/base.rb +175 -0
- data/lib/webauthn/attestation_statement/fido_u2f/public_key.rb +44 -0
- data/lib/webauthn/attestation_statement/fido_u2f.rb +71 -0
- data/lib/webauthn/attestation_statement/none.rb +23 -0
- data/lib/webauthn/attestation_statement/packed.rb +72 -0
- data/lib/webauthn/attestation_statement.rb +35 -0
- data/lib/webauthn/authenticator_assertion_response.rb +87 -0
- data/lib/webauthn/authenticator_attestation_response.rb +84 -0
- data/lib/webauthn/authenticator_data/attested_credential_data.rb +94 -0
- data/lib/webauthn/authenticator_data.rb +130 -0
- data/lib/webauthn/authenticator_response.rb +137 -0
- data/lib/webauthn/cbor.rb +189 -0
- data/lib/webauthn/client_data.rb +67 -0
- data/lib/webauthn/configuration.rb +68 -0
- data/lib/webauthn/cose.rb +237 -0
- data/lib/webauthn/credential.rb +26 -0
- data/lib/webauthn/credential_creation_options.rb +97 -0
- data/lib/webauthn/credential_entity.rb +11 -0
- data/lib/webauthn/credential_options.rb +17 -0
- data/lib/webauthn/credential_request_options.rb +44 -0
- data/lib/webauthn/credential_rp_entity.rb +8 -0
- data/lib/webauthn/credential_user_entity.rb +16 -0
- data/lib/webauthn/encoder.rb +18 -0
- data/lib/webauthn/encoders.rb +62 -0
- data/lib/webauthn/error.rb +5 -0
- data/lib/webauthn/fake_authenticator/attestation_object.rb +90 -0
- data/lib/webauthn/fake_authenticator/authenticator_data.rb +156 -0
- data/lib/webauthn/fake_authenticator.rb +134 -0
- data/lib/webauthn/fake_client.rb +179 -0
- data/lib/webauthn/json_serializer.rb +45 -0
- data/lib/webauthn/public_key.rb +65 -0
- data/lib/webauthn/public_key_credential/creation_options.rb +93 -0
- data/lib/webauthn/public_key_credential/entity.rb +21 -0
- data/lib/webauthn/public_key_credential/options.rb +51 -0
- data/lib/webauthn/public_key_credential/request_options.rb +46 -0
- data/lib/webauthn/public_key_credential/rp_entity.rb +23 -0
- data/lib/webauthn/public_key_credential/user_entity.rb +24 -0
- data/lib/webauthn/public_key_credential.rb +94 -0
- data/lib/webauthn/public_key_credential_with_assertion.rb +49 -0
- data/lib/webauthn/public_key_credential_with_attestation.rb +30 -0
- data/lib/webauthn/relying_party.rb +150 -0
- data/lib/webauthn/u2f_migrator.rb +76 -0
- data/lib/webauthn/version.rb +5 -0
- data/lib/webauthn.rb +19 -0
- data/webauthn.gemspec +43 -0
- metadata +218 -0
|
@@ -0,0 +1,173 @@
|
|
|
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 segmentation, where your users register and authenticate to each of these tenants or segments individually 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 subdomain (e.g. your application serves www.example.com and admin.example.com but all your 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
|
+
Instead 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 your 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
|
+
# Multiple origins can be used when needed. Using more than one will imply you MUST configure rp_id explicitely. If you need your credentials to be bound to a single origin but you have more than one tenant, please see [our Advanced Configuration section](https://github.com/cedarcode/webauthn-ruby/blob/master/docs/advanced_configuration.md) instead of adding multiple origins.
|
|
25
|
+
allowed_origins: ["https://admin.example.com"],
|
|
26
|
+
|
|
27
|
+
# Relying Party name for display purposes
|
|
28
|
+
name: "Admin Site for Example Inc."
|
|
29
|
+
|
|
30
|
+
# Optionally configure a client timeout hint, in milliseconds.
|
|
31
|
+
# This hint specifies how long the browser should wait for any
|
|
32
|
+
# interaction with the user.
|
|
33
|
+
# This hint may be overridden by the browser.
|
|
34
|
+
# https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout
|
|
35
|
+
# credential_options_timeout: 120_000
|
|
36
|
+
|
|
37
|
+
# You can optionally specify a different Relying Party ID
|
|
38
|
+
# (https://www.w3.org/TR/webauthn/#relying-party-identifier)
|
|
39
|
+
# if it differs from the default one.
|
|
40
|
+
#
|
|
41
|
+
# In this case the default would be "admin.example.com", but you can set it to
|
|
42
|
+
# the suffix "example.com"
|
|
43
|
+
#
|
|
44
|
+
# id: "example.com"
|
|
45
|
+
|
|
46
|
+
# Configure preferred binary-to-text encoding scheme. This should match the encoding scheme
|
|
47
|
+
# used in your client-side (user agent) code before sending the credential to the server.
|
|
48
|
+
# Supported values: `:base64url` (default), `:base64` or `false` to disable all encoding.
|
|
49
|
+
#
|
|
50
|
+
# encoding: :base64url
|
|
51
|
+
|
|
52
|
+
# Possible values: "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "RS1"
|
|
53
|
+
# Default: ["ES256", "PS256", "RS256"]
|
|
54
|
+
#
|
|
55
|
+
# algorithms: ["ES384"]
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Instance Based API
|
|
60
|
+
|
|
61
|
+
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:
|
|
62
|
+
|
|
63
|
+
### Credential Registration
|
|
64
|
+
|
|
65
|
+
#### Initiation phase
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Generate and store the WebAuthn User ID the first time the user registers a credential
|
|
69
|
+
if !user.webauthn_id
|
|
70
|
+
user.update!(webauthn_id: WebAuthn.generate_user_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
options = relying_party.options_for_registration(
|
|
74
|
+
user: { id: user.webauthn_id, name: user.name },
|
|
75
|
+
exclude: user.credentials.map { |c| c.external_id }
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Store the newly generated challenge somewhere so you can have it
|
|
79
|
+
# for the verification phase.
|
|
80
|
+
session[:creation_challenge] = options.challenge
|
|
81
|
+
|
|
82
|
+
# Send `options` back to the browser, so that they can be used
|
|
83
|
+
# to call `navigator.credentials.create({ "publicKey": options })`
|
|
84
|
+
#
|
|
85
|
+
# You can call `options.as_json` to get a ruby hash with a JSON representation if needed.
|
|
86
|
+
|
|
87
|
+
# If inside a Rails controller, `render json: options` will just work.
|
|
88
|
+
# I.e. it will encode and convert the options to JSON automatically.
|
|
89
|
+
|
|
90
|
+
# For your frontend code, you might find @github/webauthn-json npm package useful.
|
|
91
|
+
# Especially for handling the necessary decoding of the options, and sending the
|
|
92
|
+
# `PublicKeyCredential` object back to the server.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
#### Verification phase
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Assuming you're using @github/webauthn-json package to send the `PublicKeyCredential` object back
|
|
99
|
+
# in params[:publicKeyCredential]:
|
|
100
|
+
begin
|
|
101
|
+
webauthn_credential = relying_party.verify_registration(
|
|
102
|
+
params[:publicKeyCredential],
|
|
103
|
+
session[:creation_challenge]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Store Credential ID, Credential Public Key and Sign Count for future authentications
|
|
107
|
+
user.credentials.create!(
|
|
108
|
+
external_id: webauthn_credential.id,
|
|
109
|
+
public_key: webauthn_credential.public_key,
|
|
110
|
+
sign_count: webauthn_credential.sign_count
|
|
111
|
+
)
|
|
112
|
+
rescue WebAuthn::Error => e
|
|
113
|
+
# Handle error
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Credential Authentication
|
|
118
|
+
|
|
119
|
+
#### Initiation phase
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
options = relying_party.options_for_authentication(allow: user.credentials.map { |c| c.webauthn_id })
|
|
123
|
+
|
|
124
|
+
# Store the newly generated challenge somewhere so you can have it
|
|
125
|
+
# for the verification phase.
|
|
126
|
+
session[:authentication_challenge] = options.challenge
|
|
127
|
+
|
|
128
|
+
# Send `options` back to the browser, so that they can be used
|
|
129
|
+
# to call `navigator.credentials.get({ "publicKey": options })`
|
|
130
|
+
|
|
131
|
+
# You can call `options.as_json` to get a ruby hash with a JSON representation if needed.
|
|
132
|
+
|
|
133
|
+
# If inside a Rails controller, `render json: options` will just work.
|
|
134
|
+
# I.e. it will encode and convert the options to JSON automatically.
|
|
135
|
+
|
|
136
|
+
# For your frontend code, you might find @github/webauthn-json npm package useful.
|
|
137
|
+
# Especially for handling the necessary decoding of the options, and sending the
|
|
138
|
+
# `PublicKeyCredential` object back to the server.
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### Verification phase
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
begin
|
|
145
|
+
# Assuming you're using @github/webauthn-json package to send the `PublicKeyCredential` object back
|
|
146
|
+
# in params[:publicKeyCredential]:
|
|
147
|
+
webauthn_credential, stored_credential = relying_party.verify_authentication(
|
|
148
|
+
params[:publicKeyCredential],
|
|
149
|
+
session[:authentication_challenge]
|
|
150
|
+
) do |webauthn_credential|
|
|
151
|
+
# the returned object needs to respond to #public_key and #sign_count
|
|
152
|
+
user.credentials.find_by(external_id: webauthn_credential.id)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Update the stored credential sign count with the value from `webauthn_credential.sign_count`
|
|
156
|
+
stored_credential.update!(sign_count: webauthn_credential.sign_count)
|
|
157
|
+
|
|
158
|
+
# Continue with successful sign in or 2FA verification...
|
|
159
|
+
|
|
160
|
+
rescue WebAuthn::SignCountVerificationError => e
|
|
161
|
+
# Cryptographic verification of the authenticator data succeeded, but the signature counter was less than or equal
|
|
162
|
+
# to the stored value. This can have several reasons and depending on your risk tolerance you can choose to fail or
|
|
163
|
+
# pass authentication. For more information see https://www.w3.org/TR/webauthn/#sign-counter
|
|
164
|
+
rescue WebAuthn::Error => e
|
|
165
|
+
# Handle error
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Moving from Global to Instance Based Configuration
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
The gem API described in the current [Usage](../README.md#usage) section for the [Global Configuration](../README.md#configuration) approach will still be valid but the [Instance Based API](#instance-based-api) also works with the global `relying_party` that is maintained globally at `WebAuthn.configuration.relying_party`.
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
# => #<U2F::Registration:0x00007fd62f43d688
|
|
22
|
+
# @certificate=
|
|
23
|
+
# "MIIBCzCBsgIBATAKBggqhkjOPQQDAjASMRAwDgYDVQQDDAdVMkZUZXN0MB4XDTE5MDUzMDE3MjIwM1oXDTIwMDUyOTE3MjIwM1owEjEQMA4GA1UEAwwHVTJGVGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBMKpejumzlH6NxWIx2Ol+EManS9oX5nguG4RT43rZNkyn/zGjdJEhXksN5zT34rLZgFheBgkDGJCdtTPlhVK10wCgYIKoZIzj0EAwIDSAAwRQIhALYxFcROCeifWpv5+wNZIiaO/bGQg8rFBHCw3aHgehdZAiBJ3xFmQh7+Gjxt6CeAcY/k/VVAYu2vP4sUqXnCQFgJUA==",
|
|
24
|
+
# @key_handle="mbVMRTgzST5xLumckGztJ9VFW6veObfNIYWSn3sTqIY",
|
|
25
|
+
# @public_key="BOJQXlFg+ZfZKm48FNq2Ye5vSwOscE1i7YsGRSIjIe3GI0OXrSBDADDn0dQlz2iDzZ7LvCwiHz72U1qhVas3vus=">
|
|
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.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn/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
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn/cose"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "webauthn/authenticator_data/attested_credential_data"
|
|
6
|
+
require "webauthn/error"
|
|
7
|
+
|
|
8
|
+
module WebAuthn
|
|
9
|
+
module AttestationStatement
|
|
10
|
+
class UnsupportedAlgorithm < Error; end
|
|
11
|
+
|
|
12
|
+
ATTESTATION_TYPE_NONE = "None"
|
|
13
|
+
ATTESTATION_TYPE_BASIC = "Basic"
|
|
14
|
+
ATTESTATION_TYPE_SELF = "Self"
|
|
15
|
+
ATTESTATION_TYPE_ATTCA = "AttCA"
|
|
16
|
+
ATTESTATION_TYPE_BASIC_OR_ATTCA = "Basic_or_AttCA"
|
|
17
|
+
ATTESTATION_TYPE_ANONCA = "AnonCA"
|
|
18
|
+
|
|
19
|
+
ATTESTATION_TYPES_WITH_ROOT = [
|
|
20
|
+
ATTESTATION_TYPE_BASIC,
|
|
21
|
+
ATTESTATION_TYPE_BASIC_OR_ATTCA,
|
|
22
|
+
ATTESTATION_TYPE_ATTCA,
|
|
23
|
+
ATTESTATION_TYPE_ANONCA
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
class Base
|
|
27
|
+
AAGUID_EXTENSION_OID = "1.3.6.1.4.1.45724.1.1.4"
|
|
28
|
+
|
|
29
|
+
def initialize(statement, relying_party = WebAuthn.configuration.relying_party)
|
|
30
|
+
@statement = statement
|
|
31
|
+
@relying_party = relying_party
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def valid?(_authenticator_data, _client_data_hash)
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format
|
|
39
|
+
WebAuthn::AttestationStatement::FORMAT_TO_CLASS.key(self.class)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def attestation_certificate
|
|
43
|
+
certificates&.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def attestation_certificate_key_id
|
|
47
|
+
attestation_certificate.subject_key_identifier&.unpack1("H*")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :statement, :relying_party
|
|
53
|
+
|
|
54
|
+
def matching_aaguid?(attested_credential_data_aaguid)
|
|
55
|
+
extension = attestation_certificate&.find_extension(AAGUID_EXTENSION_OID)
|
|
56
|
+
if extension
|
|
57
|
+
aaguid_value = OpenSSL::ASN1.decode(extension.value_der).value
|
|
58
|
+
aaguid_value == attested_credential_data_aaguid
|
|
59
|
+
else
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def matching_public_key?(authenticator_data)
|
|
65
|
+
attestation_certificate.public_key.to_der == authenticator_data.credential.public_key_object.to_der
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def certificates
|
|
69
|
+
@certificates ||=
|
|
70
|
+
raw_certificates&.map do |raw_certificate|
|
|
71
|
+
OpenSSL::X509::Certificate.new(raw_certificate)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def algorithm
|
|
76
|
+
statement["alg"]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def raw_certificates
|
|
80
|
+
statement["x5c"]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def signature
|
|
84
|
+
statement["sig"]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def attestation_trust_path
|
|
88
|
+
if certificates&.any?
|
|
89
|
+
certificates
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def trustworthy?(aaguid: nil, attestation_certificate_key_id: nil)
|
|
94
|
+
if ATTESTATION_TYPES_WITH_ROOT.include?(attestation_type)
|
|
95
|
+
relying_party.acceptable_attestation_types.include?(attestation_type) &&
|
|
96
|
+
valid_certificate_chain?(aaguid: aaguid, attestation_certificate_key_id: attestation_certificate_key_id)
|
|
97
|
+
else
|
|
98
|
+
relying_party.acceptable_attestation_types.include?(attestation_type)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def valid_certificate_chain?(aaguid: nil, attestation_certificate_key_id: nil)
|
|
103
|
+
root_certificates = root_certificates(
|
|
104
|
+
aaguid: aaguid,
|
|
105
|
+
attestation_certificate_key_id: attestation_certificate_key_id
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if certificates&.one? && root_certificates.include?(attestation_certificate)
|
|
109
|
+
return true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
attestation_root_certificates_store(
|
|
113
|
+
aaguid: aaguid,
|
|
114
|
+
attestation_certificate_key_id: attestation_certificate_key_id
|
|
115
|
+
).verify(attestation_certificate, attestation_trust_path)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def attestation_root_certificates_store(aaguid: nil, attestation_certificate_key_id: nil)
|
|
119
|
+
OpenSSL::X509::Store.new.tap do |store|
|
|
120
|
+
root_certificates(
|
|
121
|
+
aaguid: aaguid,
|
|
122
|
+
attestation_certificate_key_id: attestation_certificate_key_id
|
|
123
|
+
).each do |cert|
|
|
124
|
+
store.add_cert(cert)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def root_certificates(aaguid: nil, attestation_certificate_key_id: nil)
|
|
130
|
+
root_certificates =
|
|
131
|
+
relying_party.attestation_root_certificates_finders.reduce([]) do |certs, finder|
|
|
132
|
+
if certs.empty?
|
|
133
|
+
finder.find(
|
|
134
|
+
attestation_format: format,
|
|
135
|
+
aaguid: aaguid,
|
|
136
|
+
attestation_certificate_key_id: attestation_certificate_key_id
|
|
137
|
+
) || []
|
|
138
|
+
else
|
|
139
|
+
certs
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
if root_certificates.empty? && respond_to?(:default_root_certificates, true)
|
|
144
|
+
default_root_certificates
|
|
145
|
+
else
|
|
146
|
+
root_certificates
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def valid_signature?(authenticator_data, client_data_hash, public_key = attestation_certificate.public_key)
|
|
151
|
+
raise("Incompatible algorithm and key") unless cose_algorithm.compatible_key?(public_key)
|
|
152
|
+
|
|
153
|
+
cose_algorithm.verify(
|
|
154
|
+
public_key,
|
|
155
|
+
signature,
|
|
156
|
+
verification_data(authenticator_data, client_data_hash)
|
|
157
|
+
)
|
|
158
|
+
rescue COSE::Error
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def verification_data(authenticator_data, client_data_hash)
|
|
163
|
+
authenticator_data.data + client_data_hash
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cose_algorithm
|
|
167
|
+
@cose_algorithm ||=
|
|
168
|
+
COSE::Algorithm.find(algorithm).tap do |alg|
|
|
169
|
+
alg && relying_party.algorithms.include?(alg.name) ||
|
|
170
|
+
raise(UnsupportedAlgorithm, "Unsupported algorithm #{algorithm}")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn/cose"
|
|
4
|
+
require "webauthn/attestation_statement/base"
|
|
5
|
+
|
|
6
|
+
module WebAuthn
|
|
7
|
+
module AttestationStatement
|
|
8
|
+
class FidoU2f < Base
|
|
9
|
+
class PublicKey
|
|
10
|
+
COORDINATE_LENGTH = 32
|
|
11
|
+
UNCOMPRESSED_FORM_INDICATOR = "\x04"
|
|
12
|
+
|
|
13
|
+
def self.uncompressed_point?(data)
|
|
14
|
+
data.size &&
|
|
15
|
+
data.length == UNCOMPRESSED_FORM_INDICATOR.length + COORDINATE_LENGTH * 2 &&
|
|
16
|
+
data[0] == UNCOMPRESSED_FORM_INDICATOR
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(data)
|
|
20
|
+
@data = data
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def valid?
|
|
24
|
+
data.size >= COORDINATE_LENGTH * 2 &&
|
|
25
|
+
cose_key.x.length == COORDINATE_LENGTH &&
|
|
26
|
+
cose_key.y.length == COORDINATE_LENGTH &&
|
|
27
|
+
cose_key.alg == COSE::Algorithm.by_name("ES256").id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_uncompressed_point
|
|
31
|
+
UNCOMPRESSED_FORM_INDICATOR + cose_key.x + cose_key.y
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :data
|
|
37
|
+
|
|
38
|
+
def cose_key
|
|
39
|
+
@cose_key ||= COSE::Key::EC2.deserialize(data)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn/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
|