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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE +21 -0
  4. data/NOTICE +9 -0
  5. data/README.md +151 -0
  6. data/app/assets/javascripts/unmagic/passkeys/passkey.js +236 -0
  7. data/app/assets/javascripts/unmagic/passkeys/webauthn.js +83 -0
  8. data/app/controllers/unmagic/passkeys/challenges_controller.rb +49 -0
  9. data/app/models/unmagic/passkeys/credential.rb +103 -0
  10. data/config/importmap.rb +5 -0
  11. data/config/routes.rb +2 -0
  12. data/lib/generators/unmagic/passkeys/install_generator.rb +51 -0
  13. data/lib/generators/unmagic/passkeys/templates/POST_INSTALL +19 -0
  14. data/lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt +19 -0
  15. data/lib/unmagic/passkeys/engine.rb +78 -0
  16. data/lib/unmagic/passkeys/form_helper.rb +128 -0
  17. data/lib/unmagic/passkeys/holder.rb +143 -0
  18. data/lib/unmagic/passkeys/request.rb +77 -0
  19. data/lib/unmagic/passkeys/version.rb +5 -0
  20. data/lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb +88 -0
  21. data/lib/unmagic/passkeys/web_authn/authenticator/attestation.rb +73 -0
  22. data/lib/unmagic/passkeys/web_authn/authenticator/attestation_response.rb +71 -0
  23. data/lib/unmagic/passkeys/web_authn/authenticator/attestation_verifiers/none.rb +24 -0
  24. data/lib/unmagic/passkeys/web_authn/authenticator/data.rb +174 -0
  25. data/lib/unmagic/passkeys/web_authn/authenticator/response.rb +141 -0
  26. data/lib/unmagic/passkeys/web_authn/cbor_decoder.rb +269 -0
  27. data/lib/unmagic/passkeys/web_authn/cose_key.rb +183 -0
  28. data/lib/unmagic/passkeys/web_authn/current.rb +19 -0
  29. data/lib/unmagic/passkeys/web_authn/public_key_credential/creation_options.rb +109 -0
  30. data/lib/unmagic/passkeys/web_authn/public_key_credential/options.rb +80 -0
  31. data/lib/unmagic/passkeys/web_authn/public_key_credential/request_options.rb +55 -0
  32. data/lib/unmagic/passkeys/web_authn/public_key_credential.rb +153 -0
  33. data/lib/unmagic/passkeys/web_authn/relying_party.rb +50 -0
  34. data/lib/unmagic/passkeys/web_authn.rb +84 -0
  35. data/lib/unmagic/passkeys.rb +41 -0
  36. 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