triauth 0.0.1 → 1.0.0.beta.4

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/LICENSE +202 -28
  4. data/README.md +2036 -7
  5. data/lib/triauth/api/attestation.rb +419 -0
  6. data/lib/triauth/api/authentication.rb +651 -0
  7. data/lib/triauth/api/identity.rb +245 -0
  8. data/lib/triauth/api/signing.rb +740 -0
  9. data/lib/triauth/api/validation.rb +162 -0
  10. data/lib/triauth/api.rb +33 -0
  11. data/lib/triauth/authentication_endpoint.rb +196 -0
  12. data/lib/triauth/challenge.rb +115 -0
  13. data/lib/triauth/challenge_response_flow.rb +344 -0
  14. data/lib/triauth/config.rb +93 -0
  15. data/lib/triauth/error.rb +95 -0
  16. data/lib/triauth/helpers.rb +1222 -0
  17. data/lib/triauth/identity.rb +301 -0
  18. data/lib/triauth/identity_domain.rb +212 -0
  19. data/lib/triauth/identity_includes.rb +249 -0
  20. data/lib/triauth/identity_keys.rb +309 -0
  21. data/lib/triauth/logger.rb +156 -0
  22. data/lib/triauth/multi_signature.rb +177 -0
  23. data/lib/triauth/protocol.rb +74 -0
  24. data/lib/triauth/request_cipher.rb +121 -0
  25. data/lib/triauth/resolvers/base.rb +147 -0
  26. data/lib/triauth/resolvers/caching_resolver.rb +202 -0
  27. data/lib/triauth/resolvers/cloudflare.rb +40 -0
  28. data/lib/triauth/resolvers/dns_json.rb +391 -0
  29. data/lib/triauth/resolvers/dns_sb.rb +40 -0
  30. data/lib/triauth/resolvers/google.rb +39 -0
  31. data/lib/triauth/resolvers/multi_resolver.rb +202 -0
  32. data/lib/triauth/resolvers/system_dns.rb +259 -0
  33. data/lib/triauth/resolvers.rb +33 -0
  34. data/lib/triauth/response.rb +142 -0
  35. data/lib/triauth/response_cipher.rb +341 -0
  36. data/lib/triauth/signature.rb +405 -0
  37. data/lib/triauth/validator.rb +549 -0
  38. data/lib/triauth/verifiers/base.rb +147 -0
  39. data/lib/triauth/verifiers/ecdsa.rb +87 -0
  40. data/lib/triauth/verifiers/ed25519.rb +89 -0
  41. data/lib/triauth/verifiers/registry.rb +52 -0
  42. data/lib/triauth/verifiers/web_authn.rb +253 -0
  43. data/lib/triauth/verifiers.rb +37 -0
  44. data/lib/triauth/version.rb +23 -0
  45. data/lib/triauth.rb +104 -7
  46. metadata +55 -13
@@ -0,0 +1,549 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ require 'json'
21
+ require_relative 'helpers'
22
+ require_relative 'protocol'
23
+
24
+ module Triauth
25
+ # A collection of helper methods dedicated for data validation.
26
+ #
27
+ # These validation methods are not meant to be bullet-proof, and API method calls should return correct results even
28
+ # if invalid or dangerous data for some reason passes the validation.
29
+ class Validator
30
+ # The longest well-formed session key, `<domain>:<key>` with a domain of at most LIMITS.domainNameBytesize
31
+ # bytes and a 43-character key, and the longest well-formed device tag, `<grantTag>:<actorIdentifier>:<actorDeviceTag>`
32
+ # with two tag components of 43 + 1 + 16 characters around an identifier of at most LIMITS.identifierBytesize bytes.
33
+ # The validators compare the length against these caps ahead of any split, so the cost of a rejection does not
34
+ # grow with the input.
35
+ SESSION_KEY_MAX_LENGTH = Protocol::LIMITS[:domainNameBytesize] + 1 + 43
36
+ DEVICE_TAG_MAX_LENGTH = (2 * (43 + 1 + 16)) + 2 + Protocol::LIMITS[:identifierBytesize]
37
+ private_constant :SESSION_KEY_MAX_LENGTH, :DEVICE_TAG_MAX_LENGTH
38
+
39
+ # The message of every validation error, by error code and reason. Each code names the invalid field,
40
+ # and each reason names the rule that the field breaks.
41
+ ERRORS = {
42
+ 'invalid_identifier' => {
43
+ 'invalid' => 'Identifier is invalid',
44
+ 'empty' => 'Identifier must not be empty',
45
+ 'too_long' => 'Identifier is too long',
46
+ 'not_lowercase' => 'Identifier must be provided in lowercase',
47
+ 'invalid_format' => 'Identifier must include one @ sign',
48
+ 'invalid_username' => 'Identifier contains invalid username',
49
+ 'invalid_domain' => 'Identifier contains invalid domain name'
50
+ },
51
+ 'invalid_callback_url' => { 'invalid' => 'Invalid callbackUrl' },
52
+ 'invalid_ext' => { 'invalid' => 'Invalid ext' },
53
+ 'invalid_challenge' => { 'invalid' => 'Invalid challenge' },
54
+ 'invalid_response' => { 'invalid' => 'Invalid response' },
55
+ 'invalid_signature' => { 'invalid' => 'Invalid signature' },
56
+ 'invalid_session_key' => { 'invalid' => 'Invalid session key' },
57
+ 'invalid_message' => { 'invalid' => 'Invalid message' },
58
+ 'invalid_attachments' => { 'invalid' => 'Invalid attachments' },
59
+ 'invalid_attestations' => { 'invalid' => 'Invalid attestations' },
60
+ 'invalid_lookup_code' => { 'invalid' => 'Invalid lookup code' },
61
+ 'invalid_device_tag' => { 'invalid' => 'Invalid device tag' },
62
+ 'invalid_device_name' => {
63
+ 'invalid' => 'Device name is invalid',
64
+ 'empty' => 'Device name must not be empty',
65
+ 'too_long' => 'Device name is too long',
66
+ 'not_lowercase' => 'Device name must be provided in lowercase',
67
+ 'invalid_characters' => 'Device name must include only letters, numbers, and hyphens'
68
+ },
69
+ 'invalid_group_name' => {
70
+ 'invalid' => 'Group name is invalid',
71
+ 'empty' => 'Group name must not be empty',
72
+ 'too_long' => 'Group name is too long',
73
+ 'not_lowercase' => 'Group name must be provided in lowercase',
74
+ 'invalid_characters' => 'Group name must include only letters, numbers, and hyphens'
75
+ }
76
+ }
77
+ private_constant :ERRORS
78
+
79
+ # Builds a validation result from the reasons found for one field.
80
+ #
81
+ # @param code [String] The error code of the field (e.g., `invalid_identifier`).
82
+ # @param reasons [Array<String>] The reasons found, empty when the field is valid.
83
+ #
84
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
85
+ def self.expand_result(code, reasons)
86
+ return {
87
+ valid: reasons.length == 0,
88
+ errors: reasons.map { |reason| { code: code, reason: reason, message: ERRORS[code][reason] } }
89
+ }
90
+ end
91
+ private_class_method :expand_result
92
+
93
+ # Validates the provided personal identifier's format, and returns human-friendly error messages if problems are found.
94
+ #
95
+ # @param identifier [String] Identifier that should be validated.
96
+ #
97
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
98
+ def self.validate_identifier(identifier)
99
+ if !Helpers.is_string(identifier)
100
+ return expand_result('invalid_identifier', ['invalid'])
101
+ end
102
+
103
+ if identifier.length <= 0
104
+ return expand_result('invalid_identifier', ['empty'])
105
+ end
106
+
107
+ if identifier.length > Protocol::LIMITS[:identifierBytesize] || Helpers.byte_size(identifier) > Protocol::LIMITS[:identifierBytesize]
108
+ return expand_result('invalid_identifier', ['too_long'])
109
+ end
110
+
111
+ # identifiers must not contain any whitespace characters and/or NULL
112
+ if /[#{Helpers::JS_WS}\0]/o.match?(identifier)
113
+ return expand_result('invalid_identifier', ['invalid'])
114
+ end
115
+
116
+ if identifier != identifier.downcase
117
+ return expand_result('invalid_identifier', ['not_lowercase'])
118
+ end
119
+
120
+ if identifier.scan(/@/).length != 1
121
+ return expand_result('invalid_identifier', ['invalid_format'])
122
+ end
123
+
124
+ username, domain = identifier.split('@', -1)
125
+ reasons = []
126
+
127
+ # Username may be composed only of ASCII letters, numbers, dot and hyphen signs, must start with a letter or a digit,
128
+ # and must be no longer than 63 chars (DNS label limit),
129
+ if !/\A[a-z0-9][a-z0-9.-]{0,62}\z/.match?(username) ||
130
+
131
+ # consecutive dots and/or hyphens are not allowed,
132
+ /[.-][.-]/.match?(username) ||
133
+
134
+ # and it must not end with a dot or hyphen.
135
+ /[.-]\z/.match?(username)
136
+ reasons.push('invalid_username')
137
+ end
138
+
139
+ if !Helpers.is_domain_name(domain)
140
+ reasons.push('invalid_domain')
141
+ end
142
+
143
+ return expand_result('invalid_identifier', reasons)
144
+ end
145
+
146
+ # Validates the provided callback URL's format, and returns human-friendly error messages if problems are found.
147
+ #
148
+ # @param callback_url [String] Callback URL that should be validated.
149
+ #
150
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
151
+ def self.validate_callback_url(callback_url)
152
+ reasons = []
153
+
154
+ # A callback URL is any canonical URL — http or https, named or IPv4-literal host — whose
155
+ # base URL is free of the signature-envelope delimiter ';'
156
+ base = Helpers.get_base_url(callback_url)
157
+ if base == false || base.include?(Protocol::DELIMITER)
158
+ reasons.push('invalid')
159
+ end
160
+
161
+ return expand_result('invalid_callback_url', reasons)
162
+ end
163
+
164
+ # Validates the optional `ext` property for `Triauth.authenticate` — both as supplied in stage 1 and as received back in stage 3.
165
+ #
166
+ # `ext` must be a plain object whose values are strings, whole numbers, booleans, or plain objects one level deep (no arrays, no deeper nesting).
167
+ # A string value carries no unpaired surrogate and a number is a whole number of magnitude at most 2^53 - 1, the rules that
168
+ # `Helpers.safe_parse_json` applies to every JSON value when the ext comes back in stage 3.
169
+ # Keys must additionally be ASCII-only — `Helpers.safe_parse_json` rejects non-ASCII property names when the ext round-trips back in stage 3 —
170
+ # and never one of the prototype-poisoning names (`__proto__`, `constructor`, `prototype`)
171
+ #
172
+ # @param ext [Hash] Ext object that should be validated.
173
+ # @param depth [Integer] Nesting depth of the object being validated: 0 for the `ext` object itself,
174
+ # 1 for the single level of nested plain objects that `ext` values may contain (defaults to 0).
175
+ #
176
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
177
+ def self.validate_ext(ext, depth = 0)
178
+ reasons = []
179
+
180
+ if ext.nil? ||
181
+ !(ext.is_a?(Hash) && ext.keys.all? { |k| Helpers.is_normal_string(k.to_s, Protocol::LIMITS[:jsonMaxKeyLength]) && !/[^\x20-\x7E]/.match?(k.to_s) && !%w[__proto__ constructor prototype].include?(k.to_s) } && ext.values.all? { |e| (Helpers.is_string(e) && Helpers.is_well_formed_string(e)) || Helpers.is_safe_integer(e) || e == true || e == false || (depth == 0 && validate_ext(e, depth + 1)[:errors].length == 0) }) ||
182
+ Helpers.byte_size(JSON.generate(ext)) > Protocol::LIMITS[:extBytesize]
183
+ reasons.push('invalid')
184
+ end
185
+
186
+ return expand_result('invalid_ext', reasons)
187
+ end
188
+
189
+ # Validates the `attestations` option for `Triauth.attest`, applied both when building the challenge (stage 1) and verifying the response (stage 3).
190
+ #
191
+ # `attestations` must be a plain object keyed by attestation name, where each value is `{label: string, providers: string[]}`.
192
+ # An empty object `{}` is valid; passing it will produce `{attested: true, attestations: {}}` from stage 3.
193
+ # An attestation name is printable ASCII, as it becomes a property name of the challenge that `Helpers.safe_parse_json` admits only in ASCII in stage 3,
194
+ # and never one of the prototype-poisoning keys (`__proto__`, `constructor`, `prototype`).
195
+ #
196
+ # @param attestations [Hash] Attestations object that should be validated.
197
+ #
198
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
199
+ def self.validate_attestations(attestations)
200
+ reasons = []
201
+
202
+ if attestations.nil? ||
203
+ !attestations.is_a?(Hash) ||
204
+ !attestations.keys.all? { |k| Helpers.is_normal_string(k.to_s) && !/[^\x20-\x7E]/.match?(k.to_s) && !%w[__proto__ constructor prototype].include?(k.to_s) }
205
+ return expand_result('invalid_attestations', ['invalid'])
206
+ end
207
+
208
+ # The multi-signature envelope holds at most maxMultiSignatures entries (1 user + N attesters),
209
+ # so requesting more than (maxMultiSignatures - 1) attestations creates a request that can never be satisfied.
210
+ if attestations.size > Protocol::LIMITS[:maxMultiSignatures] - 1
211
+ return expand_result('invalid_attestations', ['invalid'])
212
+ end
213
+
214
+ attestations.each_value do |entry|
215
+ if !entry.is_a?(Hash) || # `entry !== null && typeof entry === 'object' && !Array.isArray(entry)`: a Hash, so a nil entry is invalid here as there
216
+ !entry.keys.all? { |k| %i[label providers].include?(k) } ||
217
+ !Helpers.is_normal_string(entry[:label]) ||
218
+ !(
219
+ entry[:providers].is_a?(Array) &&
220
+ entry[:providers].length > 0 && entry[:providers].length <= Protocol::LIMITS[:maxMultiSignatures] - 1 &&
221
+ entry[:providers].all? { |p_url| Helpers.is_secure_url(p_url) }
222
+ )
223
+ reasons.push('invalid')
224
+
225
+ else
226
+ entry[:providers].each do |provider_url|
227
+ # Beyond being canonical on a secure origin, a provider URL is displayed verbatim in
228
+ # the authenticator UI and embedded whole as the `via` field of the attestation
229
+ # segment, so it must carry no query, no fragment, no percent-escapes, no envelope
230
+ # delimiter ';', and no apostrophe. In a canonical URL, '?' and '#' can appear only
231
+ # as the query/fragment introducers and '%' only in an escape — one charset test
232
+ # covers every excluded character.
233
+ if /[?#%;']/.match?(provider_url)
234
+ reasons.push('invalid')
235
+ end
236
+ end
237
+ end
238
+ end
239
+
240
+ return expand_result('invalid_attestations', reasons)
241
+ end
242
+
243
+ # Validates the challenge string returned from stage 1 of an authentication flow, before passing it to stage 3.
244
+ #
245
+ # The challenge must be a non-empty base64url-encoded string within the allowed byte size.
246
+ #
247
+ # @param challenge [String] Challenge string that should be validated.
248
+ #
249
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
250
+ def self.validate_challenge(challenge)
251
+ reasons = []
252
+
253
+ if !Helpers.is_string(challenge) ||
254
+ challenge.length <= 0 ||
255
+ challenge.length > Protocol::LIMITS[:challengeBytesize] ||
256
+ Helpers.byte_size(challenge) > Protocol::LIMITS[:challengeBytesize] ||
257
+ !Helpers.is_base64url_string(challenge)
258
+ reasons.push('invalid')
259
+ end
260
+
261
+ return expand_result('invalid_challenge', reasons)
262
+ end
263
+
264
+ # Validates the response string returned by the authenticator in stage 3 of an authentication flow.
265
+ #
266
+ # The response must be a pipe-delimited string of the form `|...|`, within the allowed byte size.
267
+ #
268
+ # @param response [String] Response string that should be validated.
269
+ #
270
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
271
+ def self.validate_response(response)
272
+ reasons = []
273
+
274
+ if !Helpers.is_string(response) ||
275
+ response.length <= 3 ||
276
+ (response[0] != '|' || response[-1] != '|') ||
277
+ response.length > Protocol::LIMITS[:signatureBytesize] ||
278
+ Helpers.byte_size(response) > Protocol::LIMITS[:signatureBytesize]
279
+ reasons.push('invalid')
280
+ end
281
+
282
+ return expand_result('invalid_response', reasons)
283
+ end
284
+
285
+ # Validates the sealed response received in stage 3 of an authentication flow, before it is opened.
286
+ #
287
+ # A sealed response is `<enc>.<ciphertext>`, both base64url: an 87-character encapsulated key, and a ciphertext of
288
+ # at least the 16-byte authentication tag, bounded by the sealed size of the largest response envelope.
289
+ #
290
+ # @param response [String] Sealed response that should be validated.
291
+ #
292
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
293
+ def self.validate_sealed_response(response)
294
+ reasons = []
295
+ max_length = 87 + 1 + ((Protocol::LIMITS[:signatureBytesize] + 16) * 4).fdiv(3).ceil # `Math.ceil(... * 4 / 3)`: a real division, never the Integer `/`
296
+
297
+ if !Helpers.is_string(response) ||
298
+ response.length > max_length || # code points where JS counts UTF-16 units: a string the two counts disagree on is non-ASCII and invalid at the regex either way
299
+ !/\A[A-Za-z0-9_-]{87}\.[A-Za-z0-9_-]{22,}\z/.match?(response)
300
+ reasons.push('invalid')
301
+ end
302
+
303
+ return expand_result('invalid_response', reasons)
304
+ end
305
+
306
+ # Validates the message string to be signed or stamped in a `Triauth.sign` or `Triauth.stamp` flow.
307
+ #
308
+ # @param message [String] Message that should be validated.
309
+ #
310
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
311
+ def self.validate_message(message)
312
+ reasons = []
313
+
314
+ if !Helpers.is_normal_string(message, Protocol::LIMITS[:messageBytesize])
315
+ reasons.push('invalid')
316
+ end
317
+
318
+ return expand_result('invalid_message', reasons)
319
+ end
320
+
321
+ # Validates the `attachments` array for a `Triauth.sign` flow.
322
+ #
323
+ # Each attachment must be a plain object with `name` (string) and `sha256` (64-character lowercase hex string),
324
+ # plus a `sourceUrl` (a canonical URL). Names must be unique within the array.
325
+ #
326
+ # `sourceUrl` is only needed when building the challenge (stage 1), so the authenticator knows where to fetch
327
+ # the file to hash. It is intentionally NOT required on the signed response (stage 3): the durable proof binds
328
+ # only `name`+`sha256`, and keeping `sourceUrl` out of the signed payload avoids embedding fetch URLs (which may
329
+ # carry presigned credentials) in a portable, third-party-verifiable signature. Pass `require_source_url = false`
330
+ # to validate a response's attachments, where `sourceUrl` may be omitted (but is held to the same rule if present).
331
+ #
332
+ # @param attachments [Array<Hash>] Attachments array that should be validated (each entry `{name: String, sourceUrl?: String, sha256: String}`).
333
+ # @param require_source_url [Boolean] Whether a valid `sourceUrl` is mandatory on every entry (defaults to true).
334
+ #
335
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
336
+ def self.validate_attachments(attachments, require_source_url = true)
337
+ reasons = []
338
+ seen_names = {} # a dedup set keyed by the attachment name Strings (the port of `Object.create(null)`)
339
+
340
+ if !attachments.is_a?(Array) ||
341
+ attachments.length > Protocol::LIMITS[:attachmentsCount] ||
342
+ !attachments.all? do |f|
343
+ Helpers.has_only_known_properties(f, %i[name sourceUrl sha256]) &&
344
+ Helpers.is_normal_string(f[:name], 255) &&
345
+ ((!require_source_url && !f.key?(:sourceUrl)) || Helpers.is_canonical_url(f[:sourceUrl])) && # `=== undefined` is "member absent" (`key?`); a present nil member keeps the JS null outcome (rejected)
346
+ Helpers.is_string(f[:sha256]) && /\A[0-9a-f]{64}\z/.match?(f[:sha256]) &&
347
+ !seen_names[f[:name]] && (seen_names[f[:name]] = true)
348
+ end
349
+ reasons.push('invalid')
350
+ end
351
+
352
+ return expand_result('invalid_attachments', reasons)
353
+ end
354
+
355
+ # The binder names a signature may state inside its signed-metadata `bind` object: the verified
356
+ # fields the signature is bound to. Each names a field of the verified user signature and must
357
+ # equal it (see `Triauth.attest`).
358
+ #
359
+ # @return [Array<Symbol>]
360
+ BINDER_NAMES = %i[identifier via deviceTag].freeze
361
+
362
+ # Validates a signature segment's signed metadata against the binder grammar.
363
+ #
364
+ # A signature states what it is bound to in the `bind` object, and **every member of `bind` is
365
+ # critical**: a verifier must recognize it and it must match, so a constraint the producer signed
366
+ # can never be dropped without a trace. Members outside `bind` stay auxiliary and tolerant
367
+ # (an unrecognized one is ignored), so a compatible revision may still add signed metadata.
368
+ #
369
+ # @param signed_metadata [Hash] The signed metadata of a signature segment.
370
+ #
371
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
372
+ # Invalid (`invalid_signature`) when `bind` is present but is not an object of at least one recognized
373
+ # binder with a string value.
374
+ def self.validate_binders(signed_metadata)
375
+ if !signed_metadata.key?(:bind)
376
+ return expand_result('invalid_signature', []) # a signature that binds nothing is valid - attesters attest anonymously
377
+ end
378
+
379
+ bind = signed_metadata[:bind]
380
+ binders = bind.is_a?(Hash) ? bind.keys : nil
381
+
382
+ if binders.nil? ||
383
+ binders.length < 1 ||
384
+ !binders.all? { |binder| BINDER_NAMES.include?(binder) && Helpers.is_string(bind[binder]) }
385
+ return expand_result('invalid_signature', ['invalid'])
386
+ end
387
+
388
+ return expand_result('invalid_signature', [])
389
+ end
390
+
391
+ # Validates the `sessionKey` that gates stage 1 of `Triauth.ping`, `Triauth.sign`, `Triauth.stamp`, and `Triauth.attest`.
392
+ #
393
+ # A session key is the `sessionKey` of a `Triauth.authenticate` result, 32 bytes in 43 base64url characters.
394
+ # After a delegated sign-in it is `<domain>:<key>`, with the lowercase domain name of the actor.
395
+ #
396
+ # @param session_key [String] Session key that should be validated.
397
+ #
398
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
399
+ def self.validate_session_key(session_key)
400
+ parts = if Helpers.is_string(session_key) && session_key.length <= SESSION_KEY_MAX_LENGTH
401
+ session_key.empty? ? [''] : session_key.split(':', -1) # JS `''.split(':')` is `['']` where Ruby gives `[]`
402
+ else
403
+ []
404
+ end
405
+ domain, key = parts.length == 2 ? parts : [nil, parts[0]]
406
+
407
+ if parts.length < 1 || parts.length > 2 ||
408
+ (!domain.nil? && !(Helpers.is_domain_name(domain) && domain == domain.downcase)) ||
409
+ key.length != 43 || !Helpers.is_base64url_string(key)
410
+ return expand_result('invalid_session_key', ['invalid'])
411
+ end
412
+
413
+ # The canonical base64url of 32 bytes has no data in the unused bits of its last character
414
+ begin
415
+ Helpers.base64url_to_uint8(key)
416
+ rescue StandardError
417
+ return expand_result('invalid_session_key', ['invalid'])
418
+ end
419
+
420
+ return expand_result('invalid_session_key', [])
421
+ end
422
+
423
+ # Validates the provided device name's format, and returns human-friendly error messages if problems are found.
424
+ #
425
+ # Device names must be lowercase and composed of letters, numbers, and single hyphens between alphanumerics
426
+ # (no leading, trailing, or consecutive hyphens), within the allowed byte size.
427
+ #
428
+ # @param device_name [String] Device name that should be validated.
429
+ #
430
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
431
+ def self.validate_device_name(device_name)
432
+ return expand_result('invalid_device_name', name_errors(device_name))
433
+ end
434
+
435
+ # Validates the provided group name's format, and returns human-friendly error messages if problems are found.
436
+ #
437
+ # Group names follow the device-name grammar: lowercase letters, numbers, and single hyphens between alphanumerics
438
+ # (no leading, trailing, or consecutive hyphens), within the allowed byte size. The name is the bare name as published
439
+ # in a `groups` record (e.g., `admins`), not the fully-qualified form found in results (e.g., `admins@example.com`).
440
+ #
441
+ # @param group_name [String] Group name that should be validated.
442
+ #
443
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
444
+ def self.validate_group_name(group_name)
445
+ return expand_result('invalid_group_name', name_errors(group_name))
446
+ end
447
+
448
+ # Validates the provided lookup code's format.
449
+ #
450
+ # A lookup code is exactly 16 uppercase letters and digits (e.g., `K7QJ3FB9M2WZX0C4`). It addresses the records
451
+ # of an identity under a `mode=private` domain.
452
+ #
453
+ # @param lookup_code [String] Lookup code that should be validated.
454
+ #
455
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
456
+ def self.validate_lookup_code(lookup_code)
457
+ reasons = []
458
+
459
+ if !Helpers.is_lookup_code(lookup_code)
460
+ reasons.push('invalid')
461
+ end
462
+
463
+ return expand_result('invalid_lookup_code', reasons)
464
+ end
465
+
466
+ # Validates the provided device tag's format.
467
+ #
468
+ # A device tag is the `deviceTag` of a result: a 43-character base64url digest (e.g., `3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ`),
469
+ # suffixed with `~` and a lookup code under a `mode=private` domain. After a delegated sign-in it is
470
+ # `<grantTag>:<actorIdentifier>:<actorDeviceTag>`, and each of its two tags may carry such a suffix.
471
+ #
472
+ # @param device_tag [String] Device tag that should be validated.
473
+ #
474
+ # @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
475
+ def self.validate_device_tag(device_tag)
476
+ reasons = []
477
+ parts = Helpers.is_string(device_tag) && device_tag.length <= DEVICE_TAG_MAX_LENGTH ? device_tag.split(':', -1) : [] # JS `''.split(':')` is `['']` where Ruby gives `[]`: neither is 1 or 3 parts
478
+
479
+ if !(parts.length == 1 && is_reported_tag(parts[0])) &&
480
+ !(parts.length == 3 && is_reported_tag(parts[0]) && validate_identifier(parts[1])[:valid] && is_reported_tag(parts[2]))
481
+ reasons.push('invalid')
482
+ end
483
+
484
+ return expand_result('invalid_device_tag', reasons)
485
+ end
486
+
487
+ # Checks one tag component of a device tag: a 43-character base64url digest, optionally suffixed with `~` and a lookup code.
488
+ #
489
+ # @param component [String] Tag component that should be checked.
490
+ #
491
+ # @return [Boolean] true when the component is a well-formed tag
492
+ def self.is_reported_tag(component)
493
+ tag, lookup_code, *rest = component.empty? ? [''] : component.split('~', -1) # JS `''.split('~')` is `['']` where Ruby gives `[]`
494
+
495
+ if rest.length > 0 || tag.length != 43 || (!lookup_code.nil? && !Helpers.is_lookup_code(lookup_code))
496
+ return false
497
+ end
498
+
499
+ # The canonical base64url of a 32-byte digest has no data in the unused bits of its last character
500
+ begin
501
+ Helpers.base64url_to_uint8(tag)
502
+ rescue StandardError
503
+ return false
504
+ end
505
+
506
+ return true
507
+ end
508
+ private_class_method :is_reported_tag
509
+
510
+ # Checks a name against the device-name grammar, shared by device names and group names.
511
+ #
512
+ # @param name [Object] Name that should be validated.
513
+ #
514
+ # @return [Array<String>] The reasons found, empty when the name is valid.
515
+ def self.name_errors(name)
516
+ reasons = []
517
+
518
+ if !Helpers.is_string(name)
519
+ return ['invalid']
520
+ end
521
+
522
+ if name.length <= 0
523
+ reasons.push('empty')
524
+ end
525
+
526
+ if name.length > Protocol::LIMITS[:deviceNameBytesize] || Helpers.byte_size(name) > Protocol::LIMITS[:deviceNameBytesize]
527
+ return ['too_long']
528
+ end
529
+
530
+ # The name must not contain any whitespace characters and/or NULL
531
+ if /[#{Helpers::JS_WS}\0]/o.match?(name)
532
+ reasons.push('invalid')
533
+ end
534
+
535
+ if name != name.downcase
536
+ reasons.push('not_lowercase')
537
+ end
538
+
539
+ # Lowercase alphanumerics joined by single hyphens: the name must start and end on an alphanumeric
540
+ # and carry no consecutive hyphens.
541
+ if !/\A[a-z0-9]+(-[a-z0-9]+)*\z/.match?(name)
542
+ reasons.push('invalid_characters')
543
+ end
544
+
545
+ return reasons
546
+ end
547
+ private_class_method :name_errors
548
+ end
549
+ end