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,23 @@
|
|
|
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
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn/attestation_statement/apple"
|
|
4
|
+
require "webauthn/attestation_statement/fido_u2f"
|
|
5
|
+
require "webauthn/attestation_statement/none"
|
|
6
|
+
require "webauthn/attestation_statement/packed"
|
|
7
|
+
require "webauthn/error"
|
|
8
|
+
|
|
9
|
+
module WebAuthn
|
|
10
|
+
module AttestationStatement
|
|
11
|
+
class FormatNotSupportedError < Error; end
|
|
12
|
+
|
|
13
|
+
ATTESTATION_FORMAT_NONE = "none"
|
|
14
|
+
ATTESTATION_FORMAT_FIDO_U2F = "fido-u2f"
|
|
15
|
+
ATTESTATION_FORMAT_PACKED = 'packed'
|
|
16
|
+
ATTESTATION_FORMAT_APPLE = "apple"
|
|
17
|
+
|
|
18
|
+
FORMAT_TO_CLASS = {
|
|
19
|
+
ATTESTATION_FORMAT_NONE => WebAuthn::AttestationStatement::None,
|
|
20
|
+
ATTESTATION_FORMAT_FIDO_U2F => WebAuthn::AttestationStatement::FidoU2f,
|
|
21
|
+
ATTESTATION_FORMAT_PACKED => WebAuthn::AttestationStatement::Packed,
|
|
22
|
+
ATTESTATION_FORMAT_APPLE => WebAuthn::AttestationStatement::Apple
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def self.from(format, statement, relying_party: WebAuthn.configuration.relying_party)
|
|
26
|
+
klass = FORMAT_TO_CLASS[format]
|
|
27
|
+
|
|
28
|
+
if klass
|
|
29
|
+
klass.new(statement, relying_party)
|
|
30
|
+
else
|
|
31
|
+
raise(FormatNotSupportedError, "Unsupported attestation format '#{format}'")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn/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
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn/cbor"
|
|
4
|
+
require "webauthn/cose"
|
|
5
|
+
require "webauthn/error"
|
|
6
|
+
|
|
7
|
+
module WebAuthn
|
|
8
|
+
class AttestedCredentialDataFormatError < WebAuthn::Error; end
|
|
9
|
+
|
|
10
|
+
class AuthenticatorData
|
|
11
|
+
class AttestedCredentialData
|
|
12
|
+
AAGUID_LENGTH = 16
|
|
13
|
+
ZEROED_AAGUID = 0.chr * AAGUID_LENGTH
|
|
14
|
+
|
|
15
|
+
ID_LENGTH_LENGTH = 2
|
|
16
|
+
|
|
17
|
+
attr_reader :raw_aaguid, :id_length, :id, :trailing_bytes
|
|
18
|
+
|
|
19
|
+
Credential =
|
|
20
|
+
Struct.new(:id, :public_key, :algorithm, keyword_init: true) do
|
|
21
|
+
def public_key_object
|
|
22
|
+
COSE::Key.deserialize(public_key).to_pkey
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.deserialize(data)
|
|
27
|
+
new(data)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(data)
|
|
31
|
+
header_length = AAGUID_LENGTH + ID_LENGTH_LENGTH
|
|
32
|
+
unless data.is_a?(String) && data.bytesize >= header_length
|
|
33
|
+
raise AttestedCredentialDataFormatError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
data = data.b
|
|
37
|
+
@raw_aaguid = data.byteslice(0, AAGUID_LENGTH)
|
|
38
|
+
@id_length = data.byteslice(AAGUID_LENGTH, ID_LENGTH_LENGTH).unpack1("n")
|
|
39
|
+
if data.bytesize < header_length + id_length
|
|
40
|
+
raise AttestedCredentialDataFormatError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@id = data.byteslice(header_length, id_length)
|
|
44
|
+
@trailing_bytes = data.byteslice(header_length + id_length..-1)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def valid?
|
|
48
|
+
valid_credential_public_key?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def aaguid
|
|
52
|
+
raw_aaguid.unpack("H8H4H4H4H12").join("-")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def credential
|
|
56
|
+
@credential ||=
|
|
57
|
+
if valid?
|
|
58
|
+
Credential.new(id: id, public_key: public_key, algorithm: algorithm)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def length
|
|
63
|
+
if valid?
|
|
64
|
+
AAGUID_LENGTH + ID_LENGTH_LENGTH + id_length + public_key_length
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def algorithm
|
|
71
|
+
COSE::Algorithm.find(cose_key.alg)&.name
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_credential_public_key?
|
|
75
|
+
!!cose_key.alg
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cose_key
|
|
79
|
+
@cose_key ||= COSE::Key.deserialize(public_key)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def public_key
|
|
83
|
+
trailing_bytes[0..public_key_length - 1]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def public_key_length
|
|
87
|
+
@public_key_length ||=
|
|
88
|
+
CBOR.decode_first(trailing_bytes).last
|
|
89
|
+
rescue CBOR::MalformedData
|
|
90
|
+
raise AttestedCredentialDataFormatError
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn/cbor"
|
|
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
|
|
11
|
+
RP_ID_HASH_LENGTH = 32
|
|
12
|
+
FLAGS_LENGTH = 1
|
|
13
|
+
SIGN_COUNT_LENGTH = 4
|
|
14
|
+
|
|
15
|
+
attr_reader :data, :rp_id_hash, :flags, :sign_count, :trailing_bytes
|
|
16
|
+
|
|
17
|
+
def self.deserialize(data)
|
|
18
|
+
new(data)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(data)
|
|
22
|
+
unless data.is_a?(String) && data.bytesize >= base_length
|
|
23
|
+
raise AuthenticatorDataFormatError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@data = data.b.freeze
|
|
27
|
+
@rp_id_hash = @data.byteslice(0, RP_ID_HASH_LENGTH)
|
|
28
|
+
@flags = @data.getbyte(RP_ID_HASH_LENGTH)
|
|
29
|
+
@sign_count = @data.byteslice(RP_ID_HASH_LENGTH + FLAGS_LENGTH, SIGN_COUNT_LENGTH).unpack1("N")
|
|
30
|
+
@trailing_bytes = @data.byteslice(base_length..-1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def valid?
|
|
34
|
+
(!attested_credential_data_included? || attested_credential_data.valid?) &&
|
|
35
|
+
(!extension_data_included? || extension_data) &&
|
|
36
|
+
valid_length?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def user_flagged?
|
|
40
|
+
user_present? || user_verified?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def user_present?
|
|
44
|
+
(flags & 0x1) != 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def user_verified?
|
|
48
|
+
(flags & 0x4) != 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def credential_backup_eligible?
|
|
52
|
+
(flags & 0x8) != 0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def credential_backed_up?
|
|
56
|
+
(flags & 0x10) != 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def attested_credential_data_included?
|
|
60
|
+
(flags & 0x40) != 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extension_data_included?
|
|
64
|
+
(flags & 0x80) != 0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def credential
|
|
68
|
+
if attested_credential_data_included?
|
|
69
|
+
attested_credential_data.credential
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attested_credential_data
|
|
74
|
+
@attested_credential_data ||=
|
|
75
|
+
AttestedCredentialData.deserialize(trailing_bytes)
|
|
76
|
+
rescue AttestedCredentialDataFormatError
|
|
77
|
+
raise AuthenticatorDataFormatError
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extension_data
|
|
81
|
+
@extension_data ||= CBOR.decode(raw_extension_data)
|
|
82
|
+
rescue CBOR::MalformedData
|
|
83
|
+
raise AuthenticatorDataFormatError
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def aaguid
|
|
87
|
+
raw_aaguid = attested_credential_data.raw_aaguid
|
|
88
|
+
|
|
89
|
+
unless raw_aaguid == WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID
|
|
90
|
+
attested_credential_data.aaguid
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def valid_length?
|
|
97
|
+
data.bytesize == base_length + attested_credential_data_length + extension_data_length
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def raw_extension_data
|
|
101
|
+
if extension_data_included?
|
|
102
|
+
if attested_credential_data_included?
|
|
103
|
+
trailing_bytes[attested_credential_data.length..-1]
|
|
104
|
+
else
|
|
105
|
+
trailing_bytes
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def attested_credential_data_length
|
|
111
|
+
if attested_credential_data_included?
|
|
112
|
+
attested_credential_data.length
|
|
113
|
+
else
|
|
114
|
+
0
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extension_data_length
|
|
119
|
+
if extension_data_included?
|
|
120
|
+
raw_extension_data.length
|
|
121
|
+
else
|
|
122
|
+
0
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def base_length
|
|
127
|
+
RP_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
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 TopOriginVerificationError < VerificationError; end
|
|
18
|
+
class TypeVerificationError < VerificationError; end
|
|
19
|
+
class UserPresenceVerificationError < VerificationError; end
|
|
20
|
+
class UserVerifiedVerificationError < VerificationError; end
|
|
21
|
+
|
|
22
|
+
class AuthenticatorResponse
|
|
23
|
+
def initialize(client_data_json:, relying_party: WebAuthn.configuration.relying_party)
|
|
24
|
+
@client_data_json = client_data_json
|
|
25
|
+
@relying_party = relying_party
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_verification: nil, rp_id: nil)
|
|
29
|
+
expected_origin ||= relying_party.allowed_origins || raise("Unspecified expected origin")
|
|
30
|
+
|
|
31
|
+
rp_id ||= relying_party.id
|
|
32
|
+
|
|
33
|
+
verify_item(:type)
|
|
34
|
+
verify_item(:token_binding)
|
|
35
|
+
verify_item(:challenge, expected_challenge)
|
|
36
|
+
verify_item(:origin, expected_origin)
|
|
37
|
+
verify_item(:top_origin) if needs_top_origin_verification?
|
|
38
|
+
verify_item(:authenticator_data)
|
|
39
|
+
|
|
40
|
+
verify_item(
|
|
41
|
+
:rp_id,
|
|
42
|
+
rp_id || rp_id_from_origin(expected_origin)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Fallback to RP configuration unless user_presence is passed in explicitely
|
|
46
|
+
if user_presence.nil? && !relying_party.silent_authentication || user_presence
|
|
47
|
+
verify_item(:user_presence)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if user_verification
|
|
51
|
+
verify_item(:user_verified)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def valid?(*args, **keyword_arguments)
|
|
58
|
+
verify(*args, **keyword_arguments)
|
|
59
|
+
rescue WebAuthn::VerificationError
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def client_data
|
|
64
|
+
@client_data ||= WebAuthn::ClientData.new(client_data_json)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
attr_reader :client_data_json, :relying_party
|
|
70
|
+
|
|
71
|
+
def verify_item(item, *args)
|
|
72
|
+
if send("valid_#{item}?", *args)
|
|
73
|
+
true
|
|
74
|
+
else
|
|
75
|
+
camelized_item = item.to_s.split('_').map { |w| w.capitalize }.join
|
|
76
|
+
error_const_name = "WebAuthn::#{camelized_item}VerificationError"
|
|
77
|
+
raise Object.const_get(error_const_name)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def valid_type?
|
|
82
|
+
client_data.type == type
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def valid_token_binding?
|
|
86
|
+
client_data.valid_token_binding_format?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def valid_top_origin?
|
|
90
|
+
return false unless client_data.cross_origin
|
|
91
|
+
|
|
92
|
+
relying_party.allowed_top_origins&.include?(client_data.top_origin)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def valid_challenge?(expected_challenge)
|
|
96
|
+
OpenSSL.secure_compare(client_data.challenge, expected_challenge)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def valid_origin?(expected_origin)
|
|
100
|
+
return false unless expected_origin
|
|
101
|
+
|
|
102
|
+
expected_origin.include?(client_data.origin)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def valid_rp_id?(rp_id)
|
|
106
|
+
return false unless rp_id
|
|
107
|
+
|
|
108
|
+
OpenSSL::Digest::SHA256.digest(rp_id) == authenticator_data.rp_id_hash
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def valid_authenticator_data?
|
|
112
|
+
authenticator_data.valid?
|
|
113
|
+
rescue WebAuthn::AuthenticatorDataFormatError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def valid_user_presence?
|
|
118
|
+
authenticator_data.user_flagged?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def valid_user_verified?
|
|
122
|
+
authenticator_data.user_verified?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def rp_id_from_origin(expected_origin)
|
|
126
|
+
URI.parse(expected_origin.first).host if expected_origin.size == 1
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def type
|
|
130
|
+
raise NotImplementedError, "Please define #type method in subclass"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def needs_top_origin_verification?
|
|
134
|
+
relying_party.verify_cross_origin && (client_data.cross_origin || client_data.top_origin)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|