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,202 @@
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_relative 'base'
21
+ require_relative '../helpers'
22
+
23
+ module Triauth
24
+ module Resolvers
25
+ # A caching resolver that wraps another resolver (a descendant of Triauth::Resolvers::Base) and caches its
26
+ # answers at the application level, so that repeated queries for the same DNS records are served from the
27
+ # cache instead of the network.
28
+ #
29
+ # @example
30
+ # Triauth.config[:resolver] = Triauth::Resolvers::CachingResolver.new(Triauth.config[:resolver])
31
+ class CachingResolver < Base
32
+ # @return [Base] the wrapped resolver.
33
+ attr_reader :resolver
34
+ # @return [Hash] the options passed to the constructor.
35
+ attr_reader :options
36
+ # @return [Object] the cache store (`options[:store]`, or the default LRU cache).
37
+ attr_reader :store
38
+ # @return [Hash{String => Thread}] the in-flight lookups by cache key.
39
+ attr_reader :pending
40
+
41
+ # @param resolver [Base] The resolver (a descendant of Triauth::Resolvers::Base) whose answers should be cached.
42
+ # @param options [Hash] Defaults to `{}`.
43
+ # @option options [Object] :store The cache store to use, fixed at construction time. May be anything that implements
44
+ # `get(key)` and `set(key, value)` methods (synchronous here: they return their value directly, there are no
45
+ # Promises); stored values are JSON-serializable and must survive the store's serialization round-trip, so a
46
+ # store that serializes its values must restore Symbol keys (`JSON.parse(..., symbolize_names: true)`).
47
+ # Defaults to an in-process `Triauth::Helpers.create_lru_cache(1000)`.
48
+ # SECURITY: the store becomes part of your trusted computing base — anyone able to write to it
49
+ # can inject DNS records (i.e., public keys). When using a shared store such as Redis,
50
+ # restrict write access accordingly.
51
+ # @option options [Integer] :maxTtl The maximal time (in seconds) an answer may be served from the cache, regardless of the TTLs
52
+ # carried by its records. Bounds the worst-case revocation latency added on top of regular
53
+ # DNS caching. Defaults to 300. Set to 0 to disable caching.
54
+ # @option options [Integer] :defaultTtl The cache lifetime (in seconds) for answers whose records carry no TTL (every built-in
55
+ # resolver reports one, SystemDns included; a custom resolver may not). Defaults to 0 (such answers are not cached).
56
+ # @option options [Integer] :negativeTtl The cache lifetime (in seconds) for empty answers (no records found).
57
+ # Defaults to 0 (empty answers are not cached).
58
+ def initialize(resolver, options = {})
59
+ super()
60
+ @resolver = resolver
61
+ @options = options
62
+ @store = options[:store] || Helpers.create_lru_cache(1000)
63
+ @pending = {}
64
+ @pending_mutex = Mutex.new # guards `@pending` (the JS Map is touched from one event loop; the Threads here are not)
65
+ end
66
+
67
+ # Queries the cache for resource records of a given type that are stored under a given domain_name,
68
+ # falling back to (and caching the answer of) the wrapped resolver on a cache miss.
69
+ #
70
+ # @param domain_name [String] A domain name to query the DNS for. For example "example.com".
71
+ # @param type [String] A type of DNS resource record to look for. For example "TXT", or "A".
72
+ # @param options [Hash] Per-call overrides for the options passed to the constructor (except `store`).
73
+ # Additionally, `cache` may be set to `false` to fully bypass the cache for this
74
+ # call (no read, no write). All options are also forwarded to the wrapped resolver. Defaults to `{}`.
75
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
76
+ #
77
+ # @return [Array<Hash>] The array of retrieved DNS resource records in `{value, ttl, dnssec}` format
78
+ # (`ttl` an Integer or nil, `dnssec` a Boolean or nil).
79
+ def resolve(domain_name, type, options = {}, config = {})
80
+ config = Helpers.merge_config(config)
81
+
82
+ options = Helpers.object_assign({
83
+ maxTtl: 300,
84
+ defaultTtl: 0,
85
+ negativeTtl: 0
86
+ }, @options, options)
87
+
88
+ # Ensure type is upper-case, so that cache keys are consistent across differently-cased queries
89
+ type = type.to_s.upcase
90
+
91
+ if options[:cache] == false
92
+ return @resolver.resolve(domain_name, type, options, config)
93
+ end
94
+
95
+ key = "#{type}:#{domain_name}"
96
+
97
+ # The cache must fail open (to a fresh resolution): a failing store is treated as a cache miss
98
+ entry = nil
99
+ begin
100
+ entry = @store.get(key)
101
+ rescue StandardError => err
102
+ Helpers.log(config, :debug, "[DNS] Cache read failed for #{type} records of #{domain_name}", { cause: err })
103
+ end
104
+
105
+ now = Helpers.date_now
106
+ # `entry && ...`: a String or an Integer entry has no members, so `is_a?(Hash)` is the whole guard
107
+ if entry.is_a?(Hash) && entry[:records].is_a?(Array) && Helpers.is_number(entry[:storedAt]) && Helpers.is_number(entry[:expiresAt]) && now < entry[:expiresAt]
108
+ Helpers.log(config, :debug, "[DNS] Cache hit for #{type} records of #{domain_name}")
109
+
110
+ # Serve records with the remaining — not the original — TTL, so that TTL-derived
111
+ # values downstream (e.g., the `expires` timestamps) stay honest
112
+ elapsed = (now - entry[:storedAt]) / 1000.0 # a float division, as the JS `/ 1000`
113
+
114
+ return entry[:records].map do |record|
115
+ {
116
+ value: record[:value],
117
+ ttl: Helpers.is_number(record[:ttl]) ? [0, (record[:ttl] - elapsed).ceil].max : nil,
118
+ dnssec: record[:dnssec]
119
+ }
120
+ end
121
+ end
122
+
123
+ # Coalesce concurrent cache misses for the same key into a single upstream query.
124
+ # Callers with an abort signal always get their own upstream query, so that one
125
+ # caller's abort cannot reject a resolution shared with the others.
126
+ # (there is no abort signal here, so every caller joins the in-flight query; the read and the create below run
127
+ # under one `synchronize` so that two concurrent misses never start two upstream queries)
128
+ lookup = nil
129
+ @pending_mutex.synchronize do
130
+ lookup = @pending[key]
131
+
132
+ if !lookup.nil?
133
+ Helpers.log(config, :debug, "[DNS] Joining the in-flight query for #{type} records of #{domain_name}")
134
+
135
+ else
136
+ # the in-flight lookup is a Thread (the pending promise): `Thread#value` hands its records to every joiner and
137
+ # re-raises its exception to each of them; the `ensure` is the JS `lookup.then(cleanup, cleanup)`
138
+ lookup = Thread.new do
139
+ Thread.current.report_on_exception = false
140
+ begin
141
+ lookup(key, domain_name, type, options, config)
142
+ ensure
143
+ @pending_mutex.synchronize { @pending.delete(key) }
144
+ end
145
+ end
146
+
147
+ # `if (!options.signal)`: no signal exists, so every lookup is registered
148
+ @pending[key] = lookup
149
+ end
150
+ end
151
+
152
+ # Each caller gets its own copy of the answer, so that downstream in-place record
153
+ # mutations (e.g., by an enclosing MultiResolver) cannot leak across callers
154
+ return copy_records(lookup.value)
155
+ end
156
+
157
+ private
158
+
159
+ # Resolves through the wrapped resolver and caches the answer for min(shortest record TTL, maxTtl) seconds.
160
+ # Rejections are propagated and never cached.
161
+ def lookup(key, domain_name, type, options, config)
162
+ records = @resolver.resolve(domain_name, type, options, config)
163
+
164
+ # lifetime: for how long (in seconds) the answer may be served from the cache
165
+
166
+ if records.length == 0
167
+ lifetime = options[:negativeTtl]
168
+
169
+ else
170
+ lifetime = options[:maxTtl]
171
+
172
+ records.each do |record|
173
+ lifetime = [lifetime, Helpers.is_number(record[:ttl]) ? record[:ttl] : options[:defaultTtl]].min
174
+ end
175
+ end
176
+
177
+ if lifetime > 0
178
+ stored_at = Helpers.date_now
179
+
180
+ begin
181
+ # A copy is stored, so that callers mutating the returned records cannot corrupt the cache
182
+ @store.set(key, { records: copy_records(records), storedAt: stored_at, expiresAt: stored_at + (lifetime * 1000) })
183
+ Helpers.log(config, :debug, "[DNS] Cached #{records.length} #{type} record(s) of #{domain_name} for #{lifetime}s")
184
+ rescue StandardError => err
185
+ Helpers.log(config, :debug, "[DNS] Cache write failed for #{type} records of #{domain_name}", { cause: err })
186
+ end
187
+ end
188
+
189
+ return records
190
+ end
191
+
192
+ # The JS `static #copyRecords` is a private instance method here: Ruby cannot call a private class method with an explicit receiver.
193
+ #
194
+ # @param records [Array<Hash>] The records to copy.
195
+ #
196
+ # @return [Array<Hash>] fresh `{value, ttl, dnssec}` Hashes.
197
+ def copy_records(records)
198
+ return records.map { |record| { value: record[:value], ttl: record[:ttl], dnssec: record[:dnssec] } }
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,40 @@
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_relative 'dns_json'
21
+
22
+ module Triauth
23
+ module Resolvers
24
+ # A DNS resolver that uses the Cloudflare DoH JSON API endpoint
25
+ #
26
+ # @see https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/ Documentation
27
+ # @see https://developers.cloudflare.com/1.1.1.1/privacy/public-dns-resolver/ Privacy Policy
28
+ # @see https://developers.cloudflare.com/1.1.1.1/terms-of-use/ Terms Of Service
29
+ class Cloudflare < DnsJson
30
+ # @param options [Hash] Resolver options, as documented on {Triauth::Resolvers::DnsJson}.
31
+ # `normalizeTxtRecords` and `decodeTxtRecords` are always set to true
32
+ # for this endpoint, overriding whatever the caller passes. Defaults to `{}`.
33
+ def initialize(options = {})
34
+ options[:normalizeTxtRecords] = true # Cloudflare returns TXT records in presentation form: each character-string double-quoted, e.g. "part1" "part2"
35
+ options[:decodeTxtRecords] = true # Cloudflare escapes non-printable ASCII characters into \\[decimal] form (RFC 1035 presentation format) instead of utf-8 encoding them
36
+ super('https://cloudflare-dns.com/dns-query', options)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,391 @@
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 'securerandom'
21
+ require_relative 'base'
22
+ require_relative '../helpers'
23
+ require_relative '../protocol'
24
+
25
+ module Triauth
26
+ module Resolvers
27
+ # A base class for DNS resolvers based on JSON API for DNS over HTTPS (DoH).
28
+ #
29
+ # It provides a basic implementation of the `resolve` method that uses the JSON API endpoint URL which is expected
30
+ # to be set by child classes through the `super(endpoint_url, options = {})` call in their respective constructors.
31
+ #
32
+ # @see https://github.com/curl/curl/wiki/DNS-over-HTTPS#publicly-available-servers A comprehensive list of public DoH servers.
33
+ # Some of them may offer JSON API that allows cross-origin requests.
34
+ class DnsJson < Base
35
+ # @return [String] The base URL of the DoH API endpoint.
36
+ attr_reader :url
37
+ # @return [Hash] The resolver options passed to the constructor.
38
+ attr_reader :options
39
+
40
+ # @param url [String] A base URL of the DoH API endpoint
41
+ # @param options [Hash] Defaults to `{}`.
42
+ # @option options [Boolean] :skipPadding Set to true if you want to omit using random padding in the fetch url.
43
+ # Random padding helps to prevent side-channel privacy attacks based on statistical analysis of the HTTPS payload.
44
+ # @option options [Integer] :retries How many times to retry if name resolution fails. Defaults to 0.
45
+ # @option options [Integer] :retryDelay If failure may be recoverable with time, how long to wait between retries in ms. Defaults to 150.
46
+ # @option options [Integer] :timeout How long to wait (ms) before aborting the HTTP fetch request. Defaults to nil.
47
+ # It bounds every phase of the request (connect, write, each read) and the whole body read; nil leaves the
48
+ # transport's own per-phase defaults (60 s) in place.
49
+ # @option options [Proc] :fetch A fetch implementation to use instead of `Triauth::Helpers.fetch`: a callable with the
50
+ # same contract (`call(url, fetch_options) { |response| }`, see {Triauth::Helpers.fetch}). Lets constrained runtimes
51
+ # inject their own client. Defaults to `Triauth::Helpers.fetch`.
52
+ # @option options [Boolean] :normalizeTxtRecords Set to true to convert presentation-form TXT data — each 255-octet character-string wrapped
53
+ # in double-quotes, e.g. `"part1" "part2"` — into the record's logical content: the character-string
54
+ # contents concatenated in order with no separator (some DoH providers return presentation form).
55
+ # @option options [Boolean] :decodeTxtRecords Set to true to decode RFC 1035 presentation-format escapes in TXT records - `\NNN` (exactly three
56
+ # decimal digits) into that octet, `\X` into X literally — and reinterpret the result as utf-8
57
+ # (some DoH providers escape non-printable ASCII). A record carrying a malformed escape
58
+ # (`\NNN` above 255, fewer than three digits, a bare trailing backslash) is ignored as a whole.
59
+ def initialize(url, options = {})
60
+ super()
61
+ @url = url
62
+ @options = options
63
+ end
64
+
65
+ # Folds a DNS name for comparison: ASCII-lowercased, without the trailing dot of the absolute form.
66
+ # DNS names compare without regard to ASCII case, and a server may spell the owner name of an answer
67
+ # in another case than the query.
68
+ #
69
+ # @param name [String] A DNS name as queried, or as returned in an answer.
70
+ # @return [String] The folded name.
71
+ def self.fold_name(name)
72
+ return Helpers.ascii_lowercase(name.end_with?('.') ? name[0...-1] : name)
73
+ end
74
+
75
+ # Converts a TXT record's presentation form into its logical content.
76
+ #
77
+ # DoH JSON providers that emit presentation form wrap each 255-octet character-string in
78
+ # double-quotes and separate consecutive strings with whitespace (`"part1" "part2"`),
79
+ # escaping a literal quote as `\"` and a literal backslash as `\\` inside a string.
80
+ # The record's logical content is the in-order, no-separator concatenation of the
81
+ # character-string contents. Because `\"`, `\\` and decimal `\NNN` escapes all live at the
82
+ # same level of the presentation grammar, they must be decoded in a single pass: with
83
+ # `keep_escapes` set, `\"` and `\\` sequences are kept verbatim in the output (they still
84
+ # delimit correctly) so that the decodeTxtRecords stage decodes every escape at once;
85
+ # without it, they decode to their literal characters here.
86
+ #
87
+ # Data that does not start with a double-quote is already logical content and is returned
88
+ # unchanged. Data that starts like presentation form but does not parse as a sequence of
89
+ # quoted strings (e.g., an unterminated quote) is returned unchanged, so downstream record
90
+ # parsing rejects it instead of consuming a half-normalized value.
91
+ #
92
+ # @param data [String] The `Answer[].data` field of a TXT answer.
93
+ # @param keep_escapes [Boolean] Set to true to keep `\"`/`\\` escape sequences verbatim
94
+ # in the output, for a subsequent single-pass escape decode. Defaults to false.
95
+ # @return [String] The logical record content.
96
+ def self.normalize_txt_data(data, keep_escapes = false)
97
+ # `data[i]` is one character here (a UTF-16 unit in JS); every comparison is to an ASCII character, so the walk is equivalent
98
+ if data.length < 2 || data[0] != '"'
99
+ return data
100
+ end
101
+
102
+ content = +''
103
+ i = 0
104
+
105
+ while i < data.length
106
+ # Each pass consumes one double-quoted character-string, then any inter-string whitespace
107
+ if data[i] != '"'
108
+ return data
109
+ end
110
+
111
+ i += 1
112
+
113
+ while i < data.length && data[i] != '"'
114
+ if data[i] == '\\' && (data[i + 1] == '"' || data[i + 1] == '\\')
115
+ content << (keep_escapes ? data[i] + data[i + 1] : data[i + 1])
116
+ i += 1
117
+ else
118
+ content << data[i]
119
+ end
120
+ i += 1
121
+ end
122
+
123
+ if i >= data.length
124
+ return data
125
+ end
126
+
127
+ i += 1
128
+
129
+ i += 1 while i < data.length && (data[i] == ' ' || data[i] == "\t")
130
+ end
131
+
132
+ return content
133
+ end
134
+
135
+ # Queries the DNS for resource records of a given type that are stored under a given domain name.
136
+ #
137
+ # @param domain_name [String] A domain name to query the DNS for. For example "example.com".
138
+ # @param type [String] A type of DNS resource record to look for. For example "TXT", or "A".
139
+ # @param options [Hash] Overrides for the options passed to the constructor that will be effective during this function call. Defaults to `{}`.
140
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
141
+ #
142
+ # @return [Array<Hash>] The array of retrieved DNS resource records in `{value, ttl, dnssec}` format;
143
+ # `ttl` and `dnssec` carry the provider's `TTL` and `AD` fields, and stay nil where the provider omits them.
144
+ def resolve(domain_name, type, options = {}, config = {})
145
+ config = Helpers.merge_config(config)
146
+
147
+ options = Helpers.object_assign({
148
+ retries: 0,
149
+ retryDelay: 150,
150
+ skipPadding: false,
151
+ timeout: nil,
152
+ # `signal: null` has no counterpart: there is no abort signal here
153
+ fetch: nil
154
+ }, @options, options)
155
+
156
+ # Ensure type is upper-case
157
+ type = type.to_s.upcase
158
+
159
+ # `return new Promise((resolve, reject) => {...})`: the promise body runs inline below; `resolve(x)` is `return x`, `reject(err)` is `raise err`
160
+
161
+ # Browsers and Node expose a global `fetch`; constrained runtimes may inject one through options.fetch.
162
+ fetch_impl = options[:fetch] || Helpers.method(:fetch)
163
+ # the `typeof fetch !== 'undefined'` detection and the 'No fetch implementation available' rejection have no counterpart: the default transport always exists
164
+
165
+ fetch_url = "#{@url}?name=#{Helpers.encode_uri_component(domain_name)}&type=#{Helpers.encode_uri_component(type)}"
166
+
167
+ # Use random padding to limit the scope for side-channel privacy attacks that use the packet sizes of HTTPS GET requests.
168
+ # Uses crypto.getRandomValues so the padding cannot be predicted from a known-plaintext leak.
169
+ if !options[:skipPadding]
170
+ chars = 'abcdefghijklmnopqrstuvwxyz'
171
+ fetch_url += "&random_padding="
172
+ needed = (50 - (fetch_url.length % 50)) % 50
173
+ if needed > 0
174
+ rnd = SecureRandom.random_bytes(needed).bytes
175
+ needed.times do |i|
176
+ fetch_url += chars[rnd[i] % chars.length]
177
+ end
178
+ end
179
+ end
180
+
181
+ # The AbortController, the timeout timer, the `onAbort` listener and `cleanup` have no counterpart: the deadline
182
+ # checked between body chunks below and the transport's per-phase timeouts (`fetch_options[:timeout]`) replace them.
183
+ # `timeout` 0 is "no timer" in JS (`if (abortController && options.timeout)`), so it leaves the transport's defaults too.
184
+ # `Accept-Encoding: identity`: the body is counted as received, so no compressed body may expand past the cap.
185
+ fetch_options = { headers: { 'Accept' => 'application/dns-json', 'Accept-Encoding' => 'identity' }, timeout: options[:timeout] == 0 ? nil : options[:timeout] }
186
+ started = Helpers.now
187
+
188
+ begin
189
+ text = fetch_impl.call(fetch_url, fetch_options) do |response|
190
+ # The deadline stays armed across the entire body read (it is checked before each chunk is kept),
191
+ # bounding slow-body delivery (server sends headers fast, then dribbles bytes).
192
+ if !response.ok
193
+ raise "DoH request failed with HTTP #{response.status}"
194
+ end
195
+
196
+ # Bail early when the server *declares* an over-limit body.
197
+ declared = response.headers['content-length'].to_s[/\A[0-9]+/]&.to_i
198
+ if !declared.nil? && declared > Protocol::LIMITS[:jsonMaxBytesize]
199
+ # `abortController?.abort(...)` has no counterpart: raising inside the block is the abort (the connection is closed)
200
+ raise 'DoH response exceeds size limit (Content-Length)'
201
+ end
202
+
203
+ # Prefer incremental streaming when supported; fall back to .text() if not
204
+ if response.respond_to?(:each_chunk)
205
+ chunks = []
206
+ received = 0
207
+
208
+ response.each_chunk do |value|
209
+ if !options[:timeout].nil? && options[:timeout] != 0 && Helpers.now - started > options[:timeout]
210
+ raise 'Resolver timeout'
211
+ end
212
+
213
+ # Check BEFORE accumulating the over-limit chunk so we never hold it.
214
+ if received + value.bytesize > Protocol::LIMITS[:jsonMaxBytesize]
215
+ raise 'DoH response exceeds size limit'
216
+ end
217
+
218
+ received += value.bytesize
219
+ chunks.push(value)
220
+ end
221
+ # `reader.releaseLock()` has no counterpart: the block-driven stream releases itself
222
+
223
+ # Assemble and decode once — multi-byte sequences may span chunk boundaries.
224
+ # Strict: a body that is not well-formed UTF-8 rejects the whole response
225
+ # (propagates to the retry/reject path below, like any transport failure).
226
+ Helpers.utf8_bytes_to_string(chunks.join)
227
+
228
+ elsif response.respond_to?(:text)
229
+ text = response.text
230
+ if Helpers.byte_size(text) > Protocol::LIMITS[:jsonMaxBytesize]
231
+ raise 'DoH response exceeds size limit'
232
+ end
233
+
234
+ text
235
+
236
+ else
237
+ raise 'DoH response has no readable body'
238
+ end
239
+ end
240
+ rescue StandardError => err
241
+ # the JS `.catch` for the request and the body read: logged, then retried while the budget lasts (no delay on this path)
242
+ Helpers.log(config, :debug, err)
243
+
244
+ # `options.signal?.aborted` has no counterpart: there is no abort signal
245
+ if options[:retries] <= 0
246
+ raise err
247
+
248
+ else
249
+ options[:retries] -= 1
250
+
251
+ return resolve(domain_name, type, options, config)
252
+ end
253
+ end
254
+
255
+ # `try { ... } catch (err) { return reject(err) }`: a parse failure is terminal; it propagates past the retry rescue below, which covers the answer loop only
256
+ json = Helpers.safe_parse_json(text)
257
+
258
+ # Check for error response status code(s), and act appropriately
259
+ # @see DNS status codes - http://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
260
+
261
+ # for NXDOMAIN return empty array
262
+ if json[:Status] == 3
263
+ return []
264
+
265
+ # for other errors, retry and if still error, fail
266
+ elsif json[:Status] != 0
267
+ if options[:retries] <= 0
268
+ raise "DoH response carries DNS status #{json[:Status]}"
269
+
270
+ else
271
+ options[:retries] -= 1
272
+
273
+ if !options[:retryDelay].nil? && options[:retryDelay] != 0
274
+ sleep(options[:retryDelay] / 1000.0)
275
+ end
276
+
277
+ return resolve(domain_name, type, options, config)
278
+ end
279
+ end
280
+
281
+ begin
282
+ # The status code is 0 - all fine, continue to extract and parse the answer
283
+ retval = []
284
+ dns_record_types = {
285
+ TXT: 16,
286
+ A: 1,
287
+ AAAA: 28,
288
+ CNAME: 5
289
+ }
290
+
291
+ queried_name = DnsJson.fold_name(domain_name)
292
+
293
+ # `for (const a of (json['Answer'] || []))`: the JS falsy set of a parsed JSON value (nil, false, 0, '') iterates
294
+ # nothing, an Array iterates its elements and a String its characters (none of which is an answer), and anything
295
+ # else (a Hash included) is not iterable there and throws into the retry path below
296
+ answer = json[:Answer]
297
+ answer = [] if answer.nil? || answer == false || answer == 0 || answer == ''
298
+ answer = answer.chars if answer.is_a?(String)
299
+ if !answer.is_a?(Array)
300
+ raise TypeError, "json['Answer'] is not iterable"
301
+ end
302
+
303
+ answer.each do |a|
304
+ # `a['name']` of a non-object is undefined in JS, so such an answer fails the name check below; a null answer throws there, as `nil[:name]` does here
305
+ if !a.nil? && !a.is_a?(Hash)
306
+ next
307
+ end
308
+
309
+ # Ensure the response is for the correct domain
310
+ if !Helpers.is_string(a[:name]) || DnsJson.fold_name(a[:name]) != queried_name
311
+ next
312
+ end
313
+
314
+ # Ensure the response has the requested record type
315
+ if dns_record_types[type.upcase.to_sym] != a[:type]
316
+ next
317
+ end
318
+
319
+ # Ensure the response has fields of expected types
320
+ if !Helpers.is_string(a[:data]) ||
321
+ (!Helpers.is_integer(a[:TTL]) || a[:TTL] < 0) ||
322
+ !(json[:AD] == true || json[:AD] == false)
323
+ next
324
+ end
325
+
326
+ # Convert presentation-form TXT data into the record's logical content if normalizeTxtRecords is set;
327
+ # when the decode stage below will run, escapes stay verbatim so it can decode them in one pass
328
+ if a[:type] == 16 && options[:normalizeTxtRecords]
329
+ a[:data] = DnsJson.normalize_txt_data(a[:data], options[:decodeTxtRecords])
330
+ end
331
+
332
+ # Decode escaped chars: one left-to-right pass over the RFC 1035 escape grammar —
333
+ # `\DDD` (exactly three digits, one octet) yields that byte, `\X` yields X literally,
334
+ # and anything else (`\DDD` above 255, fewer than three digits, a bare trailing
335
+ # backslash) rejects the record as a whole rather than guessing
336
+ if a[:type] == 16 && options[:decodeTxtRecords]
337
+ begin
338
+ bytes = a[:data].gsub(/\\([0-9]{3})|\\([^0-9])?/) do
339
+ ddd = Regexp.last_match(1)
340
+ ch = Regexp.last_match(2)
341
+ if !ddd.nil?
342
+ code = Integer(ddd, 10)
343
+ if code > 255
344
+ raise 'Invalid \\DDD escape in a TXT record'
345
+ end
346
+
347
+ next code.chr(Encoding::UTF_8)
348
+ end
349
+ if ch.nil?
350
+ raise 'Truncated escape in a TXT record'
351
+ end
352
+
353
+ next ch
354
+ end
355
+ # One-char-per-octet contract: a char above U+00FF is not an octet, so it rejects
356
+ # the record rather than wrapping mod-256 into an unrelated byte
357
+ a[:data] = Helpers.utf8_bytes_to_string(bytes.each_char.map do |c|
358
+ byte = c.ord
359
+ if byte > 255
360
+ raise 'Non-octet character in a TXT record escape decode'
361
+ end
362
+
363
+ next byte
364
+ end.pack('C*'))
365
+ rescue StandardError => err
366
+ Helpers.log(config, :debug, 'DNS TXT record could not be decoded', { cause: err })
367
+ next
368
+ end
369
+ end
370
+
371
+ retval.push({ value: a[:data], ttl: a[:TTL], dnssec: json[:AD] })
372
+ end
373
+
374
+ return retval
375
+ rescue StandardError => err
376
+ # the JS `.catch` for the answer loop: a throw inside it is logged, then retried while the budget lasts
377
+ Helpers.log(config, :debug, err)
378
+
379
+ if options[:retries] <= 0
380
+ raise err
381
+
382
+ else
383
+ options[:retries] -= 1
384
+
385
+ return resolve(domain_name, type, options, config)
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,40 @@
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_relative 'dns_json'
21
+
22
+ module Triauth
23
+ module Resolvers
24
+ # A DNS resolver that uses the DNS.SB DoH JSON API endpoint
25
+ #
26
+ # @see https://dns.sb/doh/ Documentation
27
+ # @see https://dns.sb/privacy/ Privacy Policy
28
+ # @see https://dns.sb/tos/ Terms Of Service
29
+ class DnsSb < DnsJson
30
+ # @param options [Hash] Resolver options, as documented on {Triauth::Resolvers::DnsJson}.
31
+ # `normalizeTxtRecords` and `decodeTxtRecords` are always set to true
32
+ # for this endpoint, overriding whatever the caller passes. Defaults to `{}`.
33
+ def initialize(options = {})
34
+ options[:normalizeTxtRecords] = true # resolver returns TXT records in presentation form: each character-string double-quoted, e.g. "part1" "part2"
35
+ options[:decodeTxtRecords] = true # resolver escapes non-printable ASCII characters into \\[decimal] form (RFC 1035 presentation format) instead of utf-8 encoding them
36
+ super('https://doh.dns.sb/dns-query', options)
37
+ end
38
+ end
39
+ end
40
+ end