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,1222 @@
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 'openssl'
22
+ require 'securerandom'
23
+ require 'net/http'
24
+ require 'uri'
25
+ require_relative 'protocol'
26
+ require_relative 'config'
27
+
28
+ module Triauth
29
+ # Static helpers shared across the library.
30
+ class Helpers
31
+ BASE64URL_REGEXP = /\A[A-Za-z0-9\-_]+\z/
32
+ BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
33
+ BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
34
+ BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
35
+ BASE64URL_VALUES = lambda do
36
+ values = Array.new(123, 0) # indexed by char code; 'z' (the highest) is 122
37
+ 64.times do |i|
38
+ values[BASE64URL_ALPHABET.getbyte(i)] = i
39
+ end
40
+ return values
41
+ end.call
42
+ DOMAIN_NAME_REGEXP = /\A(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9]\z/
43
+ XN_LABEL_REGEXP = /(\A[xX][nN]--)|(\.[xX][nN]--)/ # a Punycode (xn--) label, leading or after a dot
44
+ URL_HOST_LABEL_REGEXP = /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/ # one lowercase LDH host label, 1-63 chars, no edge hyphens
45
+ URL_NUMERIC_LABEL_REGEXP = /\A(?:[0-9]+|0[xX][0-9a-fA-F]*)\z/ # an all-digits or 0x-hex label, an IPv4 notation when final
46
+ URL_IPV4_QUAD_REGEXP = /\A(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\z/ # a canonical dotted-quad IPv4 host: four decimal octets 0-255, no leading zeros
47
+ URL_DOT_SEGMENT_REGEXP = /\A(?:\.|%2[eE]){1,2}\z/ # a "." or ".." path segment, %2e spellings included
48
+ URL_PATH_REGEXP = %r{\A[A-Za-z0-9\-._~!$&'()*+,;=:@/%]*\z} # RFC 3986 pchar + "/" ("%" is a plain char here; "|" is not a URL character)
49
+ URL_QUERY_REGEXP = %r{\A[A-Za-z0-9\-._~!$&'()*+,;=:@/?%]+\z} # the path characters + "?" also the fragment charset
50
+ NORMAL_STRING_REGEXP = %r{\A[\p{L}\p{N} ~!@#$%^&*()_\-+={}\[\]|\\:;"'<,>.?/]+\z} # the Unicode property escape \p{L} matches any kind of letter from any language, \p{N} any number.
51
+ # `UNPAIRED_SURROGATE_REGEXP` (`\p{Cs}`) has no counterpart: an ill-formed string is "not a string" (`Helpers.is_string`, the encoding-validity gate behind `is_well_formed_string`)
52
+ LOOKUP_CODE_REGEXP = /\A[A-Z0-9]{16}\z/
53
+ PROTOTYPE_NAMES = %w[__proto__ constructor prototype].freeze # the prototype-poisoning names, never a JSON member name
54
+ P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff # the P-256 field prime p
55
+ # `#P256_B` (the P-256 curve coefficient b, a = -3) has no counterpart: the curve equation is OpenSSL's (`decode_p256_public_key`)
56
+ # The json gem parses a wider grammar than `JSON.parse` of JS: `/* */` and `//` comments between tokens, an unknown
57
+ # escape such as `\x` inside a string (read as `x`), and a high-surrogate `\u` escape paired with whatever `\u` escape
58
+ # follows it (a lone high one it refuses, a lone low one it spells as ill-formed bytes). This gate admits only the JS
59
+ # grammar of the first two constructs - outside a string literal no `/`, inside one only the JSON escapes - and refuses
60
+ # every surrogate escape outside a high-then-low pair: the reference parses such an escape into a surrogate code point
61
+ # and refuses the string (`safe_parse_json`, the string-value check), which no Ruby String can hold, so the same inputs
62
+ # are a parse failure here, on the source (a pair is one code point on both sides). The gate is linear in the source
63
+ # length: every alternative is decided by a bounded prefix (its first character, or the six characters of a `\u` escape).
64
+ JSON_STRICT_SOURCE_REGEXP = %r{\A(?:[^"/]|"(?:[^"\\]|\\["\\/bfnrt]|\\u[Dd][89ABab][0-9A-Fa-f]{2}\\u[Dd][C-Fc-f][0-9A-Fa-f]{2}|\\u(?![Dd][89A-Fa-f])[0-9A-Fa-f]{4})*")*\z}
65
+ private_constant :BASE64URL_REGEXP, :BASE32_ALPHABET, :BASE64_ALPHABET, :BASE64URL_ALPHABET, :BASE64URL_VALUES,
66
+ :DOMAIN_NAME_REGEXP, :XN_LABEL_REGEXP, :URL_HOST_LABEL_REGEXP, :URL_NUMERIC_LABEL_REGEXP,
67
+ :URL_IPV4_QUAD_REGEXP, :URL_DOT_SEGMENT_REGEXP, :URL_PATH_REGEXP, :URL_QUERY_REGEXP,
68
+ :NORMAL_STRING_REGEXP, :LOOKUP_CODE_REGEXP, :PROTOTYPE_NAMES, :P256_P, :JSON_STRICT_SOURCE_REGEXP
69
+
70
+ # `#ENCODER` and `#DECODER` have no counterpart: `string_to_utf8_bytes` and `utf8_bytes_to_string` work on the String itself
71
+
72
+ # Returns the current time in milliseconds, for duration measurements.
73
+ #
74
+ # Monotonic (`Process::CLOCK_MONOTONIC`); durations only, never the wall clock.
75
+ #
76
+ # @return [Float] a millisecond timestamp.
77
+ def self.now
78
+ # the `?? Date.now()` fallback has no counterpart: `CLOCK_MONOTONIC` always exists
79
+ return Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
80
+ end
81
+
82
+ # Returns the earlier of two timestamps, tolerating either being nil.
83
+ #
84
+ # @param a [Integer, nil] a timestamp, or nil.
85
+ # @param b [Integer, nil] a timestamp, or nil.
86
+ #
87
+ # @return [Integer, nil] the earlier of the two, or whichever one is set.
88
+ def self.earliest(a, b)
89
+ return [a, b].min if !a.nil? && !b.nil?
90
+
91
+ return a.nil? ? b : a
92
+ end
93
+
94
+ # Verifies DNS record options against a schema of allowed critical options and values.
95
+ #
96
+ # Non-`x-` prefixed options are critical: each must be whitelisted in the schema and match one of its
97
+ # allowed values (strings, regexps, or predicate callables returning exactly `true`). `x-` prefixed
98
+ # options are experimental and pass as long as they are normal strings.
99
+ #
100
+ # @param options [Hash{Symbol => Object}] the options as parsed from a DNS record.
101
+ # @param options_schema [Hash{Symbol => Array<String, Regexp, Proc>}] a map of option keys to arrays of allowed values (strings, regexps, or predicates).
102
+ #
103
+ # @return [Boolean] true when every option is acceptable, false otherwise.
104
+ def self.verify_options_schema(options, options_schema)
105
+ options.each do |option_key, option_val|
106
+ # whitelisted critical options and values
107
+ next if Helpers.is_string(option_val) &&
108
+ options_schema.key?(option_key) &&
109
+ options_schema[option_key].any? { |v| (Helpers.is_string(v) && option_val == v) || (v.is_a?(Regexp) && v.match?(option_val)) || (v.respond_to?(:call) && v.call(option_val) == true) }
110
+
111
+ # experimental options prefixed with 'x-' are allowed as long as they are generally sane
112
+ next if Helpers.is_normal_string(option_key.to_s) &&
113
+ Helpers.is_normal_string(option_val) &&
114
+ option_key.to_s[0] == 'x' &&
115
+ option_key.to_s[1] == '-'
116
+
117
+ # unknown or invalid critical option - reject
118
+ return false
119
+ end
120
+
121
+ return true
122
+ end
123
+
124
+ # Returns a deep copy of the given JSON-shaped value (API results, parsed records).
125
+ #
126
+ # A recursive copy of `Hash`, `Array` and `String` values; other immediates are returned as they are.
127
+ #
128
+ # @param value [Object] A JSON-shaped value to copy.
129
+ #
130
+ # @return [Object] a deep copy sharing no mutable state with the input.
131
+ def self.clone(value)
132
+ # the `structuredClone` detection and the JSON round-trip have no counterpart: the recursive copy below is the port of both
133
+ case value
134
+ when Hash
135
+ return value.to_h { |k, v| [k, clone(v)] }
136
+ when Array
137
+ return value.map { |v| clone(v) }
138
+ when String
139
+ return value.dup
140
+ else
141
+ return value
142
+ end
143
+ end
144
+
145
+ # Encodes a given byte array into a Base32 encoded string.
146
+ #
147
+ # @param b [String] A byte array (binary String) containing the message to be encoded as a base32 string.
148
+ #
149
+ # @return [String] base32 representation of the given byte array
150
+ def self.uint8_array_to_base32(b)
151
+ alphabet = BASE32_ALPHABET
152
+ bytes = b
153
+
154
+ bits = 0
155
+ value = 0
156
+ rv = +""
157
+
158
+ bytes.bytesize.times do |i|
159
+ value = ((value << 8) | bytes.getbyte(i)) & 0xFFFFFFFF # the JS `<<` works on 32-bit integers: the mask keeps the accumulator bounded the same way
160
+ bits += 8
161
+
162
+ # 5-bits per character in base32
163
+ while bits >= 5
164
+ rv << alphabet[(value >> (bits - 5)) & 31]
165
+ bits -= 5
166
+ end
167
+ end
168
+
169
+ # anything that's left needs to be also encoded
170
+ if bits > 0
171
+ rv << alphabet[(value << (5 - bits)) & 31]
172
+ end
173
+
174
+ return rv
175
+ end
176
+
177
+ # Encodes a given byte array buffer into a Base64 encoded string.
178
+ #
179
+ # @param b [String] A byte array (binary String) that contains the message (bytes) to be encoded as base64 string.
180
+ #
181
+ # @return [String] base64 representation of the given array buffer
182
+ def self.array_buffer_to_base64(b)
183
+ alphabet = BASE64_ALPHABET
184
+ bytes = b # `new Uint8Array(b)`: the bytes are read from the String itself
185
+
186
+ rv = +''
187
+
188
+ # 3 bytes make 4 characters; a final partial group is '='-padded
189
+ (0...bytes.bytesize).step(3) do |i|
190
+ b0 = bytes.getbyte(i)
191
+ b1 = i + 1 < bytes.bytesize ? bytes.getbyte(i + 1) : 0
192
+ b2 = i + 2 < bytes.bytesize ? bytes.getbyte(i + 2) : 0
193
+
194
+ rv << alphabet[b0 >> 2]
195
+ rv << alphabet[((b0 & 0x03) << 4) | (b1 >> 4)]
196
+ rv << (i + 1 < bytes.bytesize ? alphabet[((b1 & 0x0f) << 2) | (b2 >> 6)] : '=')
197
+ rv << (i + 2 < bytes.bytesize ? alphabet[b2 & 0x3f] : '=')
198
+ end
199
+
200
+ return rv
201
+ end
202
+
203
+ # Encodes a given byte array buffer into a Base64URL encoded string.
204
+ #
205
+ # @param b [String] A byte array (binary String) that contains the message (bytes) to be encoded as base64url string.
206
+ #
207
+ # @return [String] base64url representation of the given array buffer
208
+ def self.array_buffer_to_base64url(b)
209
+ rv = array_buffer_to_base64(b)
210
+ return rv.gsub('+', '-').gsub('/', '_').sub(/=+\z/, '')
211
+ end
212
+
213
+ # Converts the passed base64url encoded string into a binary String of its individual bytes.
214
+ #
215
+ # @param s [String] A base64url encoded string.
216
+ #
217
+ # @return [String] a binary String with byte values representing the characters of passed string.
218
+ #
219
+ # @raise [TypeError] when the string is not base64url, or is a non-canonical encoding
220
+ # (an impossible length, or payload in the final character's unused bits).
221
+ def self.base64url_to_uint8(s)
222
+ # Canonical decode: only the padding-free base64url alphabet, only
223
+ # possible lengths, and no payload in the final character's unused bits —
224
+ # every byte sequence has exactly one accepted encoding.
225
+ if !Helpers.is_string(s) || s.length % 4 == 1 || !/\A[A-Za-z0-9\-_]*\z/.match?(s)
226
+ raise TypeError, 'Invalid base64url input'
227
+ end
228
+
229
+ values = BASE64URL_VALUES
230
+ rv = Array.new(s.length * 3 / 4, 0).pack('C*') # `Math.floor(s.length * 3 / 4)`: the Integer division floors
231
+
232
+ bits = 0
233
+ value = 0
234
+ index = 0
235
+
236
+ s.length.times do |i|
237
+ value = ((value << 6) | values[s.getbyte(i)]) & 0xFFFFFFFF # the JS `<<` works on 32-bit integers: the mask keeps the accumulator bounded the same way
238
+ bits += 6
239
+
240
+ # 8 accumulated bits form a byte
241
+ if bits >= 8
242
+ bits -= 8
243
+ rv.setbyte(index, (value >> bits) & 0xff)
244
+ index += 1
245
+ end
246
+ end
247
+
248
+ # Trailing bits of a final partial group must be zero, or the same bytes
249
+ # would have more than one encoding.
250
+ if bits > 0 && value.anybits?((1 << bits) - 1)
251
+ raise TypeError, 'Non-canonical base64url input'
252
+ end
253
+
254
+ return rv
255
+ end
256
+
257
+ # Encodes the passed string into base64url encoding of its UTF-8 bytes.
258
+ #
259
+ # Any well-formed string is encodable (emojis, non-latin scripts, etc.). Raises a TypeError
260
+ # on an ill-formed string, the counterpart of an unpaired surrogate — a surrogate code point has no UTF-8 encoding,
261
+ # and an ill-formed String no byte encoding to publish. Unreachable through
262
+ # the protocol surface: every internal call site encodes JSON.generate output, which
263
+ # is always well-formed.
264
+ #
265
+ # @param s [String] A string to encode.
266
+ #
267
+ # @return [String] encoded string.
268
+ def self.string_to_base64url(s)
269
+ return array_buffer_to_base64url(string_to_utf8_bytes(s))
270
+ end
271
+
272
+ # Decodes the passed base64url encoded string, interpreting the decoded bytes as UTF-8.
273
+ #
274
+ # Raises a TypeError on malformed base64url input and on bytes that are not well-formed
275
+ # UTF-8. A leading byte-order mark is preserved, not stripped: U+FEFF stays byte-visible,
276
+ # so every byte sequence has exactly one decoded spelling.
277
+ #
278
+ # @param s [String] A string to decode.
279
+ #
280
+ # @return [String] decoded string.
281
+ def self.base64url_to_string(s)
282
+ return utf8_bytes_to_string(base64url_to_uint8(s))
283
+ end
284
+
285
+ # Encodes the passed string into its UTF-8 bytes.
286
+ #
287
+ # Raises a TypeError on an ill-formed string, the counterpart of an unpaired surrogate — a surrogate code point has no
288
+ # UTF-8 encoding, and substituting U+FFFD would let two distinct strings share one byte encoding (the
289
+ # platform's transcoders substitute, so the well-formedness gate lives here).
290
+ #
291
+ # @param s [String] A string to encode. Anything else raises.
292
+ #
293
+ # @return [String] the UTF-8 bytes of the string (a binary String).
294
+ #
295
+ # @raise [TypeError] when the value is not a string, or the string is ill-formed.
296
+ def self.string_to_utf8_bytes(s)
297
+ # an ill-formed String is "not a string" (`is_well_formed_string` is the encoding-validity test of `is_string`): the
298
+ # `Unpaired surrogate` TypeError has no separate counterpart
299
+ if !Helpers.is_well_formed_string(s)
300
+ raise TypeError, 'stringToUtf8Bytes expects a string'
301
+ end
302
+
303
+ return s.b
304
+ end
305
+
306
+ # Decodes the passed UTF-8 bytes into a string.
307
+ #
308
+ # Strict: raises a TypeError on any ill-formed sequence — overlong encodings, encoded
309
+ # surrogates, code points above U+10FFFF, truncated or stray continuation bytes. A leading
310
+ # byte-order mark is preserved, not stripped.
311
+ #
312
+ # Backed by the runtime's encoding validity check (`String#valid_encoding?`). The strict-decode contract itself is
313
+ # pinned by the conformance vectors, so ports use their own stdlib's strict decoder.
314
+ #
315
+ # @param bytes [String] The UTF-8 bytes to decode (a binary String). Anything else raises.
316
+ #
317
+ # @return [String] the decoded string.
318
+ #
319
+ # @raise [TypeError] when the value is not a String, or the bytes are not well-formed UTF-8.
320
+ def self.utf8_bytes_to_string(bytes)
321
+ if !bytes.is_a?(String)
322
+ raise TypeError, 'utf8BytesToString expects a Uint8Array'
323
+ end
324
+
325
+ str = bytes.dup.force_encoding(Encoding::UTF_8)
326
+ if !str.valid_encoding?
327
+ raise TypeError, 'Ill-formed UTF-8 bytes'
328
+ end
329
+
330
+ return str
331
+ end
332
+
333
+ # Lowercases only the ASCII letters `A`-`Z` of the passed string, applying no other case mapping.
334
+ #
335
+ # Wire values that are "lowercased" before an ASCII grammar must use this instead of
336
+ # String#downcase: the full Unicode case mapping would fold characters such as
337
+ # U+212A KELVIN SIGN into ASCII (`k`), silently widening the wire alphabet in a way
338
+ # other implementations (ASCII tolower) would not reproduce.
339
+ #
340
+ # @param string [String] A string to map.
341
+ #
342
+ # @return [String] the string with `A`-`Z` mapped to `a`-`z` and every other character intact.
343
+ def self.ascii_lowercase(string)
344
+ return string.tr('A-Z', 'a-z')
345
+ end
346
+
347
+ # Percent-decodes the passed string into utf-8.
348
+ #
349
+ # @param string [String] A string to decode.
350
+ #
351
+ # @return [String, nil] the decoded string, or nil when an escape is malformed (a `%` not
352
+ # followed by two hexadecimal digits) or decodes to ill-formed UTF-8.
353
+ def self.percent_decode(string)
354
+ # the port of `decodeURIComponent`: a `%XX` escape is its byte, every other character is copied through
355
+ # (`+` included) and the result must be well-formed UTF-8; a value that is not a string has no decoding here
356
+ if !Helpers.is_string(string)
357
+ return nil
358
+ end
359
+
360
+ # Reject malformed percent escapes.
361
+ if /%(?![0-9A-Fa-f]{2})/.match?(string)
362
+ return nil
363
+ end
364
+
365
+ decoded = string.b.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }
366
+ decoded.force_encoding(Encoding::UTF_8)
367
+ return decoded.valid_encoding? ? decoded : nil
368
+ end
369
+
370
+ # Checks that the passed string carries no surrogate code point (U+D800 to U+DFFF), so that it has a UTF-8 encoding.
371
+ #
372
+ # Every string takes this check before it is encoded, and every string parsed from JSON text is held to
373
+ # the same rule: the `\uXXXX` escape of a surrogate is the one spelling of such a string that passes the
374
+ # strict UTF-8 decoder, because the JSON text itself is plain ASCII. The name mirrors the ES2024
375
+ # `String.prototype.isWellFormed`, which performs the same test.
376
+ #
377
+ # This is the Ruby form of the reference test: a Ruby String cannot hold a surrogate code point, so "carries no
378
+ # surrogate code point, so it has a UTF-8 encoding" is the encoding test of {Triauth::Helpers.is_string} (valid UTF-8,
379
+ # or ASCII-only in another ASCII-compatible encoding) applied to a value that is a String; an ill-formed String (an
380
+ # encoded surrogate included) is the counterpart of a JS string that carries one.
381
+ #
382
+ # @param string [String] A string to check.
383
+ #
384
+ # @return [Boolean] true if the value is a string without surrogate code points, false otherwise.
385
+ def self.is_well_formed_string(string)
386
+ # `typeof string === 'string'` is `is_a?(String)` here, and `!UNPAIRED_SURROGATE_REGEXP.test(string)` is the
387
+ # encoding validity of `is_string`
388
+ return string.is_a?(String) && Helpers.is_string(string)
389
+ end
390
+
391
+ # Checks that the passed string does not contain fancy utf-8 characters such as emojis, right-to-left encoding, etc.
392
+ #
393
+ # This is useful in validation of user-provided identifiers and/or other strings that may be displayed in the UI.
394
+ #
395
+ # @param string [String] A string to check.
396
+ # @param bytesize_limit [Integer, nil] The size limit for the string (counted in bytes). nil for unbound. Defaults to 255.
397
+ #
398
+ # @return [Boolean] true if string contains only 'normal' characters, false otherwise.
399
+ def self.is_normal_string(string, bytesize_limit = 255)
400
+ # Ensure that we are dealing with a string
401
+ if !Helpers.is_string(string)
402
+ return false
403
+ end
404
+
405
+ # If bytesizeLimit is set, enforce it
406
+ if !bytesize_limit.nil? && bytesize_limit != 0 && byte_size(string) > bytesize_limit
407
+ return false
408
+ end
409
+
410
+ return NORMAL_STRING_REGEXP.match?(string)
411
+ end
412
+
413
+ # Checks whether any label of the given hostname (or domain name) is in Punycode form.
414
+ #
415
+ # Punycode/IDN labels [RFC 3492] are blocked throughout the protocol, in identifiers, URLs,
416
+ # and endpoint hosts alike, to keep look-alike "homograph" domains out of everything a user
417
+ # may be shown.
418
+ #
419
+ # @param string [String] A hostname or domain name to check.
420
+ #
421
+ # @return [Boolean] true if the string carries an `xn--` label, false otherwise.
422
+ def self.has_punycode_label(string)
423
+ return Helpers.is_string(string) && XN_LABEL_REGEXP.match?(string)
424
+ end
425
+
426
+ # Parses an absolute http(s) URL given in its canonical spelling, and returns its components.
427
+ #
428
+ # This is a small subset of RFC 3986 admitting exactly one spelling per resource.
429
+ # Owning it makes the accepted set identical on every engine (cross-engine URL parser
430
+ # differentials are a classic source of origin confusion).
431
+ #
432
+ # The grammar:
433
+ # - scheme: the literal `https://` or `http://`,
434
+ # - host: dot-separated lowercase LDH labels (letters/digits/hyphens, 1–63 chars, no
435
+ # leading/trailing hyphen, no empty labels) whose final label is not numeric (all digits,
436
+ # or `0x` + hex digits) — or a canonical dotted-quad IPv4 literal (four decimal octets
437
+ # 0–255, no leading zeros). Every other IPv4 spelling (hex, octal, dword, short forms)
438
+ # matches neither production; `[` is not in the grammar, so IPv6 literals are out as well,
439
+ # - port: optional; 1–65535 with no leading zeros, never the scheme's default,
440
+ # - userinfo: none (`@` cannot appear before the path),
441
+ # - path: required and absolute; characters from the RFC 3986 `pchar` set plus `/` and `%`
442
+ # (`|` is not an RFC 3986 URL character and never appears); no `.`/`..` segments in any
443
+ # spelling (`%2e` forms included),
444
+ # - query and fragment: optional and non-empty, over the path characters plus `?`.
445
+ #
446
+ # `%` is a plain character at this layer; the policy wrapper below rejects malformed escapes
447
+ # before parsing. This parser is reachable only through that wrapper, so grammar and policy
448
+ # always travel together.
449
+ #
450
+ # @param string [String] The URL string to parse.
451
+ #
452
+ # @return [Hash{Symbol => Object}, nil] The URL's components (a frozen Hash with the keys `scheme`, `host`, `port`,
453
+ # `path`, `query`, `fragment`, `origin`, `base`), or nil when the string is not a canonical URL. `port` is `''` and
454
+ # `query`/`fragment` are nil when absent; `origin` carries no trailing slash; `base` is the
455
+ # origin plus the path up to and including its last `/`.
456
+ def self.parse_canonical_url(string)
457
+ if !Helpers.is_string(string)
458
+ return nil
459
+ end
460
+
461
+ # Scheme — the lowercase spelling only
462
+ scheme = nil
463
+ if string.start_with?('https://')
464
+ scheme = 'https'
465
+ elsif string.start_with?('http://')
466
+ scheme = 'http'
467
+ else
468
+ return nil
469
+ end
470
+
471
+ tail = string[(scheme.length + 3)..]
472
+
473
+ # The authority runs up to the first '/', '?' or '#'
474
+ authority_end = tail.index(%r{[/?#]})
475
+ authority = authority_end.nil? ? tail : tail[0...authority_end]
476
+ tail = authority_end.nil? ? '' : tail[authority_end..]
477
+
478
+ if authority == '' || authority.include?('@')
479
+ return nil
480
+ end
481
+
482
+ # Optional port
483
+ host = authority
484
+ port = ''
485
+ port_at = authority.rindex(':')
486
+ if !port_at.nil?
487
+ port = authority[(port_at + 1)..]
488
+ host = authority[0...port_at]
489
+ if !/\A[1-9][0-9]{0,4}\z/.match?(port) || # 1-65535: no leading zeros, and port 0 is not a destination
490
+ port.to_i > 65_535 ||
491
+ port == (scheme == 'https' ? '443' : '80') # an explicit default port is not canonical
492
+ return nil
493
+ end
494
+ end
495
+
496
+ # Host — a named host (LDH labels, non-numeric final label) or a canonical dotted-quad
497
+ # IPv4 literal; every exotic IPv4 spelling matches neither production
498
+ labels = host.empty? ? [''] : host.split('.', -1) # JS `''.split('.')` is `['']`
499
+ if !URL_IPV4_QUAD_REGEXP.match?(host) &&
500
+ (
501
+ !labels.all? { |label| URL_HOST_LABEL_REGEXP.match?(label) } ||
502
+ URL_NUMERIC_LABEL_REGEXP.match?(labels[-1])
503
+ )
504
+ return nil
505
+ end
506
+
507
+ # Fragment and query — split off in that order, so neither can hide in the other
508
+ fragment = nil
509
+ fragment_at = tail.index('#')
510
+ if !fragment_at.nil?
511
+ fragment = tail[(fragment_at + 1)..]
512
+ tail = tail[0...fragment_at]
513
+ if !URL_QUERY_REGEXP.match?(fragment)
514
+ return nil
515
+ end
516
+ end
517
+
518
+ query = nil
519
+ query_at = tail.index('?')
520
+ if !query_at.nil?
521
+ query = tail[(query_at + 1)..]
522
+ tail = tail[0...query_at]
523
+ if !URL_QUERY_REGEXP.match?(query)
524
+ return nil
525
+ end
526
+ end
527
+
528
+ # Path — what remains
529
+ path = tail
530
+ if path[0] != '/' ||
531
+ !URL_PATH_REGEXP.match?(path) ||
532
+ path.split('/', -1).any? { |segment| URL_DOT_SEGMENT_REGEXP.match?(segment) }
533
+ return nil
534
+ end
535
+
536
+ origin = "#{scheme}://#{host}#{":#{port}" unless port.empty?}"
537
+ base = origin + path[0, path.rindex('/') + 1]
538
+
539
+ return { scheme: scheme, host: host, port: port, path: path, query: query, fragment: fragment, origin: origin, base: base }.freeze
540
+ end
541
+ private_class_method :parse_canonical_url
542
+
543
+ # Checks that the passed string is a canonical, safe-to-display URL.
544
+ #
545
+ # A string passes only when it is:
546
+ # - non-empty and within `LIMITS[:urlBytesize]`,
547
+ # - printable ASCII only (no spaces or control characters),
548
+ # - free of malformed `%`-escapes (every `%` is followed by two hex digits),
549
+ # - a canonical `http` or `https` URL per the closed grammar (a lowercase LDH host with a
550
+ # non-numeric final label, or a canonical dotted-quad IPv4 literal — no other IP spelling,
551
+ # no IPv6; optional non-default port; absolute path; optional query and fragment; no
552
+ # userinfo; the RFC 3986 `pchar` charset, which has no `|`; no `.`/`..`/`%2e` path
553
+ # segments), and
554
+ # - free of Punycode/IDN `xn--` labels (homograph look-alikes).
555
+ #
556
+ # Used to validate caller-supplied URLs that are later embedded in signatures or shown to the
557
+ # user: the `callbackUrl` (whose base-URL span carries the extra `;` rule at the validator) and
558
+ # attachment `sourceUrl`s. URLs that act as verbatim trust anchors — attestation provider URLs —
559
+ # use the narrower {Triauth::Helpers.is_secure_url} instead.
560
+ #
561
+ # @param string [String] A string to check.
562
+ #
563
+ # @return [Boolean] true if the string is a canonical URL meeting all the above constraints, false otherwise.
564
+ def self.is_canonical_url(string)
565
+ return !canonical_url(string).nil?
566
+ end
567
+
568
+ # Checks that the passed string is a canonical URL (see {Triauth::Helpers.is_canonical_url})
569
+ # on a secure origin: an `https` URL with a named (non-IP-literal) host — or, for local
570
+ # development, an `http` URL on the literal host `localhost`.
571
+ #
572
+ # This is the acceptance rule for URLs used verbatim as trust anchors: attestation provider
573
+ # URLs, which are displayed to the user as the trust decision and embedded whole as the
574
+ # attestation Segment's `via`. Callback URLs take the wider {Triauth::Helpers.is_canonical_url}
575
+ # instead — only their base URL enters the envelope, under its own delimiter rule.
576
+ #
577
+ # Every string this accepts is also accepted by `is_canonical_url`: both are views over the same
578
+ # parse, so the two accept-sets cannot diverge.
579
+ #
580
+ # @param string [String] A string to check.
581
+ #
582
+ # @return [Boolean] true if the string is a canonical URL on a secure origin, false otherwise.
583
+ def self.is_secure_url(string)
584
+ url = canonical_url(string)
585
+
586
+ return !url.nil? &&
587
+ !URL_IPV4_QUAD_REGEXP.match?(url[:host]) &&
588
+ (url[:scheme] == 'https' || url[:host] == 'localhost')
589
+ end
590
+
591
+ # The single accept-set behind every canonical-URL helper: the closed grammar of
592
+ # `parse_canonical_url` plus the protocol policy — size cap, printable
593
+ # ASCII, well-formed `%`-escapes, and no Punycode label.
594
+ #
595
+ # `is_canonical_url`, `is_secure_url`, `get_base_url`, and `url_host` are all views over this one
596
+ # function (`is_secure_url` a narrowing one), so no URL can ever pass one of them while failing
597
+ # another's underlying parse.
598
+ #
599
+ # @param string [String] A string to check and parse.
600
+ #
601
+ # @return [Hash{Symbol => Object}, nil] The parsed components (see `parse_canonical_url`),
602
+ # or nil when the string is not a canonical URL under the full protocol rules.
603
+ def self.canonical_url(string)
604
+ # Ensure that we are dealing with a string
605
+ if !Helpers.is_string(string)
606
+ return nil
607
+ end
608
+
609
+ # Ensure size limits
610
+ if string.length == 0 || byte_size(string) > Protocol::LIMITS[:urlBytesize]
611
+ return nil
612
+ end
613
+
614
+ # ASCII only
615
+ if !/\A[\x21-\x7E]+\z/.match?(string)
616
+ return nil
617
+ end
618
+
619
+ # Reject malformed percent escapes.
620
+ if /%(?![0-9A-Fa-f]{2})/.match?(string)
621
+ return nil
622
+ end
623
+
624
+ # The string must be a canonical URL per the closed grammar
625
+ url = parse_canonical_url(string)
626
+ if url.nil?
627
+ return nil
628
+ end
629
+
630
+ # Block Punycode/IDN homographs (RFC 3492)
631
+ if has_punycode_label(url[:host])
632
+ return nil
633
+ end
634
+
635
+ return url
636
+ end
637
+ private_class_method :canonical_url
638
+
639
+ # Checks that the passed string uses only characters from base64URL alphabet.
640
+ #
641
+ # This method is intended for quick validation and is not bullet-proof - e.g., may not check that the string is properly base64URL encoded.
642
+ #
643
+ # @param string [String] A string to check.
644
+ #
645
+ # @return [Boolean] true if string contains only base64url characters, false otherwise.
646
+ def self.is_base64url_string(string)
647
+ return Helpers.is_string(string) && BASE64URL_REGEXP.match?(string)
648
+ end
649
+
650
+ # Checks that the passed string is a valid domain name.
651
+ #
652
+ # Accepts case-insensitive LDH domain names of at least two labels. Labels in Punycode form
653
+ # (`xn--`) are rejected (homograph look-alikes), and so is a numeric final label (all digits,
654
+ # or `0x` + hex digits) — real TLDs are never numeric, and this keeps every IPv4 spelling,
655
+ # exotic notations included, from ever being taken for a domain name.
656
+ #
657
+ # This method is intended for quick validation and is not bullet-proof - e.g., exotic domain names may not be recognized
658
+ #
659
+ # @param string [String] A string to check.
660
+ #
661
+ # @return [Boolean] true if string is a domain name, false otherwise.
662
+ def self.is_domain_name(string)
663
+ return Helpers.is_string(string) &&
664
+ byte_size(string) <= Protocol::LIMITS[:domainNameBytesize] &&
665
+ DOMAIN_NAME_REGEXP.match?(string) &&
666
+ !has_punycode_label(string) &&
667
+ !URL_NUMERIC_LABEL_REGEXP.match?(string[(string.rindex('.') + 1)..])
668
+ end
669
+
670
+ # Returns the base URL of the given canonical URL string: its origin plus the path up to and
671
+ # including the last `/`, with query and fragment removed.
672
+ #
673
+ # For example, `https://example.com/foo/bar` returns `https://example.com/foo/`, while
674
+ # `https://example.com/foo/` returns `https://example.com/foo/` unchanged.
675
+ #
676
+ # The base URL is what the authenticator signs into the `via` field of a signature envelope,
677
+ # so this accepts only canonical URLs (see {Triauth::Helpers.is_canonical_url}) — for
678
+ # anything else it returns `false` rather than a normalized guess.
679
+ #
680
+ # @param url [String] A canonical URL string to process.
681
+ #
682
+ # @return [String, false] The base URL with a trailing slash, or `false` if the input is not a canonical URL.
683
+ def self.get_base_url(url)
684
+ parsed = canonical_url(url)
685
+ return parsed.nil? ? false : parsed[:base]
686
+ end
687
+
688
+ # Returns the host (without the port) of the given canonical URL string.
689
+ #
690
+ # @param url [String] A canonical URL string to process.
691
+ #
692
+ # @return [String, false] The lowercase hostname, or `false` if the input is not a canonical URL.
693
+ def self.url_host(url)
694
+ parsed = canonical_url(url)
695
+ return parsed.nil? ? false : parsed[:host]
696
+ end
697
+
698
+ # Calculates the SHA-256 digest of the passed string.
699
+ #
700
+ # @param message [String] A message whose SHA-256 digest should be calculated.
701
+ # @return [String] the sha256 of the given message (base64url).
702
+ def self.sha256(message)
703
+ data = string_to_utf8_bytes(message)
704
+
705
+ hash = OpenSSL::Digest::SHA256.digest(data)
706
+ return array_buffer_to_base64url(hash)
707
+ end
708
+
709
+ # Computes the HMAC-SHA-256 of a message under a key.
710
+ #
711
+ # Keys the private-mode identity-domain derivation (`Triauth::IdentityDomain.derive_full_label`).
712
+ #
713
+ # @param key [String] The key bytes (a binary String).
714
+ # @param message [String] The message bytes (a binary String).
715
+ # @return [String] the 32-byte MAC (a binary String).
716
+ def self.hmac_sha256(key, message)
717
+ return OpenSSL::HMAC.digest('SHA256', key, message)
718
+ end
719
+
720
+ # Returns a P-256 public key as the 65-byte uncompressed point (`0x04`, x, y), the form that the Web Crypto
721
+ # `raw` import accepts on all runtimes, and the form that `Verifiers::Base.import_key` accepts.
722
+ #
723
+ # Accepts the uncompressed point (65 bytes, prefix `0x04`) and returns it unchanged. Accepts the compressed
724
+ # point (33 bytes, prefix `0x02` or `0x03`, then x) and decodes it here, because not all Web Crypto
725
+ # implementations import a compressed point. Rejects all other forms, the hybrid form (prefix `0x06` or `0x07`)
726
+ # included, also on runtimes whose Web Crypto import accepts them. The result is therefore the same on all runtimes.
727
+ #
728
+ # @param bytes [String] The public key bytes (a binary String).
729
+ # @return [String] the 65-byte uncompressed point (a binary String).
730
+ #
731
+ # @raise [TypeError] when the bytes are not an uncompressed or a compressed point, or when the compressed point
732
+ # is not on the curve.
733
+ def self.decode_p256_public_key(bytes)
734
+ if bytes.bytesize == 65 && bytes.getbyte(0) == 0x04
735
+ return bytes
736
+ end
737
+
738
+ if bytes.bytesize != 33 || (bytes.getbyte(0) != 0x02 && bytes.getbyte(0) != 0x03)
739
+ raise TypeError, 'Invalid public key'
740
+ end
741
+
742
+ # x must be smaller than p, so that a key has one compressed form only
743
+ p = P256_P
744
+ x = bytes.byteslice(1, 32).unpack1('H*').to_i(16)
745
+ if x >= p
746
+ raise TypeError, 'Invalid public key'
747
+ end
748
+
749
+ # The square root of y² = x³ - 3x + b (mod p), the on-curve check and the parity of y are OpenSSL's: `EC_POINT_bn2point` decompresses the point and validates it
750
+ begin
751
+ point = OpenSSL::PKey::EC::Point.new(OpenSSL::PKey::EC::Group.new('prime256v1'), OpenSSL::BN.new(bytes, 2))
752
+ rescue OpenSSL::PKey::EC::Point::Error
753
+ raise TypeError, 'Invalid public key'
754
+ end
755
+ return point.to_octet_string(:uncompressed)
756
+ end
757
+
758
+ # `#modPow` (square-and-multiply) has no counterpart: the exponentiation is OpenSSL's (`decode_p256_public_key`)
759
+
760
+ # Generates a random string of the given length using the base64 url alphabet.
761
+ #
762
+ # The underlying byte source is the effective config's `randomSource` when set, otherwise
763
+ # `SecureRandom.random_bytes`. See `Triauth.config[:randomSource]` for details on overriding
764
+ # the entropy source.
765
+ #
766
+ # @param len [Integer] The character length of the returned string.
767
+ # @param config [Hash] The effective config whose `randomSource` supplies the bytes;
768
+ # defaults to the global `Triauth.config`.
769
+ # @return [String] a random string of the given length
770
+ def self.random_string(len, config = Triauth.config)
771
+ # base64url packs 3 random bytes into 4 chars (0.75 bytes/char), so `len` chars need
772
+ # ceil(len * 0.75) bytes; the +1 covers the partial final group and guarantees the encoded
773
+ # string is at least `len` chars before the substring trim. Every returned char is therefore
774
+ # backed by fresh random bytes (no padding/repetition is introduced by the trim).
775
+ n = (len * 0.75).ceil + 1
776
+ random_source = config[:randomSource]
777
+ source = random_source || ->(count) { SecureRandom.random_bytes(count) }
778
+ bytes = source.call(n)
779
+ if !bytes.is_a?(String) || bytes.bytesize != n
780
+ raise ArgumentError, "randomSource must return #{n} bytes"
781
+ end
782
+
783
+ return array_buffer_to_base64url(bytes.b)[0, len]
784
+ end
785
+
786
+ # Verifies that the passed object has only properties that are on the passed whitelist.
787
+ #
788
+ # This method helps to ensure that the user-passed arguments were not misspelled or come from untrusted sources.
789
+ # It also helps to recognize that an argument was passed from the user in the subsequent code, by enforcing (in strict
790
+ # mode) that only non-undefined values may be set by the user.
791
+ #
792
+ # @param obj [Hash] An object which properties should be checked.
793
+ # @param whitelist [Array<Symbol>] An array of allowed properties (whitelist).
794
+ # @param strict [Boolean] When true (default), a whitelisted key whose value is `undefined` is rejected; use this at
795
+ # trust boundaries (user-facing options/constraints) to force callers to omit a value rather than pass `undefined`.
796
+ # Ruby has no `undefined`: a `nil` value passes, as `null` does in triauth-js.
797
+ #
798
+ # @return [Boolean] true if object contains only properties from the whitelist, false otherwise
799
+ def self.has_only_known_properties(obj, whitelist, strict = true) # rubocop:disable Lint/UnusedMethodArgument -- the `strict` check of an `undefined` value has no counterpart: Ruby has one null, which follows the JS `null` branch
800
+ if !obj.is_a?(Hash)
801
+ return false
802
+ end
803
+
804
+ obj_keys = obj.keys
805
+ return obj_keys.all? { |key| whitelist.include?(key) }
806
+ end
807
+
808
+ # Returns the number of bytes it takes to store the given string in UTF-8.
809
+ #
810
+ # This method is helpful when validating untrusted input, as simply checking .length
811
+ # may be not enough for utf-8 encoded strings.
812
+ #
813
+ # @param str [String] A string for which its underlying bytesize should be calculated.
814
+ #
815
+ # @return [Integer] The number of bytes.
816
+ def self.byte_size(str)
817
+ return str.bytesize
818
+ end
819
+
820
+ # Parses the provided JSON text and returns the object it encodes.
821
+ #
822
+ # The native JSON parser reads the text, then one walk over the parsed value applies the rules below, so that
823
+ # a value which passes them reads the same in every language:
824
+ # - the source is a string of at most 256KB, and its root is an object,
825
+ # - a number is a whole number of magnitude at most 2^53 - 1 (`Number.isSafeInteger`), the range that every
826
+ # JSON parser reads exactly: `1.0` and `1e2` are the whole numbers 1 and 100, while a fraction, a larger
827
+ # integer or a literal that overflows to Infinity is rejected,
828
+ # - a string carries no surrogate code point (the JSON escape of a surrogate is the one spelling of such a
829
+ # string that passes the strict UTF-8 decoder),
830
+ # - a member name is ASCII, at most 255 characters long, and never `__proto__`, `constructor` or `prototype`,
831
+ # - objects and arrays nest at most 8 levels deep: the root object is level 1, and other values add no level.
832
+ #
833
+ # A member name that occurs twice in one object keeps its last value, as the native parser reads it, and the
834
+ # rules apply to that last value.
835
+ #
836
+ # @param source [String] A source string that contains json encoded data.
837
+ #
838
+ # @return [Hash{Symbol => Object}] deserialized object (keys are Symbols)
839
+ def self.safe_parse_json(source)
840
+ err = RuntimeError.new('JSON validation error')
841
+
842
+ # Pre-validate that source is a string not larger than 256KB
843
+ if !Helpers.is_string(source) ||
844
+ Helpers.byte_size(source) > Protocol::LIMITS[:jsonMaxBytesize]
845
+ raise err
846
+ end
847
+
848
+ # The json gem admits comments and unknown escapes that the JS parser rejects, and cannot parse a surrogate escape
849
+ # outside a pair into the surrogate code point that the string-value rule of the walk below refuses in JS (see
850
+ # `JSON_STRICT_SOURCE_REGEXP`): the same inputs are a parser failure here
851
+ if !JSON_STRICT_SOURCE_REGEXP.match?(source)
852
+ raise JSON::ParserError, 'source is not strict JSON (a comment, an unknown escape or a surrogate escape outside a pair)'
853
+ end
854
+
855
+ json_object = JSON.parse(source, symbolize_names: true)
856
+
857
+ # We always expect to see JSON encoded objects ({}) at root level.
858
+ if !json_object.is_a?(Hash)
859
+ raise err
860
+ end
861
+
862
+ if !is_bounded_json_value(json_object, 1)
863
+ raise err
864
+ end
865
+
866
+ return json_object
867
+ end
868
+
869
+ # Checks one value produced by `JSON.parse`, and every value inside it, against the rules of `Helpers.safe_parse_json`.
870
+ #
871
+ # The walk goes at most one level past the nesting limit, so its recursion depth stays bounded whatever the parser built.
872
+ #
873
+ # @param value [Object] A value produced by `JSON.parse`.
874
+ # @param depth [Integer] The nesting level of the value if it is an object or an array (the root object is level 1).
875
+ #
876
+ # @return [Boolean] true if the value and every value inside it follow the rules, false otherwise.
877
+ def self.is_bounded_json_value(value, depth)
878
+ if Helpers.is_number(value)
879
+ return Helpers.is_safe_integer(value)
880
+ end
881
+
882
+ if value.is_a?(String) # `typeof === 'string'` is `is_a?(String)` here so that the well-formedness test is the deciding one
883
+ return Helpers.is_well_formed_string(value)
884
+ end
885
+
886
+ # true, false and null add no nesting level
887
+ if !(value.is_a?(Hash) || value.is_a?(Array))
888
+ return true
889
+ end
890
+
891
+ if depth > Protocol::LIMITS[:jsonMaxNestingDepth]
892
+ return false
893
+ end
894
+
895
+ if value.is_a?(Array)
896
+ return value.each_index.all? do |i|
897
+ # JS has one number type (language-hazards B3): an integral Float the parser produced from a spelling
898
+ # such as `1.0` or `1e2` is the number 1 or 100 there, so it becomes the Integer here (a safe integer)
899
+ value[i] = value[i].to_i if value[i].is_a?(Float) && Helpers.is_safe_integer(value[i])
900
+ is_bounded_json_value(value[i], depth + 1)
901
+ end
902
+ end
903
+
904
+ return value.keys.all? do |name|
905
+ # JS has one number type (language-hazards B3): an integral Float the parser produced from a spelling
906
+ # such as `1.0` or `1e2` is the number 1 or 100 there, so it becomes the Integer here (a safe integer)
907
+ value[name] = value[name].to_i if value[name].is_a?(Float) && Helpers.is_safe_integer(value[name])
908
+ name.to_s.length <= Protocol::LIMITS[:jsonMaxKeyLength] &&
909
+ !/[^\x00-\x7F]/.match?(name.to_s) &&
910
+ !PROTOTYPE_NAMES.include?(name.to_s) &&
911
+ is_bounded_json_value(value[name], depth + 1)
912
+ end
913
+ end
914
+ private_class_method :is_bounded_json_value
915
+
916
+ # Builds a minimal in-memory LRU cache - the default answer store of `Triauth::Resolvers::CachingResolver`.
917
+ #
918
+ # The returned object's `get` and `set` methods are plain synchronous calls here (they return Promises in
919
+ # triauth-js) so a caller can swap in a custom remote cache without changing the call sites.
920
+ #
921
+ # @param size [Integer] Maximum number of entries. Defaults to 1024.
922
+ #
923
+ # @return [LRUCache] An LRU cache exposing `get(key)`, `set(key, value)`, and `resize(new_size)`.
924
+ def self.create_lru_cache(size = 1024)
925
+ return LRUCache.new(size)
926
+ end
927
+
928
+ # The port of the closure object that `create_lru_cache` returns: an LRU cache over an insertion-ordered Hash
929
+ # (`cache.keys.first` is the oldest entry). Keys are Strings.
930
+ class LRUCache
931
+ # @param size [Integer] Maximum number of entries.
932
+ def initialize(size)
933
+ @size = size
934
+ @cache = {}
935
+ end
936
+
937
+ # @param key [String] The entry's key.
938
+ #
939
+ # @return [Object, nil] the cached value, now the most recently used, or nil when absent.
940
+ def get(key)
941
+ return nil if !@cache.key?(key)
942
+
943
+ val = @cache[key]
944
+ @cache.delete(key)
945
+ @cache[key] = val
946
+ return val
947
+ end
948
+
949
+ # @param key [String] The entry's key.
950
+ # @param val [Object] The value to store; the oldest entry is evicted when the cache is full.
951
+ #
952
+ # @return [void]
953
+ def set(key, val)
954
+ if @cache.key?(key)
955
+ @cache.delete(key)
956
+ elsif @cache.size >= @size
957
+ @cache.delete(@cache.keys.first)
958
+ end
959
+ @cache[key] = val
960
+ end
961
+
962
+ # @param new_size [Integer] The new maximum number of entries; the oldest entries beyond it are evicted.
963
+ #
964
+ # @return [void]
965
+ def resize(new_size)
966
+ @size = new_size
967
+ @cache.delete(@cache.keys.first) while @cache.size > @size
968
+ end
969
+ end
970
+
971
+ # Checks whether the given value is a well-formed lookup code - the 16-character token that
972
+ # addresses an identity's records in the DNS when resolving in private mode.
973
+ #
974
+ # @param val [Object] A value to check; anything that is not a string is reported as not a lookup code.
975
+ #
976
+ # @return [Boolean] true when the value is a string of exactly 16 uppercase letters and digits
977
+ def self.is_lookup_code(val)
978
+ return Helpers.is_string(val) && LOOKUP_CODE_REGEXP.match?(val)
979
+ end
980
+
981
+ # Returns the effective settings for one call or instance: the global `Triauth.config`
982
+ # with the given per-call overrides merged over it. The global Hash is read at call
983
+ # time, so runtime mutations of `Triauth.config` are picked up.
984
+ #
985
+ # @param overrides [Hash] Per-call overrides for the global Triauth.config settings. Defaults to `{}`.
986
+ #
987
+ # @return [Hash{Symbol => Object}] the merged settings (a new Hash)
988
+ def self.merge_config(overrides = {})
989
+ return Helpers.object_assign({}, Triauth.config, overrides)
990
+ end
991
+
992
+ # Helpers with no triauth-js counterpart (the profile's idiom table).
993
+
994
+ # The JS `\s` set (ECMA-262 WhiteSpace and LineTerminator), as a character class fragment for interpolation into
995
+ # regexes (`/#{JS_WS}+/`); `String#strip` is never used.
996
+ JS_WS = '[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]'
997
+ # Its complement, the JS `\S`.
998
+ JS_NOT_WS = '[^\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]'
999
+ # The JS `.`: every character but the four line terminators `\n`, `\r`, U+2028 and U+2029.
1000
+ JS_DOT = '[^\n\r\u2028\u2029]'
1001
+ JS_WS_LEADING_REGEXP = /\A#{JS_WS}+/
1002
+ JS_WS_TRAILING_REGEXP = /#{JS_WS}+\z/
1003
+ private_constant :JS_WS_LEADING_REGEXP, :JS_WS_TRAILING_REGEXP
1004
+
1005
+ # The port of the optional call `config.logger?.<level>?.(...)`: calls `config[:logger].public_send(level, *args)`
1006
+ # only when `config[:logger]` is non-nil and responds to `level`; returns nil otherwise. The remaining
1007
+ # arguments are the values to log, passed through verbatim.
1008
+ #
1009
+ # @param config [Hash] Effective per-call config; only `config[:logger]` is consulted here.
1010
+ # @param level [Symbol] One of `:debug`, `:info`, `:warn`, `:error`.
1011
+ #
1012
+ # @return [Object, nil] whatever the logger's method returns, or nil when it was not called.
1013
+ def self.log(config, level, *)
1014
+ logger = config[:logger]
1015
+ return nil if logger.nil? || !logger.respond_to?(level)
1016
+
1017
+ return logger.public_send(level, *)
1018
+ end
1019
+
1020
+ # The port of `Date.now()`: the wall clock in integer milliseconds. The JSON runner and the time-sensitive
1021
+ # tests freeze it by redefining this singleton method; `now` (monotonic, durations only) is never frozen.
1022
+ #
1023
+ # @return [Integer] milliseconds since the Unix epoch.
1024
+ def self.date_now
1025
+ return Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
1026
+ end
1027
+
1028
+ # The port of `typeof x === 'string'`: a String that is valid UTF-8, or ASCII-only in another ASCII-compatible
1029
+ # encoding. An ill-formed String is not a string (the counterpart of a JS string with an unpaired surrogate failing
1030
+ # its regex gate), which also makes every regex match on a value that passed this check safe.
1031
+ #
1032
+ # @param x [Object] A value to check.
1033
+ #
1034
+ # @return [Boolean] true for a well-formed String, false otherwise.
1035
+ def self.is_string(x)
1036
+ return x.is_a?(String) && (x.encoding == Encoding::UTF_8 ? x.valid_encoding? : x.ascii_only?)
1037
+ end
1038
+
1039
+ # The port of `typeof x === 'number'`: an Integer or a Float (`Float::NAN` and `Float::INFINITY` included;
1040
+ # `true` and `false` are not numbers).
1041
+ #
1042
+ # @param x [Object] A value to check.
1043
+ #
1044
+ # @return [Boolean] true for an Integer or a Float, false otherwise.
1045
+ def self.is_number(x)
1046
+ return x.is_a?(Integer) || x.is_a?(Float)
1047
+ end
1048
+
1049
+ # The port of `Number.isInteger(x)`: an Integer, or a finite Float with no fractional part (`30.0` is an integer,
1050
+ # as it is in JS).
1051
+ #
1052
+ # @param x [Object] A value to check.
1053
+ #
1054
+ # @return [Boolean] true for an integral number, false otherwise.
1055
+ def self.is_integer(x)
1056
+ return x.is_a?(Integer) || (x.is_a?(Float) && x.finite? && x == x.floor)
1057
+ end
1058
+
1059
+ # The port of `Number.isSafeInteger(x)`: an Integer, or a finite integral Float, of magnitude at most 2^53 - 1
1060
+ # (`true` and `false` are not Integers; `Float::NAN` and `Float::INFINITY` fail `is_integer`).
1061
+ #
1062
+ # @param x [Object] A value to check.
1063
+ #
1064
+ # @return [Boolean] true for a safe integer, false otherwise.
1065
+ def self.is_safe_integer(x)
1066
+ return Helpers.is_integer(x) && x.abs <= (2**53) - 1
1067
+ end
1068
+
1069
+ # The port of the JS object key order (`Object.entries`, `Object.keys`, `for..in`) for a Hash whose keys may be
1070
+ # array indexes: the canonical array-index keys (`:"0"`, `:"1"`, ... up to 2**32 - 2) come first in ascending
1071
+ # numeric order, then the remaining keys in insertion order.
1072
+ #
1073
+ # @param hash [Hash{Symbol => Object}] The Hash to list.
1074
+ #
1075
+ # @return [Array<Array(Symbol, Object)>] the `[key, value]` pairs in JS order.
1076
+ def self.ordered_entries(hash)
1077
+ index_keys = []
1078
+ other_keys = []
1079
+ hash.each_key do |key|
1080
+ if /\A(0|[1-9][0-9]*)\z/.match?(key.to_s) && key.to_s.to_i <= (2**32) - 2
1081
+ index_keys.push(key)
1082
+ else
1083
+ other_keys.push(key)
1084
+ end
1085
+ end
1086
+ index_keys.sort_by! { |key| key.to_s.to_i }
1087
+ return (index_keys + other_keys).map { |key| [key, hash[key]] }
1088
+ end
1089
+
1090
+ # The port of `Object.assign({}, ...sources)` for sources that may be any caller value: a Hash is merged (a nil
1091
+ # value still overrides, as `undefined` does); nil, true, false, numbers and Symbols contribute nothing; a String
1092
+ # or an Array contributes its characters or elements under the keys `:"0"`, `:"1"`, ... (JS spreads array-likes),
1093
+ # so the known-keys guards reject a stray String or Array exactly as triauth-js does; any other object contributes
1094
+ # nothing.
1095
+ #
1096
+ # @param sources [Array<Object>] The sources, merged left to right into a new Hash.
1097
+ #
1098
+ # @return [Hash{Symbol => Object}] a new Hash.
1099
+ def self.object_assign(*sources)
1100
+ target = {}
1101
+ sources.each do |source|
1102
+ case source
1103
+ when Hash
1104
+ target.merge!(source)
1105
+ when String
1106
+ source.each_char.with_index { |char, i| target[i.to_s.to_sym] = char }
1107
+ when Array
1108
+ source.each_with_index { |element, i| target[i.to_s.to_sym] = element }
1109
+ end
1110
+ end
1111
+ return target
1112
+ end
1113
+
1114
+ # The port of `String.prototype.trim`: strips the JS whitespace set ({JS_WS}) from both ends; NUL is kept.
1115
+ #
1116
+ # @param s [String] A string to trim.
1117
+ #
1118
+ # @return [String] the trimmed string.
1119
+ def self.trim(s)
1120
+ return s.sub(JS_WS_LEADING_REGEXP, '').sub(JS_WS_TRAILING_REGEXP, '')
1121
+ end
1122
+
1123
+ # The port of `String.prototype.trimEnd`: strips the JS whitespace set ({JS_WS}) from the end; NUL is kept.
1124
+ #
1125
+ # @param s [String] A string to trim.
1126
+ #
1127
+ # @return [String] the trimmed string.
1128
+ def self.trim_end(s)
1129
+ return s.sub(JS_WS_TRAILING_REGEXP, '')
1130
+ end
1131
+
1132
+ # The port of `encodeURIComponent`: the unreserved characters `A-Z a-z 0-9 - _ . ! ~ * ' ( )` are kept and every
1133
+ # other character is percent-encoded as the uppercase hex of its UTF-8 bytes.
1134
+ #
1135
+ # @param s [String] A string to encode.
1136
+ #
1137
+ # @return [String] the encoded string.
1138
+ #
1139
+ # @raise [TypeError] when the value is not a well-formed string (`encodeURIComponent` throws a `URIError` then).
1140
+ def self.encode_uri_component(s)
1141
+ if !Helpers.is_string(s)
1142
+ raise TypeError, 'encodeUriComponent expects a string'
1143
+ end
1144
+
1145
+ return s.b.gsub(/[^A-Za-z0-9\-_.!~*'()]/) { |byte| format('%%%02X', byte.ord) }.force_encoding(Encoding::UTF_8)
1146
+ end
1147
+
1148
+ # The port of `fetch(url, fetchOptions)` over one `Net::HTTP` per call: the default transport of the DNS-over-HTTPS
1149
+ # resolvers, and the contract an injected `options[:fetch]` must honor. `fetch.call(url, fetch_options) { |response| }`
1150
+ # sends a `GET` with `fetch_options[:headers]`, bounds the connect, the write and every read by
1151
+ # `fetch_options[:timeout]` milliseconds when given (Net::HTTP's 60 s defaults otherwise), yields a {FetchResponse}
1152
+ # inside the connection (so the body can stream) and returns the block's value. One request per call: Net::HTTP's own
1153
+ # retry of idempotent requests is off. TLS: an `https` URL verifies the peer certificate and the host name
1154
+ # (`OpenSSL::SSL::VERIFY_PEER`, never configurable off). Redirects are not followed (a 3xx answer has `ok` false).
1155
+ # The proxy environment (`http_proxy`, `https_proxy`, `no_proxy`) is honored, as `Net::HTTP.new` does by default.
1156
+ # Raising from inside the block closes the connection (the port of aborting a body read).
1157
+ #
1158
+ # @param url [String] The absolute http(s) URL to request.
1159
+ # @param fetch_options [Hash] `headers` (a `Hash{String => String}`, the request headers) and `timeout`
1160
+ # (Integer milliseconds, nil for Net::HTTP's defaults). Defaults to `{}`.
1161
+ #
1162
+ # @yieldparam response [FetchResponse] the response, valid inside the block only.
1163
+ #
1164
+ # @return [Object] whatever the block returns.
1165
+ def self.fetch(url, fetch_options = {})
1166
+ uri = URI.parse(url)
1167
+ http = Net::HTTP.new(uri.host, uri.port)
1168
+ http.max_retries = 0
1169
+ if uri.scheme == 'https'
1170
+ http.use_ssl = true
1171
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
1172
+ end
1173
+ if !fetch_options[:timeout].nil?
1174
+ seconds = fetch_options[:timeout] / 1000.0
1175
+ http.open_timeout = seconds
1176
+ http.read_timeout = seconds
1177
+ http.write_timeout = seconds
1178
+ end
1179
+ request = Net::HTTP::Get.new(uri.request_uri, fetch_options[:headers] || {})
1180
+ http.start do
1181
+ http.request(request) do |res|
1182
+ return yield(FetchResponse.new(res))
1183
+ end
1184
+ end
1185
+ end
1186
+
1187
+ # The response object that {Helpers.fetch} yields: a thin wrapper over `Net::HTTPResponse` with the contract every
1188
+ # transport must honor - `ok` (true for a 2xx status), `status` (Integer), `headers` (Hash of lowercase String
1189
+ # header names to String values) and `each_chunk { |bytes| }` (streams the body as binary String chunks; the port
1190
+ # of `body.getReader()`). It is valid inside the `fetch` block only.
1191
+ class FetchResponse
1192
+ # @param res [Net::HTTPResponse] The response, inside its connection block.
1193
+ def initialize(res)
1194
+ @res = res
1195
+ end
1196
+
1197
+ # @return [Boolean] true for a 2xx status.
1198
+ def ok
1199
+ return @res.code.to_i >= 200 && @res.code.to_i < 300
1200
+ end
1201
+
1202
+ # @return [Integer] the HTTP status code.
1203
+ def status
1204
+ return @res.code.to_i
1205
+ end
1206
+
1207
+ # @return [Hash{String => String}] the response headers, names lowercased; several values of one name are joined with `, `.
1208
+ def headers
1209
+ return @res.each_header.to_h
1210
+ end
1211
+
1212
+ # Streams the body as it arrives; may be called once.
1213
+ #
1214
+ # @yieldparam bytes [String] a chunk of the body (a binary String).
1215
+ #
1216
+ # @return [void]
1217
+ def each_chunk(&)
1218
+ @res.read_body(&)
1219
+ end
1220
+ end
1221
+ end
1222
+ end