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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/LICENSE +202 -28
- data/README.md +2036 -7
- data/lib/triauth/api/attestation.rb +419 -0
- data/lib/triauth/api/authentication.rb +651 -0
- data/lib/triauth/api/identity.rb +245 -0
- data/lib/triauth/api/signing.rb +740 -0
- data/lib/triauth/api/validation.rb +162 -0
- data/lib/triauth/api.rb +33 -0
- data/lib/triauth/authentication_endpoint.rb +196 -0
- data/lib/triauth/challenge.rb +115 -0
- data/lib/triauth/challenge_response_flow.rb +344 -0
- data/lib/triauth/config.rb +93 -0
- data/lib/triauth/error.rb +95 -0
- data/lib/triauth/helpers.rb +1222 -0
- data/lib/triauth/identity.rb +301 -0
- data/lib/triauth/identity_domain.rb +212 -0
- data/lib/triauth/identity_includes.rb +249 -0
- data/lib/triauth/identity_keys.rb +309 -0
- data/lib/triauth/logger.rb +156 -0
- data/lib/triauth/multi_signature.rb +177 -0
- data/lib/triauth/protocol.rb +74 -0
- data/lib/triauth/request_cipher.rb +121 -0
- data/lib/triauth/resolvers/base.rb +147 -0
- data/lib/triauth/resolvers/caching_resolver.rb +202 -0
- data/lib/triauth/resolvers/cloudflare.rb +40 -0
- data/lib/triauth/resolvers/dns_json.rb +391 -0
- data/lib/triauth/resolvers/dns_sb.rb +40 -0
- data/lib/triauth/resolvers/google.rb +39 -0
- data/lib/triauth/resolvers/multi_resolver.rb +202 -0
- data/lib/triauth/resolvers/system_dns.rb +259 -0
- data/lib/triauth/resolvers.rb +33 -0
- data/lib/triauth/response.rb +142 -0
- data/lib/triauth/response_cipher.rb +341 -0
- data/lib/triauth/signature.rb +405 -0
- data/lib/triauth/validator.rb +549 -0
- data/lib/triauth/verifiers/base.rb +147 -0
- data/lib/triauth/verifiers/ecdsa.rb +87 -0
- data/lib/triauth/verifiers/ed25519.rb +89 -0
- data/lib/triauth/verifiers/registry.rb +52 -0
- data/lib/triauth/verifiers/web_authn.rb +253 -0
- data/lib/triauth/verifiers.rb +37 -0
- data/lib/triauth/version.rb +23 -0
- data/lib/triauth.rb +104 -7
- 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
|