webauthn 1.18.0 → 2.0.0.beta1

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -0
  3. data/.travis.yml +7 -3
  4. data/Appraisals +8 -0
  5. data/CHANGELOG.md +52 -0
  6. data/README.md +88 -80
  7. data/SECURITY.md +18 -0
  8. data/gemfiles/cose_head.gemfile +7 -0
  9. data/gemfiles/openssl_head.gemfile +7 -0
  10. data/lib/webauthn.rb +9 -1
  11. data/lib/webauthn/attestation_statement/android_safetynet.rb +4 -4
  12. data/lib/webauthn/attestation_statement/base.rb +4 -4
  13. data/lib/webauthn/attestation_statement/fido_u2f.rb +1 -2
  14. data/lib/webauthn/authenticator_assertion_response.rb +33 -35
  15. data/lib/webauthn/authenticator_attestation_response.rb +30 -0
  16. data/lib/webauthn/authenticator_data.rb +3 -1
  17. data/lib/webauthn/authenticator_data/attested_credential_data.rb +1 -0
  18. data/lib/webauthn/authenticator_response.rb +1 -2
  19. data/lib/webauthn/client_data.rb +2 -1
  20. data/lib/webauthn/configuration.rb +9 -0
  21. data/lib/webauthn/credential.rb +26 -0
  22. data/lib/webauthn/credential_creation_options.rb +5 -1
  23. data/lib/webauthn/credential_request_options.rb +5 -0
  24. data/lib/webauthn/encoder.rb +8 -1
  25. data/lib/webauthn/fake_authenticator.rb +1 -0
  26. data/lib/webauthn/fake_client.rb +26 -22
  27. data/lib/webauthn/public_key_credential.rb +10 -50
  28. data/lib/webauthn/public_key_credential/creation_options.rb +92 -0
  29. data/lib/webauthn/public_key_credential/entity.rb +44 -0
  30. data/lib/webauthn/public_key_credential/options.rb +72 -0
  31. data/lib/webauthn/public_key_credential/request_options.rb +36 -0
  32. data/lib/webauthn/public_key_credential/rp_entity.rb +23 -0
  33. data/lib/webauthn/public_key_credential/user_entity.rb +24 -0
  34. data/lib/webauthn/public_key_credential_with_assertion.rb +35 -0
  35. data/lib/webauthn/public_key_credential_with_attestation.rb +30 -0
  36. data/lib/webauthn/u2f_migrator.rb +1 -1
  37. data/lib/webauthn/version.rb +1 -1
  38. data/webauthn.gemspec +3 -2
  39. metadata +33 -8
  40. data/webauthn-ruby.png +0 -0
@@ -10,7 +10,6 @@ module WebAuthn
10
10
  class FidoU2f < Base
11
11
  VALID_ATTESTATION_CERTIFICATE_COUNT = 1
12
12
  VALID_ATTESTATION_CERTIFICATE_ALGORITHM = COSE::Algorithm.by_name("ES256")
13
- VALID_ATTESTED_AAGUID = 0.chr * WebAuthn::AuthenticatorData::AttestedCredentialData::AAGUID_LENGTH
14
13
 
15
14
  def valid?(authenticator_data, client_data_hash)
16
15
  valid_format? &&
@@ -43,7 +42,7 @@ module WebAuthn
43
42
  end
44
43
 
45
44
  def valid_aaguid?(attested_credential_data_aaguid)
46
- attested_credential_data_aaguid == VALID_ATTESTED_AAGUID
45
+ attested_credential_data_aaguid == WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID
47
46
  end
48
47
 
49
48
  def valid_signature?(authenticator_data, client_data_hash)
@@ -5,32 +5,45 @@ require "cose/key"
5
5
  require "webauthn/attestation_statement/fido_u2f/public_key"
6
6
  require "webauthn/authenticator_data"
7
7
  require "webauthn/authenticator_response"
8
+ require "webauthn/encoder"
8
9
  require "webauthn/signature_verifier"
9
10
 
10
11
  module WebAuthn
11
- class CredentialVerificationError < VerificationError; end
12
12
  class SignatureVerificationError < VerificationError; end
13
13
  class SignCountVerificationError < VerificationError; end
14
14
 
15
15
  class AuthenticatorAssertionResponse < AuthenticatorResponse
16
+ def self.from_client(response)
17
+ encoder = WebAuthn.configuration.encoder
18
+
19
+ user_handle =
20
+ if response["userHandle"]
21
+ encoder.decode(response["userHandle"])
22
+ end
23
+
24
+ new(
25
+ authenticator_data: encoder.decode(response["authenticatorData"]),
26
+ client_data_json: encoder.decode(response["clientDataJSON"]),
27
+ signature: encoder.decode(response["signature"]),
28
+ user_handle: user_handle
29
+ )
30
+ end
31
+
16
32
  attr_reader :user_handle
17
33
 
18
- # FIXME: credential_id doesn't belong inside AuthenticatorAssertionResponse
19
- def initialize(credential_id:, authenticator_data:, signature:, user_handle: nil, **options)
34
+ def initialize(authenticator_data:, signature:, user_handle: nil, **options)
20
35
  super(options)
21
36
 
22
- @credential_id = credential_id
23
37
  @authenticator_data_bytes = authenticator_data
24
38
  @signature = signature
25
39
  @user_handle = user_handle
26
40
  end
27
41
 
28
- def verify(expected_challenge, expected_origin = nil, allowed_credentials:, user_verification: nil, rp_id: nil)
42
+ def verify(expected_challenge, expected_origin = nil, public_key:, sign_count:, user_verification: nil,
43
+ rp_id: nil)
29
44
  super(expected_challenge, expected_origin, user_verification: user_verification, rp_id: rp_id)
30
-
31
- verify_item(:credential, allowed_credentials)
32
- verify_item(:signature, credential_cose_key(allowed_credentials))
33
- verify_item(:sign_count, allowed_credentials)
45
+ verify_item(:signature, credential_cose_key(public_key))
46
+ verify_item(:sign_count, sign_count)
34
47
 
35
48
  true
36
49
  end
@@ -41,7 +54,7 @@ module WebAuthn
41
54
 
42
55
  private
43
56
 
44
- attr_reader :credential_id, :authenticator_data_bytes, :signature
57
+ attr_reader :authenticator_data_bytes, :signature
45
58
 
46
59
  def valid_signature?(credential_cose_key)
47
60
  WebAuthn::SignatureVerifier
@@ -49,32 +62,17 @@ module WebAuthn
49
62
  .verify(signature, authenticator_data_bytes + client_data.hash)
50
63
  end
51
64
 
52
- def valid_sign_count?(allowed_credentials)
53
- matched_credential = allowed_credentials.find do |credential|
54
- credential[:id] == credential_id
55
- end
56
- # TODO: make passing sign count mandatory in next major version
57
- stored_sign_count = matched_credential.fetch(:sign_count, 0)
58
-
59
- if authenticator_data.sign_count.nonzero? || stored_sign_count.nonzero?
60
- authenticator_data.sign_count > stored_sign_count
65
+ def valid_sign_count?(stored_sign_count)
66
+ normalized_sign_count = stored_sign_count || 0
67
+ if authenticator_data.sign_count.nonzero? || normalized_sign_count.nonzero?
68
+ authenticator_data.sign_count > normalized_sign_count
61
69
  else
62
70
  true
63
71
  end
64
72
  end
65
73
 
66
- def valid_credential?(allowed_credentials)
67
- allowed_credential_ids = allowed_credentials.map { |credential| credential[:id] }
68
-
69
- allowed_credential_ids.include?(credential_id)
70
- end
71
-
72
- def credential_cose_key(allowed_credentials)
73
- matched_credential = allowed_credentials.find do |credential|
74
- credential[:id] == credential_id
75
- end
76
-
77
- if WebAuthn::AttestationStatement::FidoU2f::PublicKey.uncompressed_point?(matched_credential[:public_key])
74
+ def credential_cose_key(public_key)
75
+ if WebAuthn::AttestationStatement::FidoU2f::PublicKey.uncompressed_point?(public_key)
78
76
  # Gem version v1.11.0 and lower, used to behave so that Credential#public_key
79
77
  # returned an EC P-256 uncompressed point.
80
78
  #
@@ -83,16 +81,16 @@ module WebAuthn
83
81
  # credentialPublicKey (as in https://www.w3.org/TR/webauthn/#credentialpublickey).
84
82
  #
85
83
  # Given that the credential public key is expected to be stored long-term by the gem
86
- # user and later be passed as one of the allowed_credentials arguments in the
84
+ # user and later be passed as the public_key argument in the
87
85
  # AuthenticatorAssertionResponse.verify call, we then need to support the two formats.
88
86
  COSE::Key::EC2.new(
89
87
  alg: COSE::Algorithm.by_name("ES256").id,
90
88
  crv: 1,
91
- x: matched_credential[:public_key][1..32],
92
- y: matched_credential[:public_key][33..-1]
89
+ x: public_key[1..32],
90
+ y: public_key[33..-1]
93
91
  )
94
92
  else
95
- COSE::Key.deserialize(matched_credential[:public_key])
93
+ COSE::Key.deserialize(public_key)
96
94
  end
97
95
  end
98
96
 
@@ -8,12 +8,22 @@ require "webauthn/authenticator_data"
8
8
  require "webauthn/authenticator_response"
9
9
  require "webauthn/attestation_statement"
10
10
  require "webauthn/client_data"
11
+ require "webauthn/encoder"
11
12
 
12
13
  module WebAuthn
13
14
  class AttestationStatementVerificationError < VerificationError; end
14
15
  class AttestedCredentialVerificationError < VerificationError; end
15
16
 
16
17
  class AuthenticatorAttestationResponse < AuthenticatorResponse
18
+ def self.from_client(response)
19
+ encoder = WebAuthn.configuration.encoder
20
+
21
+ new(
22
+ attestation_object: encoder.decode(response["attestationObject"]),
23
+ client_data_json: encoder.decode(response["clientDataJSON"])
24
+ )
25
+ end
26
+
17
27
  attr_reader :attestation_type, :attestation_trust_path
18
28
 
19
29
  def initialize(attestation_object:, **options)
@@ -52,6 +62,17 @@ module WebAuthn
52
62
  @attestation ||= CBOR.decode(attestation_object)
53
63
  end
54
64
 
65
+ def aaguid
66
+ raw_aaguid = authenticator_data.attested_credential_data.raw_aaguid
67
+ unless raw_aaguid == WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID
68
+ authenticator_data.attested_credential_data.aaguid
69
+ end
70
+ end
71
+
72
+ def attestation_certificate_key
73
+ raw_subject_key_identifier(attestation_statement.attestation_certificate)&.unpack("H*")&.[](0)
74
+ end
75
+
55
76
  private
56
77
 
57
78
  attr_reader :attestation_object
@@ -68,5 +89,14 @@ module WebAuthn
68
89
  def valid_attestation_statement?
69
90
  @attestation_type, @attestation_trust_path = attestation_statement.valid?(authenticator_data, client_data.hash)
70
91
  end
92
+
93
+ def raw_subject_key_identifier(certificate)
94
+ extension = certificate.extensions.detect { |ext| ext.oid == "subjectKeyIdentifier" }
95
+ return unless extension
96
+
97
+ ext_asn1 = OpenSSL::ASN1.decode(extension.to_der)
98
+ ext_value = ext_asn1.value.last
99
+ OpenSSL::ASN1.decode(ext_value.value).value
100
+ end
71
101
  end
72
102
  end
@@ -57,7 +57,9 @@ module WebAuthn
57
57
  end
58
58
 
59
59
  def credential
60
- attested_credential_data.credential
60
+ if attested_credential_data_included?
61
+ attested_credential_data.credential
62
+ end
61
63
  end
62
64
 
63
65
  def sign_count
@@ -6,6 +6,7 @@ module WebAuthn
6
6
  class AuthenticatorData
7
7
  class AttestedCredentialData
8
8
  AAGUID_LENGTH = 16
9
+ ZEROED_AAGUID = 0.chr * AAGUID_LENGTH
9
10
 
10
11
  ID_LENGTH_LENGTH = 2
11
12
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "webauthn/client_data"
5
4
  require "webauthn/error"
6
5
  require "webauthn/security_utils"
@@ -73,7 +72,7 @@ module WebAuthn
73
72
  end
74
73
 
75
74
  def valid_challenge?(expected_challenge)
76
- WebAuthn::SecurityUtils.secure_compare(Base64.urlsafe_decode64(client_data.challenge), expected_challenge)
75
+ WebAuthn::SecurityUtils.secure_compare(client_data.challenge, expected_challenge)
77
76
  end
78
77
 
79
78
  def valid_origin?(expected_origin)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "openssl"
5
+ require "webauthn/encoder"
5
6
  require "webauthn/error"
6
7
 
7
8
  module WebAuthn
@@ -19,7 +20,7 @@ module WebAuthn
19
20
  end
20
21
 
21
22
  def challenge
22
- data["challenge"]
23
+ WebAuthn.standard_encoder.decode(data["challenge"])
23
24
  end
24
25
 
25
26
  def origin
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openssl"
4
+ require "webauthn/encoder"
4
5
 
5
6
  module WebAuthn
6
7
  def self.configuration
@@ -19,6 +20,7 @@ module WebAuthn
19
20
  DEFAULT_ALGORITHMS = ["ES256", if_pss_supported("PS256"), "RS256"].compact.freeze
20
21
 
21
22
  attr_accessor :algorithms
23
+ attr_accessor :encoding
22
24
  attr_accessor :origin
23
25
  attr_accessor :rp_id
24
26
  attr_accessor :rp_name
@@ -27,8 +29,15 @@ module WebAuthn
27
29
 
28
30
  def initialize
29
31
  @algorithms = DEFAULT_ALGORITHMS.dup
32
+ @encoding = WebAuthn::Encoder::STANDARD_ENCODING
30
33
  @verify_attestation_statement = true
31
34
  @credential_options_timeout = 120000
32
35
  end
36
+
37
+ # This is the user-data encoder.
38
+ # Used to decode user input and to encode data provided to the user.
39
+ def encoder
40
+ @encoder ||= WebAuthn::Encoder.new(encoding)
41
+ end
33
42
  end
34
43
  end
@@ -0,0 +1,26 @@
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(*args)
11
+ WebAuthn::PublicKeyCredential::CreationOptions.new(*args)
12
+ end
13
+
14
+ def self.options_for_get(*args)
15
+ WebAuthn::PublicKeyCredential::RequestOptions.new(*args)
16
+ end
17
+
18
+ def self.from_create(credential)
19
+ WebAuthn::PublicKeyCredentialWithAttestation.from_client(credential)
20
+ end
21
+
22
+ def self.from_get(credential)
23
+ WebAuthn::PublicKeyCredentialWithAssertion.from_client(credential)
24
+ end
25
+ end
26
+ end
@@ -6,8 +6,12 @@ require "webauthn/credential_rp_entity"
6
6
  require "webauthn/credential_user_entity"
7
7
 
8
8
  module WebAuthn
9
- # TODO: make keyword arguments mandatory in next major version
10
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
+
11
15
  CredentialCreationOptions.new(
12
16
  rp_name: rp_name, user_id: user_id, user_name: user_name, user_display_name: display_name
13
17
  ).to_h
@@ -4,6 +4,11 @@ require "webauthn/credential_options"
4
4
 
5
5
  module WebAuthn
6
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
+
7
12
  CredentialRequestOptions.new.to_h
8
13
  end
9
14
 
@@ -3,10 +3,17 @@
3
3
  require "base64"
4
4
 
5
5
  module WebAuthn
6
+ def self.standard_encoder
7
+ @standard_encoder ||= Encoder.new
8
+ end
9
+
6
10
  class Encoder
11
+ # https://www.w3.org/TR/webauthn-2/#base64url-encoding
12
+ STANDARD_ENCODING = :base64url
13
+
7
14
  attr_reader :encoding
8
15
 
9
- def initialize(encoding = :base64)
16
+ def initialize(encoding = STANDARD_ENCODING)
10
17
  @encoding = encoding
11
18
  end
12
19
 
@@ -61,6 +61,7 @@ module WebAuthn
61
61
  user_present: user_present,
62
62
  user_verified: user_verified,
63
63
  aaguid: aaguid,
64
+ credential: nil,
64
65
  sign_count: sign_count || credential_sign_count,
65
66
  ).serialize
66
67
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "openssl"
4
+ require "securerandom"
5
5
  require "webauthn/authenticator_data"
6
6
  require "webauthn/encoder"
7
7
  require "webauthn/fake_authenticator"
@@ -16,7 +16,7 @@ module WebAuthn
16
16
  origin = fake_origin,
17
17
  token_binding: nil,
18
18
  authenticator: WebAuthn::FakeAuthenticator.new,
19
- encoding: nil
19
+ encoding: WebAuthn.configuration.encoding
20
20
  )
21
21
  @origin = origin
22
22
  @token_binding = token_binding
@@ -26,13 +26,14 @@ module WebAuthn
26
26
 
27
27
  def create(
28
28
  challenge: fake_challenge,
29
- rp_id: nil, user_present: true,
29
+ rp_id: nil,
30
+ user_present: true,
30
31
  user_verified: false,
31
32
  attested_credential_data: true
32
33
  )
33
34
  rp_id ||= URI.parse(origin).host
34
35
 
35
- client_data_json = data_json_for(:create, challenge)
36
+ client_data_json = data_json_for(:create, encoder.decode(challenge))
36
37
  client_data_hash = hashed(client_data_json)
37
38
 
38
39
  attestation_object = authenticator.make_credential(
@@ -50,14 +51,13 @@ module WebAuthn
50
51
  "id-for-pk-without-attested-credential-data"
51
52
  end
52
53
 
53
- # TODO: return camelCase string keys instead of snakecase symbols
54
54
  {
55
- type: "public-key",
56
- id: Base64.urlsafe_encode64(id),
57
- raw_id: encoder.encode(id),
58
- response: {
59
- attestation_object: encoder.encode(attestation_object),
60
- client_data_json: encoder.encode(client_data_json)
55
+ "type" => "public-key",
56
+ "id" => internal_encoder.encode(id),
57
+ "rawId" => encoder.encode(id),
58
+ "response" => {
59
+ "attestationObject" => encoder.encode(attestation_object),
60
+ "clientDataJSON" => encoder.encode(client_data_json)
61
61
  }
62
62
  }
63
63
  end
@@ -65,7 +65,7 @@ module WebAuthn
65
65
  def get(challenge: fake_challenge, rp_id: nil, user_present: true, user_verified: false, sign_count: nil)
66
66
  rp_id ||= URI.parse(origin).host
67
67
 
68
- client_data_json = data_json_for(:get, challenge)
68
+ client_data_json = data_json_for(:get, encoder.decode(challenge))
69
69
  client_data_hash = hashed(client_data_json)
70
70
 
71
71
  assertion = authenticator.get_assertion(
@@ -76,15 +76,15 @@ module WebAuthn
76
76
  sign_count: sign_count,
77
77
  )
78
78
 
79
- # TODO: return camelCase string keys instead of snakecase symbols
80
79
  {
81
- type: "public-key",
82
- id: Base64.urlsafe_encode64(assertion[:credential_id]),
83
- raw_id: encoder.encode(assertion[:credential_id]),
84
- response: {
85
- client_data_json: encoder.encode(client_data_json),
86
- authenticator_data: encoder.encode(assertion[:authenticator_data]),
87
- signature: encoder.encode(assertion[:signature])
80
+ "type" => "public-key",
81
+ "id" => internal_encoder.encode(assertion[:credential_id]),
82
+ "rawId" => encoder.encode(assertion[:credential_id]),
83
+ "response" => {
84
+ "clientDataJSON" => encoder.encode(client_data_json),
85
+ "authenticatorData" => encoder.encode(assertion[:authenticator_data]),
86
+ "signature" => encoder.encode(assertion[:signature]),
87
+ "userHandle" => nil
88
88
  }
89
89
  }
90
90
  end
@@ -96,7 +96,7 @@ module WebAuthn
96
96
  def data_json_for(method, challenge)
97
97
  data = {
98
98
  type: type_for(method),
99
- challenge: Base64.urlsafe_encode64(challenge, padding: false),
99
+ challenge: internal_encoder.encode(challenge),
100
100
  origin: origin
101
101
  }
102
102
 
@@ -111,12 +111,16 @@ module WebAuthn
111
111
  @encoder ||= WebAuthn::Encoder.new(encoding)
112
112
  end
113
113
 
114
+ def internal_encoder
115
+ WebAuthn.standard_encoder
116
+ end
117
+
114
118
  def hashed(data)
115
119
  OpenSSL::Digest::SHA256.digest(data)
116
120
  end
117
121
 
118
122
  def fake_challenge
119
- SecureRandom.random_bytes(32)
123
+ encoder.encode(SecureRandom.random_bytes(32))
120
124
  end
121
125
 
122
126
  def fake_origin