unmagic-passkeys 0.1.0
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/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/NOTICE +9 -0
- data/README.md +151 -0
- data/app/assets/javascripts/unmagic/passkeys/passkey.js +236 -0
- data/app/assets/javascripts/unmagic/passkeys/webauthn.js +83 -0
- data/app/controllers/unmagic/passkeys/challenges_controller.rb +49 -0
- data/app/models/unmagic/passkeys/credential.rb +103 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +2 -0
- data/lib/generators/unmagic/passkeys/install_generator.rb +51 -0
- data/lib/generators/unmagic/passkeys/templates/POST_INSTALL +19 -0
- data/lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt +19 -0
- data/lib/unmagic/passkeys/engine.rb +78 -0
- data/lib/unmagic/passkeys/form_helper.rb +128 -0
- data/lib/unmagic/passkeys/holder.rb +143 -0
- data/lib/unmagic/passkeys/request.rb +77 -0
- data/lib/unmagic/passkeys/version.rb +5 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb +88 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation.rb +73 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation_response.rb +71 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation_verifiers/none.rb +24 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/data.rb +174 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/response.rb +141 -0
- data/lib/unmagic/passkeys/web_authn/cbor_decoder.rb +269 -0
- data/lib/unmagic/passkeys/web_authn/cose_key.rb +183 -0
- data/lib/unmagic/passkeys/web_authn/current.rb +19 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/creation_options.rb +109 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/options.rb +80 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/request_options.rb +55 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential.rb +153 -0
- data/lib/unmagic/passkeys/web_authn/relying_party.rb +50 -0
- data/lib/unmagic/passkeys/web_authn.rb +84 -0
- data/lib/unmagic/passkeys.rb +41 -0
- metadata +152 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Attestation Response
|
|
2
|
+
#
|
|
3
|
+
# Handles the authenticator response from a WebAuthn registration ceremony.
|
|
4
|
+
# When a user registers a new credential, the authenticator returns an
|
|
5
|
+
# attestation response containing the new public key and credential ID.
|
|
6
|
+
#
|
|
7
|
+
# == Usage
|
|
8
|
+
#
|
|
9
|
+
# response = Unmagic::Passkeys::WebAuthn::Authenticator::AttestationResponse.new(
|
|
10
|
+
# client_data_json: params[:response][:clientDataJSON],
|
|
11
|
+
# attestation_object: params[:response][:attestationObject],
|
|
12
|
+
# origin: "https://example.com"
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# response.validate!
|
|
16
|
+
#
|
|
17
|
+
# # Store the credential
|
|
18
|
+
# credential_id = response.attestation.credential_id
|
|
19
|
+
# public_key = response.attestation.public_key
|
|
20
|
+
#
|
|
21
|
+
# == Validation
|
|
22
|
+
#
|
|
23
|
+
# In addition to the base Response validations, this class verifies:
|
|
24
|
+
#
|
|
25
|
+
# * The client data type is "webauthn.create"
|
|
26
|
+
# * The attestation format has a registered verifier
|
|
27
|
+
# * The attestation statement passes format-specific verification
|
|
28
|
+
#
|
|
29
|
+
class Unmagic::Passkeys::WebAuthn::Authenticator::AttestationResponse < Unmagic::Passkeys::WebAuthn::Authenticator::Response
|
|
30
|
+
attr_reader :attestation_object
|
|
31
|
+
|
|
32
|
+
validate :client_data_type_must_be_create
|
|
33
|
+
validate :attestation_must_be_valid
|
|
34
|
+
|
|
35
|
+
def initialize(attestation_object:, **attributes)
|
|
36
|
+
super(**attributes)
|
|
37
|
+
@attestation_object = attestation_object
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns the decoded Attestation object, lazily parsed from the raw
|
|
41
|
+
# attestation object bytes.
|
|
42
|
+
def attestation
|
|
43
|
+
@attestation ||= Unmagic::Passkeys::WebAuthn::Authenticator::Attestation.wrap(attestation_object)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the authenticator data extracted from the attestation object.
|
|
47
|
+
def authenticator_data
|
|
48
|
+
attestation.authenticator_data
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
def challenge_purpose
|
|
53
|
+
"registration"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def client_data_type_must_be_create
|
|
57
|
+
unless client_data["type"] == "webauthn.create"
|
|
58
|
+
errors.add(:base, "Client data type is not webauthn.create")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def attestation_must_be_valid
|
|
63
|
+
verifier = Unmagic::Passkeys::WebAuthn.attestation_verifiers[attestation.format]
|
|
64
|
+
|
|
65
|
+
if verifier
|
|
66
|
+
verifier.verify!(attestation, client_data_json: client_data_json)
|
|
67
|
+
else
|
|
68
|
+
errors.add(:base, "Unsupported attestation format: #{attestation.format}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# = Action Pack WebAuthn None Attestation Verifier
|
|
2
|
+
#
|
|
3
|
+
# Verifies attestation responses with the "none" format, which indicates the
|
|
4
|
+
# authenticator did not provide any attestation statement. This is the default
|
|
5
|
+
# format used by most consumer authenticators.
|
|
6
|
+
#
|
|
7
|
+
# == Implementing Custom Verifiers
|
|
8
|
+
#
|
|
9
|
+
# To support other attestation formats (e.g., "packed", "fido-u2f"), implement
|
|
10
|
+
# a class with the same +verify!+ interface and register it:
|
|
11
|
+
#
|
|
12
|
+
# Unmagic::Passkeys::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
|
|
13
|
+
#
|
|
14
|
+
# The +verify!+ method receives the decoded +Attestation+ object and the raw
|
|
15
|
+
# +client_data_json+ bytes. Raise +InvalidResponseError+ if verification fails.
|
|
16
|
+
#
|
|
17
|
+
class Unmagic::Passkeys::WebAuthn::Authenticator::AttestationVerifiers::None
|
|
18
|
+
def verify!(attestation, client_data_json:)
|
|
19
|
+
if attestation.attestation_statement.present?
|
|
20
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError,
|
|
21
|
+
"Attestation statement must be empty for 'none' format"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Authenticator Data
|
|
2
|
+
#
|
|
3
|
+
# Decodes and represents the authenticator data structure from WebAuthn
|
|
4
|
+
# responses. This binary format contains information about the authenticator
|
|
5
|
+
# and, during registration, the newly created credential.
|
|
6
|
+
#
|
|
7
|
+
# == Structure
|
|
8
|
+
#
|
|
9
|
+
# The authenticator data consists of:
|
|
10
|
+
#
|
|
11
|
+
# * RP ID Hash (32 bytes) - SHA-256 hash of the relying party ID
|
|
12
|
+
# * Flags (1 byte) - Bit flags for user presence, verification, etc.
|
|
13
|
+
# * Sign Count (4 bytes) - Signature counter for replay detection
|
|
14
|
+
# * Attested Credential Data (variable) - Present only during registration
|
|
15
|
+
#
|
|
16
|
+
# == Usage
|
|
17
|
+
#
|
|
18
|
+
# data = Unmagic::Passkeys::WebAuthn::Authenticator::Data.decode(bytes)
|
|
19
|
+
#
|
|
20
|
+
# data.user_present? # => true
|
|
21
|
+
# data.user_verified? # => true
|
|
22
|
+
# data.sign_count # => 42
|
|
23
|
+
# data.credential_id # => "abc123..." (registration only)
|
|
24
|
+
# data.public_key # => OpenSSL::PKey::EC (registration only)
|
|
25
|
+
#
|
|
26
|
+
# == Flags
|
|
27
|
+
#
|
|
28
|
+
# [+user_present?+]
|
|
29
|
+
# Returns true if the user performed a test of user presence (e.g., touched
|
|
30
|
+
# the authenticator).
|
|
31
|
+
#
|
|
32
|
+
# [+user_verified?+]
|
|
33
|
+
# Returns true if the user was verified through biometrics, PIN, or other
|
|
34
|
+
# method. This is stronger than mere presence.
|
|
35
|
+
#
|
|
36
|
+
# [+backup_eligible?+]
|
|
37
|
+
# Returns true if the credential can be backed up (e.g., synced passkeys
|
|
38
|
+
# from Apple, Google, or Microsoft). Indicates multi-device credential support.
|
|
39
|
+
#
|
|
40
|
+
# [+backed_up?+]
|
|
41
|
+
# Returns true if the credential is currently backed up to cloud storage.
|
|
42
|
+
# Useful for risk assessment—backed-up credentials may be accessible from
|
|
43
|
+
# multiple devices.
|
|
44
|
+
#
|
|
45
|
+
class Unmagic::Passkeys::WebAuthn::Authenticator::Data
|
|
46
|
+
# Segment lengths
|
|
47
|
+
RELYING_PARTY_ID_HASH_LENGTH = 32
|
|
48
|
+
FLAGS_LENGTH = 1
|
|
49
|
+
SIGN_COUNT_LENGTH = 4
|
|
50
|
+
AAGUID_LENGTH = 16
|
|
51
|
+
CREDENTIAL_ID_LENGTH_BYTES = 2
|
|
52
|
+
|
|
53
|
+
# Flags
|
|
54
|
+
USER_PRESENT_FLAG = 0x01
|
|
55
|
+
USER_VERIFIED_FLAG = 0x04
|
|
56
|
+
BACKUP_ELIGIBLE_FLAG = 0x08
|
|
57
|
+
BACKUP_STATE_FLAG = 0x10
|
|
58
|
+
ATTESTED_CREDENTIAL_DATA_FLAG = 0x40
|
|
59
|
+
|
|
60
|
+
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# Wraps raw authenticator data into a Data instance. Accepts an existing
|
|
64
|
+
# Data object (returned as-is), a Base64URL-encoded string, or raw binary.
|
|
65
|
+
def wrap(data)
|
|
66
|
+
if data.is_a?(self)
|
|
67
|
+
data
|
|
68
|
+
else
|
|
69
|
+
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
|
|
70
|
+
decode(data)
|
|
71
|
+
end
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Invalid base64 encoding in authenticator data"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Decodes raw authenticator data bytes into a Data instance, parsing the
|
|
77
|
+
# RP ID hash, flags, sign count, and (if present) attested credential data.
|
|
78
|
+
def decode(bytes)
|
|
79
|
+
bytes = bytes.bytes if bytes.is_a?(String)
|
|
80
|
+
|
|
81
|
+
minimum_length = RELYING_PARTY_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH
|
|
82
|
+
if bytes.length < minimum_length
|
|
83
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Authenticator data is too short"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
position = 0
|
|
87
|
+
|
|
88
|
+
relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack("C*")
|
|
89
|
+
position += RELYING_PARTY_ID_HASH_LENGTH
|
|
90
|
+
|
|
91
|
+
flags = bytes[position]
|
|
92
|
+
position += FLAGS_LENGTH
|
|
93
|
+
|
|
94
|
+
sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N")
|
|
95
|
+
position += SIGN_COUNT_LENGTH
|
|
96
|
+
|
|
97
|
+
aaguid = nil
|
|
98
|
+
credential_id = nil
|
|
99
|
+
public_key_bytes = nil
|
|
100
|
+
|
|
101
|
+
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
|
|
102
|
+
if bytes.length < position + AAGUID_LENGTH + CREDENTIAL_ID_LENGTH_BYTES
|
|
103
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Authenticator data is too short for attested credential data"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
|
|
107
|
+
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
|
|
108
|
+
position += AAGUID_LENGTH
|
|
109
|
+
|
|
110
|
+
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
|
|
111
|
+
position += CREDENTIAL_ID_LENGTH_BYTES
|
|
112
|
+
|
|
113
|
+
if bytes.length < position + credential_id_length + 1
|
|
114
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Authenticator data is too short for credential ID and public key"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack("C*"), padding: false)
|
|
118
|
+
position += credential_id_length
|
|
119
|
+
|
|
120
|
+
public_key_bytes = bytes[position..].pack("C*")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
new(
|
|
124
|
+
bytes: bytes,
|
|
125
|
+
relying_party_id_hash: relying_party_id_hash,
|
|
126
|
+
flags: flags,
|
|
127
|
+
sign_count: sign_count,
|
|
128
|
+
aaguid: aaguid,
|
|
129
|
+
credential_id: credential_id,
|
|
130
|
+
public_key_bytes: public_key_bytes
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, aaguid: nil, credential_id:, public_key_bytes:)
|
|
136
|
+
@bytes = bytes
|
|
137
|
+
@relying_party_id_hash = relying_party_id_hash
|
|
138
|
+
@flags = flags
|
|
139
|
+
@sign_count = sign_count
|
|
140
|
+
@aaguid = aaguid
|
|
141
|
+
@credential_id = credential_id
|
|
142
|
+
@public_key_bytes = public_key_bytes
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns true if the user performed a test of presence (e.g., touched the
|
|
146
|
+
# authenticator).
|
|
147
|
+
def user_present?
|
|
148
|
+
flags & USER_PRESENT_FLAG != 0
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns true if the user was verified via biometrics, PIN, or similar.
|
|
152
|
+
def user_verified?
|
|
153
|
+
flags & USER_VERIFIED_FLAG != 0
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns true if the credential is eligible for backup (e.g., synced passkey).
|
|
157
|
+
# This indicates the authenticator supports multi-device credentials.
|
|
158
|
+
def backup_eligible?
|
|
159
|
+
flags & BACKUP_ELIGIBLE_FLAG != 0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Returns true if the credential is currently backed up to cloud storage.
|
|
163
|
+
# Only meaningful when +backup_eligible?+ is true.
|
|
164
|
+
def backed_up?
|
|
165
|
+
flags & BACKUP_STATE_FLAG != 0
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Decodes the COSE public key bytes into an OpenSSL key object.
|
|
169
|
+
# Returns +nil+ when no attested credential data is present (authentication
|
|
170
|
+
# responses).
|
|
171
|
+
def public_key
|
|
172
|
+
@public_key ||= Unmagic::Passkeys::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Authenticator Response
|
|
2
|
+
#
|
|
3
|
+
# Abstract base class for WebAuthn authenticator responses. Provides common
|
|
4
|
+
# validation logic for both registration (attestation) and authentication
|
|
5
|
+
# (assertion) ceremonies.
|
|
6
|
+
#
|
|
7
|
+
# This class should not be instantiated directly. Use AttestationResponse for
|
|
8
|
+
# registration or AssertionResponse for authentication.
|
|
9
|
+
#
|
|
10
|
+
# == Validation
|
|
11
|
+
#
|
|
12
|
+
# The +validate!+ method performs security checks required by the WebAuthn
|
|
13
|
+
# specification:
|
|
14
|
+
#
|
|
15
|
+
# * Challenge verification - ensures the response matches the server-generated challenge
|
|
16
|
+
# * Origin verification - ensures the response comes from the expected origin
|
|
17
|
+
# * User verification - optionally requires biometric or PIN verification
|
|
18
|
+
#
|
|
19
|
+
# == Example
|
|
20
|
+
#
|
|
21
|
+
# response = Unmagic::Passkeys::WebAuthn::Authenticator::AssertionResponse.new(
|
|
22
|
+
# client_data_json: client_data_json,
|
|
23
|
+
# authenticator_data: authenticator_data,
|
|
24
|
+
# signature: signature,
|
|
25
|
+
# credential: credential,
|
|
26
|
+
# origin: "https://example.com",
|
|
27
|
+
# user_verification: :required
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# response.validate!
|
|
31
|
+
#
|
|
32
|
+
class Unmagic::Passkeys::WebAuthn::Authenticator::Response
|
|
33
|
+
include ActiveModel::Validations
|
|
34
|
+
|
|
35
|
+
attr_reader :client_data_json
|
|
36
|
+
attr_accessor :origin, :user_verification
|
|
37
|
+
|
|
38
|
+
validate :challenge_must_be_present
|
|
39
|
+
validate :challenge_must_not_be_expired
|
|
40
|
+
validate :origin_must_match
|
|
41
|
+
validate :must_not_be_cross_origin
|
|
42
|
+
validate :must_not_have_token_binding
|
|
43
|
+
validate :relying_party_id_must_match
|
|
44
|
+
validate :user_must_be_present
|
|
45
|
+
validate :user_must_be_verified_when_required
|
|
46
|
+
|
|
47
|
+
def initialize(client_data_json:, origin: nil, user_verification: :preferred)
|
|
48
|
+
@client_data_json = client_data_json
|
|
49
|
+
@origin = origin
|
|
50
|
+
@user_verification = user_verification.to_sym
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate!
|
|
54
|
+
super
|
|
55
|
+
rescue ActiveModel::ValidationError
|
|
56
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, errors.full_messages.join(", ")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the RelyingParty used for RP ID validation.
|
|
60
|
+
def relying_party
|
|
61
|
+
Unmagic::Passkeys::WebAuthn.relying_party
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Parses the client data JSON string into a Hash. Raises
|
|
65
|
+
# +InvalidResponseError+ if the JSON is malformed.
|
|
66
|
+
def client_data
|
|
67
|
+
@client_data ||= JSON.parse(client_data_json)
|
|
68
|
+
rescue JSON::ParserError
|
|
69
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Client data is not valid JSON"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def authenticator_data
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
def challenge_must_be_present
|
|
78
|
+
if client_data["challenge"].blank?
|
|
79
|
+
errors.add(:base, "Challenge missing")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def challenge_must_not_be_expired
|
|
84
|
+
return if errors.any?
|
|
85
|
+
|
|
86
|
+
signed_message = Base64.urlsafe_decode64(client_data["challenge"])
|
|
87
|
+
|
|
88
|
+
unless Unmagic::Passkeys::WebAuthn.challenge_verifier.verified(signed_message, purpose: challenge_purpose)
|
|
89
|
+
errors.add(:base, "Challenge has expired")
|
|
90
|
+
end
|
|
91
|
+
rescue ArgumentError
|
|
92
|
+
errors.add(:base, "Challenge is invalid")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def challenge_purpose
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def origin_must_match
|
|
100
|
+
if origin.blank?
|
|
101
|
+
errors.add(:base, "Origin missing")
|
|
102
|
+
elsif client_data["origin"].blank?
|
|
103
|
+
errors.add(:base, "Origin missing in client data")
|
|
104
|
+
elsif !ActiveSupport::SecurityUtils.secure_compare(origin.to_s, client_data["origin"].to_s)
|
|
105
|
+
errors.add(:base, "Origin does not match")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def must_not_be_cross_origin
|
|
110
|
+
if client_data["crossOrigin"] == true
|
|
111
|
+
errors.add(:base, "Cross-origin requests are not supported")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def must_not_have_token_binding
|
|
116
|
+
if client_data.dig("tokenBinding", "status") == "present"
|
|
117
|
+
errors.add(:base, "Token binding is not supported")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def relying_party_id_must_match
|
|
122
|
+
unless ActiveSupport::SecurityUtils.secure_compare(
|
|
123
|
+
Digest::SHA256.digest(relying_party.id),
|
|
124
|
+
authenticator_data&.relying_party_id_hash || ""
|
|
125
|
+
)
|
|
126
|
+
errors.add(:base, "Relying party ID does not match")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def user_must_be_present
|
|
131
|
+
unless authenticator_data&.user_present?
|
|
132
|
+
errors.add(:base, "User presence is required")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def user_must_be_verified_when_required
|
|
137
|
+
if user_verification == :required && !authenticator_data&.user_verified?
|
|
138
|
+
errors.add(:base, "User verification is required")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# = Action Pack WebAuthn CBOR Decoder
|
|
2
|
+
#
|
|
3
|
+
# Decodes Concise Binary Object Representation (CBOR) data as specified in
|
|
4
|
+
# RFC 8949[https://tools.ietf.org/html/rfc8949]. CBOR is a binary data format
|
|
5
|
+
# used by WebAuthn for encoding authenticator data and attestation objects.
|
|
6
|
+
#
|
|
7
|
+
# == Usage
|
|
8
|
+
#
|
|
9
|
+
# The decoder accepts either a binary string or an array of bytes:
|
|
10
|
+
#
|
|
11
|
+
# # From binary string
|
|
12
|
+
# Unmagic::Passkeys::WebAuthn::CborDecoder.decode("\x83\x01\x02\x03")
|
|
13
|
+
# # => [1, 2, 3]
|
|
14
|
+
#
|
|
15
|
+
# # From byte array
|
|
16
|
+
# Unmagic::Passkeys::WebAuthn::CborDecoder.decode([0x83, 0x01, 0x02, 0x03])
|
|
17
|
+
# # => [1, 2, 3]
|
|
18
|
+
#
|
|
19
|
+
# == Supported Types
|
|
20
|
+
#
|
|
21
|
+
# The decoder supports the following CBOR types:
|
|
22
|
+
#
|
|
23
|
+
# [Integers]
|
|
24
|
+
# Unsigned (major type 0) and negative (major type 1) integers of any size.
|
|
25
|
+
#
|
|
26
|
+
# [Byte strings]
|
|
27
|
+
# Binary data (major type 2), returned as ASCII-8BIT encoded strings.
|
|
28
|
+
#
|
|
29
|
+
# [Text strings]
|
|
30
|
+
# UTF-8 text (major type 3), returned as UTF-8 encoded strings.
|
|
31
|
+
#
|
|
32
|
+
# [Arrays]
|
|
33
|
+
# Ordered collections (major type 4) of any CBOR values.
|
|
34
|
+
#
|
|
35
|
+
# [Maps]
|
|
36
|
+
# Key-value pairs (major type 5) with any CBOR values as keys and values.
|
|
37
|
+
#
|
|
38
|
+
# [Floats]
|
|
39
|
+
# IEEE 754 half (16-bit), single (32-bit), and double (64-bit) precision.
|
|
40
|
+
#
|
|
41
|
+
# [Simple values]
|
|
42
|
+
# +false+, +true+, +null+, and +undefined+ (both decoded as +nil+).
|
|
43
|
+
#
|
|
44
|
+
# [Indefinite length]
|
|
45
|
+
# Streaming byte strings, text strings, arrays, and maps.
|
|
46
|
+
#
|
|
47
|
+
# Tags (major type 6) are recognized but their semantic meaning is ignored;
|
|
48
|
+
# the tagged value is returned directly.
|
|
49
|
+
#
|
|
50
|
+
# == Errors
|
|
51
|
+
#
|
|
52
|
+
# Raises +InvalidCborError+ when encountering malformed or unsupported CBOR data.
|
|
53
|
+
class Unmagic::Passkeys::WebAuthn::CborDecoder
|
|
54
|
+
# Major types
|
|
55
|
+
UNSIGNED_INTEGER_TYPE = 0
|
|
56
|
+
NEGATIVE_INTEGER_TYPE = 1
|
|
57
|
+
BYTE_STRING_TYPE = 2
|
|
58
|
+
TEXT_STRING_TYPE = 3
|
|
59
|
+
ARRAY_TYPE = 4
|
|
60
|
+
MAP_TYPE = 5
|
|
61
|
+
TAG_TYPE = 6
|
|
62
|
+
FLOAT_OR_SIMPLE_TYPE = 7
|
|
63
|
+
|
|
64
|
+
# Additional information values
|
|
65
|
+
SIMPLE_VALUE_RANGE = 0..23
|
|
66
|
+
SINGLE_BYTE_VALUE_FOLLOWS = 24
|
|
67
|
+
TWO_BYTE_VALUE_FOLLOWS = 25
|
|
68
|
+
FOUR_BYTE_VALUE_FOLLOWS = 26
|
|
69
|
+
EIGHT_BYTE_VALUE_FOLLOWS = 27
|
|
70
|
+
RESERVED_VALUE_RANGE = 28..30
|
|
71
|
+
INDEFINITE_LENGTH_MAJOR_TYPE = 31
|
|
72
|
+
|
|
73
|
+
# Simple values
|
|
74
|
+
SIMPLE_FALSE_VALUE = 20
|
|
75
|
+
SIMPLE_TRUE_VALUE = 21
|
|
76
|
+
SIMPLE_NULL_VALUE = 22
|
|
77
|
+
SIMPLE_UNDEFINED_VALUE = 23
|
|
78
|
+
|
|
79
|
+
# Flow control
|
|
80
|
+
BREAK_CODE = 0xFF
|
|
81
|
+
|
|
82
|
+
# Limits
|
|
83
|
+
MAX_DEPTH = 16
|
|
84
|
+
MAX_SIZE = 10.megabytes
|
|
85
|
+
|
|
86
|
+
# Tags
|
|
87
|
+
POSITIVE_BIGNUM_TAG = 2
|
|
88
|
+
NEGATIVE_BIGNUM_TAG = 3
|
|
89
|
+
|
|
90
|
+
class << self
|
|
91
|
+
# Decodes a CBOR-encoded byte sequence into a Ruby object.
|
|
92
|
+
#
|
|
93
|
+
# Unmagic::Passkeys::WebAuthn::CborDecoder.decode("\xa2\x61a\x01\x61b\x02")
|
|
94
|
+
# # => {"a" => 1, "b" => 2}
|
|
95
|
+
def decode(bytes, **args)
|
|
96
|
+
bytes = bytes.bytes if bytes.respond_to?(:bytes)
|
|
97
|
+
new(bytes, **args).decode
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc:
|
|
102
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidCborError, "Input exceeds maximum size" if bytes.length > max_size
|
|
103
|
+
|
|
104
|
+
@bytes = bytes
|
|
105
|
+
@max_depth = max_depth
|
|
106
|
+
@position = 0
|
|
107
|
+
@depth = 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Decodes the next CBOR data item from the byte sequence.
|
|
111
|
+
def decode
|
|
112
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
|
|
113
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidCborError, "Maximum nesting depth exceeded" if @depth >= @max_depth
|
|
114
|
+
|
|
115
|
+
@depth += 1
|
|
116
|
+
|
|
117
|
+
result = case major_type
|
|
118
|
+
when UNSIGNED_INTEGER_TYPE then decode_unsigned_integer
|
|
119
|
+
when NEGATIVE_INTEGER_TYPE then decode_negative_integer
|
|
120
|
+
when BYTE_STRING_TYPE then decode_byte_string
|
|
121
|
+
when TEXT_STRING_TYPE then decode_text_string
|
|
122
|
+
when ARRAY_TYPE then decode_array
|
|
123
|
+
when MAP_TYPE then decode_map
|
|
124
|
+
when TAG_TYPE then decode_tag
|
|
125
|
+
when FLOAT_OR_SIMPLE_TYPE then decode_float_or_simple
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@depth -= 1
|
|
129
|
+
result
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
def major_type
|
|
134
|
+
peek >> 5
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def peek
|
|
138
|
+
@bytes[@position]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def decode_unsigned_integer
|
|
142
|
+
read_argument
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def decode_negative_integer
|
|
146
|
+
-1 - read_argument
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def decode_byte_string
|
|
150
|
+
if indefinite_length?
|
|
151
|
+
String.new(encoding: Encoding::ASCII_8BIT).tap { |str| str << decode_byte_string until break_code? }
|
|
152
|
+
else
|
|
153
|
+
read_bytes(read_argument).pack("C*")
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def decode_text_string
|
|
158
|
+
if indefinite_length?
|
|
159
|
+
String.new(encoding: Encoding::UTF_8).tap { |str| str << decode_text_string until break_code? }
|
|
160
|
+
else
|
|
161
|
+
read_bytes(read_argument).pack("C*").force_encoding(Encoding::UTF_8)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def decode_array
|
|
166
|
+
if indefinite_length?
|
|
167
|
+
Array.new.tap { |arr| arr << decode until break_code? }
|
|
168
|
+
else
|
|
169
|
+
Array.new(read_argument) { decode }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def decode_map
|
|
174
|
+
if indefinite_length?
|
|
175
|
+
Hash.new.tap { |hash| hash[decode] = decode until break_code? }
|
|
176
|
+
else
|
|
177
|
+
Hash.new.tap do |hash|
|
|
178
|
+
read_argument.times do
|
|
179
|
+
hash[decode] = decode
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def decode_float_or_simple
|
|
186
|
+
case info = additional_info
|
|
187
|
+
when SIMPLE_FALSE_VALUE then false
|
|
188
|
+
when SIMPLE_TRUE_VALUE then true
|
|
189
|
+
when SIMPLE_NULL_VALUE, SIMPLE_UNDEFINED_VALUE then nil
|
|
190
|
+
when TWO_BYTE_VALUE_FOLLOWS then decode_half_float
|
|
191
|
+
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("g")
|
|
192
|
+
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("G")
|
|
193
|
+
else
|
|
194
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidCborError, "Invalid simple value: #{info}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def decode_tag
|
|
199
|
+
tag = read_argument
|
|
200
|
+
value = decode
|
|
201
|
+
|
|
202
|
+
case tag
|
|
203
|
+
when POSITIVE_BIGNUM_TAG then value.bytes.inject(0) { |n, b| (n << 8) | b }
|
|
204
|
+
when NEGATIVE_BIGNUM_TAG then -1 - value.bytes.inject(0) { |n, b| (n << 8) | b }
|
|
205
|
+
else value
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def decode_half_float
|
|
210
|
+
half = read_bytes(2).pack("C*").unpack1("n")
|
|
211
|
+
|
|
212
|
+
sign = (half >> 15) & 0x1
|
|
213
|
+
exponent = (half >> 10) & 0x1F
|
|
214
|
+
mantissa = half & 0x3FF
|
|
215
|
+
|
|
216
|
+
value = if exponent == 0
|
|
217
|
+
Math.ldexp(mantissa, -24)
|
|
218
|
+
elsif exponent == 31
|
|
219
|
+
mantissa == 0 ? Float::INFINITY : Float::NAN
|
|
220
|
+
else
|
|
221
|
+
Math.ldexp(mantissa + 1024, exponent - 25)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
sign == 1 ? -value : value
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def read_argument
|
|
228
|
+
case info = additional_info
|
|
229
|
+
when SIMPLE_VALUE_RANGE then info
|
|
230
|
+
when SINGLE_BYTE_VALUE_FOLLOWS then read_byte
|
|
231
|
+
when TWO_BYTE_VALUE_FOLLOWS then read_bytes(2).pack("C*").unpack1("n")
|
|
232
|
+
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("N")
|
|
233
|
+
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("Q>")
|
|
234
|
+
when RESERVED_VALUE_RANGE
|
|
235
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidCborError, "Reserved additional info: #{info}"
|
|
236
|
+
else
|
|
237
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidCborError, "Invalid additional info: #{info}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def additional_info(consume: true)
|
|
242
|
+
byte = consume ? read_byte : peek
|
|
243
|
+
byte & 0b00011111
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def indefinite_length?
|
|
247
|
+
read_byte if additional_info(consume: false) == INDEFINITE_LENGTH_MAJOR_TYPE
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def break_code?
|
|
251
|
+
read_byte if peek == BREAK_CODE
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def read_bytes(length)
|
|
255
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidCborError, "Unexpected end of input" if @position + length > @bytes.length
|
|
256
|
+
|
|
257
|
+
bytes = @bytes[@position, length]
|
|
258
|
+
@position += length
|
|
259
|
+
bytes
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def read_byte
|
|
263
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
|
|
264
|
+
|
|
265
|
+
byte = @bytes[@position]
|
|
266
|
+
@position += 1
|
|
267
|
+
byte
|
|
268
|
+
end
|
|
269
|
+
end
|