webauthn 1.18.0 → 2.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
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