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,39 @@
|
|
|
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 Google Public DNS DoH JSON API endpoint
|
|
25
|
+
#
|
|
26
|
+
# @see https://developers.google.com/speed/public-dns/docs/doh/json Documentation
|
|
27
|
+
# @see https://developers.google.com/speed/public-dns/privacy Privacy Policy
|
|
28
|
+
# @see https://developers.google.com/speed/public-dns/terms Terms Of Service
|
|
29
|
+
class Google < DnsJson
|
|
30
|
+
# @param options [Hash] Resolver options, as documented on {Triauth::Resolvers::DnsJson}. Defaults to `{}`.
|
|
31
|
+
def initialize(options = {})
|
|
32
|
+
# Google returns TXT data as logical record content: character-strings arrive already
|
|
33
|
+
# concatenated with no separator and without surrounding quotes, so neither
|
|
34
|
+
# normalizeTxtRecords nor decodeTxtRecords is needed.
|
|
35
|
+
super('https://dns.google/resolve', options)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -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 multi-resolver that takes an array of resolvers, and returns an intersection of DNS resource record values
|
|
26
|
+
# as returned by each of the listed resolvers. This allows responses from multiple independent DNS resolver service
|
|
27
|
+
# providers to be cross-validated against each other, helping to protect from BGP poisoning, rogue providers and provider-specific attacks.
|
|
28
|
+
#
|
|
29
|
+
# The TTL of each record is calculated as a minimal TTL returned by the resolvers.
|
|
30
|
+
# The dnssec property for returned record is set to true only when at least one resolver has set it to 'true' and no resolvers have set it to 'false'.
|
|
31
|
+
# The aggregation is independent of resolver order: resolvers that report no DNSSEC status (nil) express no opinion,
|
|
32
|
+
# and the property remains nil when no resolver reports an explicit status.
|
|
33
|
+
class MultiResolver < Base
|
|
34
|
+
# The carrier of the rejection value of `MultiResolver#resolve`: triauth-js rejects with an Array of the
|
|
35
|
+
# sub-resolver errors, and Ruby raises exceptions only, so that Array travels in `reasons`.
|
|
36
|
+
class ResolutionError < StandardError
|
|
37
|
+
# @return [Array<Exception>] the errors raised by the failed sub-resolvers, in resolver order.
|
|
38
|
+
attr_reader :reasons
|
|
39
|
+
|
|
40
|
+
# @param reasons [Array<Exception>] The errors raised by the failed sub-resolvers.
|
|
41
|
+
def initialize(reasons)
|
|
42
|
+
@reasons = reasons
|
|
43
|
+
super("MultiResolver: #{reasons.length} resolver(s) failed: #{reasons.map { |r| r.respond_to?(:message) ? r.message : r.to_s }.join('; ')}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Base>] the resolvers queried by every `resolve` call.
|
|
48
|
+
attr_reader :resolvers
|
|
49
|
+
# @return [Hash] the options passed to the constructor.
|
|
50
|
+
attr_reader :options
|
|
51
|
+
|
|
52
|
+
# @param resolvers [Array<Base>] An array of resolver objects (descendants of Triauth::Resolvers::Base).
|
|
53
|
+
# @param options [Hash] The options listed below are consumed at this layer; any other entries
|
|
54
|
+
# are forwarded to each sub-resolver's resolve() call. Defaults to `{}`.
|
|
55
|
+
# @option options [Integer] :maxFailures The maximal number of resolvers that are allowed to fail for the resolution to still successfully complete.
|
|
56
|
+
# @option options [Integer] :timeout How long to wait (ms) before aborting the multi-resolver call with a timeout error.
|
|
57
|
+
# A sub-resolver still running at that deadline is left to finish on its own timeouts and its answer is discarded.
|
|
58
|
+
def initialize(resolvers, options = {})
|
|
59
|
+
super()
|
|
60
|
+
@resolvers = resolvers
|
|
61
|
+
@options = options
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Queries every configured resolver for the given records, and returns the intersection of their answers.
|
|
65
|
+
#
|
|
66
|
+
# A record is returned only when every resolver that answered carries it. Its `ttl` is the lowest one
|
|
67
|
+
# reported for it, and its `dnssec` follows the aggregation described in the class documentation above.
|
|
68
|
+
#
|
|
69
|
+
# @param domain [String] A domain name to query the DNS for.
|
|
70
|
+
# @param type [String] A type of DNS resource record to look for (e.g., 'TXT', 'A').
|
|
71
|
+
# @param options [Hash] Per-call overrides for the resolver options passed to the constructor.
|
|
72
|
+
# `maxFailures` and `timeout` are consumed at this layer; every other entry
|
|
73
|
+
# fans out to each sub-resolver's resolve() call. Defaults to `{}`.
|
|
74
|
+
# @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
|
|
75
|
+
#
|
|
76
|
+
# @return [Array<Hash>] The array of retrieved DNS resource records in `{value, ttl, dnssec}` format
|
|
77
|
+
# (`ttl` an Integer or nil, `dnssec` a Boolean or nil).
|
|
78
|
+
#
|
|
79
|
+
# @raise [ResolutionError] carrying the Array of the sub-resolver errors once more than `options[:maxFailures]`
|
|
80
|
+
# of them fail or none of them answers.
|
|
81
|
+
# @raise [RuntimeError] 'MultiResolver timeout' once `options[:timeout]` elapses.
|
|
82
|
+
def resolve(domain, type, options = {}, config = {})
|
|
83
|
+
config = Helpers.merge_config(config)
|
|
84
|
+
options = Helpers.object_assign({}, @options, options)
|
|
85
|
+
|
|
86
|
+
# `return new Promise((multiResolve, multiReject) => {...})`: the promise body runs inline below; `multiResolve(x)` is `return x`, `multiReject(err)` is `raise`
|
|
87
|
+
results = [] # one Thread per sub-resolver (the JS array of promises)
|
|
88
|
+
|
|
89
|
+
# Handle timeouts through a deadline: the AbortController and its timer have no counterpart, a sub-resolver still
|
|
90
|
+
# running at the deadline is left to finish on its own timeouts and its answer is discarded (nil and 0 mean no deadline, as `if (options.timeout)`)
|
|
91
|
+
deadline = options[:timeout].nil? || options[:timeout] == 0 ? nil : Helpers.now + options[:timeout]
|
|
92
|
+
|
|
93
|
+
# maxFailures and timeout are consumed at this layer; every other option fans out
|
|
94
|
+
# to the sub-resolvers, with the shared abort signal replacing any caller-provided one
|
|
95
|
+
child_options = Helpers.object_assign({}, options)
|
|
96
|
+
child_options.delete(:maxFailures)
|
|
97
|
+
child_options.delete(:timeout)
|
|
98
|
+
# the `childOptions.signal` branches have no counterpart: no `:signal` key ever exists
|
|
99
|
+
|
|
100
|
+
@resolvers.each do |resolver|
|
|
101
|
+
results.push(Thread.new do
|
|
102
|
+
Thread.current.report_on_exception = false
|
|
103
|
+
resolver_name = resolver.class.name.to_s.split('::').last # `resolver.constructor.name` (an anonymous class prints as '' there too)
|
|
104
|
+
Helpers.log(config, :debug, "[DNS] Querying resolver #{resolver_name} for #{type} records of #{domain}")
|
|
105
|
+
ts = Helpers.now
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
res = resolver.resolve(domain, type, child_options, config)
|
|
109
|
+
Helpers.log(config, :debug, "[DNS] Resolved #{res.length} record(s) with #{resolver_name} in #{(Helpers.now - ts).round}ms", { res: res })
|
|
110
|
+
[:fulfilled, res]
|
|
111
|
+
rescue StandardError => err
|
|
112
|
+
Helpers.log(config, :debug, "[DNS] Failed resolution with #{resolver_name} in #{(Helpers.now - ts).round}ms", { cause: err })
|
|
113
|
+
[:rejected, err]
|
|
114
|
+
end
|
|
115
|
+
end)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# the port of `Promise.allSettled(results).then((values) => ...)`: each Thread is joined with the time left to the
|
|
119
|
+
# deadline; a Thread still running at the deadline is left to finish on its own resolver timeouts and its answer is
|
|
120
|
+
# discarded, and the call fails as the JS `multiReject(new Error('MultiResolver timeout'))` does
|
|
121
|
+
values = results.map do |thread|
|
|
122
|
+
joined = deadline.nil? ? thread.join : thread.join([(deadline - Helpers.now) / 1000.0, 0].max)
|
|
123
|
+
raise 'MultiResolver timeout' if joined.nil?
|
|
124
|
+
|
|
125
|
+
thread.value
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `clearTimeout(timeoutHandle)` has no counterpart: no timer is armed
|
|
129
|
+
|
|
130
|
+
failed_resolutions = values.select do |v|
|
|
131
|
+
v[0] != :fulfilled
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
fulfilled_resolutions = values.select do |v|
|
|
135
|
+
v[0] == :fulfilled
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Enforce the maxFailures limit from options
|
|
139
|
+
if failed_resolutions.length > (options[:maxFailures].nil? || options[:maxFailures] == 0 ? 0 : options[:maxFailures]) || fulfilled_resolutions.length <= 0
|
|
140
|
+
raise(ResolutionError, failed_resolutions.map { |fr| fr[1] })
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Start with the first result, and limit/modify it basing on other
|
|
144
|
+
retval = fulfilled_resolutions[0][1]
|
|
145
|
+
|
|
146
|
+
# Remove entries which values are not present in responses of _all_ fulfilled resolvers
|
|
147
|
+
fulfilled_resolutions.each do |v|
|
|
148
|
+
retval = retval.select do |record|
|
|
149
|
+
rv = v[1].map do |e|
|
|
150
|
+
e[:value]
|
|
151
|
+
end.include?(record[:value])
|
|
152
|
+
|
|
153
|
+
if !rv
|
|
154
|
+
Helpers.log(config, :debug, '[DNS] Ignoring DNS record for which non-matching responses were received', { record: record })
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
rv
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Modify dnssec and ttl properties
|
|
162
|
+
retval.each do |e|
|
|
163
|
+
# Track explicit per-record dnssec reports across all fulfilled resolvers
|
|
164
|
+
saw_true = false
|
|
165
|
+
saw_false = false
|
|
166
|
+
|
|
167
|
+
fulfilled_resolutions.each do |v|
|
|
168
|
+
v[1].each do |r|
|
|
169
|
+
if r[:value] == e[:value]
|
|
170
|
+
|
|
171
|
+
# Any defined non-true report counts as an explicit veto
|
|
172
|
+
# (`r.dnssec !== undefined`: a nil dnssec is "no opinion", the JS `null` veto cannot be expressed by Ruby data)
|
|
173
|
+
if r[:dnssec] == true
|
|
174
|
+
saw_true = true
|
|
175
|
+
elsif !r[:dnssec].nil?
|
|
176
|
+
saw_false = true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# e.ttl and/or r.ttl may be undefined; ?? keeps a TTL of 0 in the min-aggregation
|
|
180
|
+
# (`undefined < number` is false in JS: a record without a TTL never lowers the min)
|
|
181
|
+
if !r[:ttl].nil? && r[:ttl] < (e[:ttl].nil? ? Float::INFINITY : e[:ttl])
|
|
182
|
+
e[:ttl] = r[:ttl]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# dnssec is true only when at least one resolver explicitly reported true and
|
|
190
|
+
# none reported false; it stays nil when no resolver expressed an opinion.
|
|
191
|
+
e[:dnssec] = if saw_false
|
|
192
|
+
false
|
|
193
|
+
elsif saw_true
|
|
194
|
+
true
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return retval
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
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 'resolv'
|
|
21
|
+
require 'socket'
|
|
22
|
+
require_relative 'base'
|
|
23
|
+
require_relative '../helpers'
|
|
24
|
+
|
|
25
|
+
module Triauth
|
|
26
|
+
module Resolvers
|
|
27
|
+
# A DNS resolver that uses the Ruby standard library's `Resolv::DNS` to perform queries.
|
|
28
|
+
# This resolver works with the system's configured nameservers (`/etc/resolv.conf`, or the registry on Windows).
|
|
29
|
+
#
|
|
30
|
+
# @see https://docs.ruby-lang.org/en/3.2/Resolv/DNS.html Documentation
|
|
31
|
+
class SystemDns < Base
|
|
32
|
+
# @return [Hash] The resolver options passed to the constructor.
|
|
33
|
+
attr_reader :options
|
|
34
|
+
|
|
35
|
+
# @param options [Hash] Defaults to `{}`.
|
|
36
|
+
# @option options [Integer] :tries How many times each name server is tried before the query fails
|
|
37
|
+
# (`ARES_OPT_TRIES`; this works differently than `retries` in DnsJson). Defaults to 3.
|
|
38
|
+
# @option options [Integer] :timeout How long to wait (ms) for a single try before it counts as failed.
|
|
39
|
+
# Defaults to nil, which leaves the `Resolv::DNS` default (5 s per try) in place.
|
|
40
|
+
def initialize(options = {})
|
|
41
|
+
super()
|
|
42
|
+
@options = options
|
|
43
|
+
# `this.dns = null` has no counterpart: `resolv` is loaded with the file, there is nothing to load lazily
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# `#loadDns` has no counterpart: `require 'resolv'` above is the whole load, and there is no browser lane
|
|
47
|
+
|
|
48
|
+
# Queries the DNS for resource records of a given type that are stored under a given domain name,
|
|
49
|
+
# through the Ruby `Resolv::DNS` resolver.
|
|
50
|
+
#
|
|
51
|
+
# @param domain [String] A domain name to query the DNS for.
|
|
52
|
+
# @param type [String] A type of DNS resource record to look for; only 'TXT' is supported.
|
|
53
|
+
# @param options [Hash] Per-call overrides for the resolver options passed to the constructor. Defaults to `{}`.
|
|
54
|
+
# @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Hash>] The array of retrieved DNS resource records in `{value, ttl, dnssec}`
|
|
57
|
+
# format. `Resolv::DNS` reports the TTL of every resource record but no DNSSEC status, so `ttl` carries the
|
|
58
|
+
# record's TTL in seconds and `dnssec` is always nil, and a record whose bytes are not well-formed UTF-8 is
|
|
59
|
+
# left out of the answer. Every TXT record of the reply's answer section is returned whatever its owner name,
|
|
60
|
+
# as `node:dns` does, so a name that is a CNAME alias resolves to its target's TXT records. A missing name
|
|
61
|
+
# (NXDOMAIN) and a name with no records of this type (NODATA) both resolve to an empty array. It raises for a
|
|
62
|
+
# non-TXT query type, and upon any other resolver error.
|
|
63
|
+
def resolve(domain, type, options = {}, config = {})
|
|
64
|
+
config = Helpers.merge_config(config)
|
|
65
|
+
|
|
66
|
+
options = Helpers.object_assign({
|
|
67
|
+
tries: 3, # this works differently than "retries" in dns_json - @see ARES_OPT_TRIES on https://manpages.ubuntu.com/manpages/oracular/en/man3/ares_init_options.3.html
|
|
68
|
+
timeout: nil
|
|
69
|
+
# `signal: null` has no counterpart: there is no abort signal here
|
|
70
|
+
}, @options, options)
|
|
71
|
+
|
|
72
|
+
# `return new Promise((resolve, reject) => {...})`: the promise body runs inline below; `resolve(x)` is `return x`, `reject(err)` is `raise err`
|
|
73
|
+
|
|
74
|
+
# the `typeof window` rejection has no counterpart: there is no browser lane
|
|
75
|
+
|
|
76
|
+
if type != 'TXT'
|
|
77
|
+
raise 'Non TXT queries are not supported yet for this resolver'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# the `#loadDns().then((dns) => ...)` chain and the 'node:dns resolver is not available' rejection have no counterpart: `resolv` is always loaded
|
|
81
|
+
|
|
82
|
+
# @see https://nodejs.org/dist/latest-v20.x/docs/api/dns.html#resolveroptions
|
|
83
|
+
# @see https://manpages.ubuntu.com/manpages/oracular/en/man3/ares_init_options.3.html
|
|
84
|
+
# @see https://docs.ruby-lang.org/en/3.2/Resolv/DNS.html
|
|
85
|
+
# the port of `new dns.Resolver({timeout, tries})`: `options.timeout || -1` leaves the resolver's default per-try
|
|
86
|
+
# timeout in place (`Resolv::DNS::Config::InitialTimeout`, 5 s), `options.tries || 3` the number of rounds
|
|
87
|
+
tries = options[:tries].nil? || options[:tries] == 0 ? 3 : options[:tries]
|
|
88
|
+
per_try_seconds = options[:timeout].nil? || options[:timeout] == 0 ? Resolv::DNS::Config::InitialTimeout : options[:timeout] / 1000.0
|
|
89
|
+
|
|
90
|
+
# the `onAbort` listener and its removal have no counterpart: there is no abort signal
|
|
91
|
+
|
|
92
|
+
reply = resolve_txt(domain, Array.new(tries, per_try_seconds))
|
|
93
|
+
|
|
94
|
+
# the port of the `resolveTxt` callback: `err.code` is the reply's rcode here
|
|
95
|
+
# A missing name (NXDOMAIN/ENOTFOUND) and a name with no records of this type (ENODATA)
|
|
96
|
+
# are both empty answers - same as the DoH resolvers report them; other errors propagate
|
|
97
|
+
case reply.rcode
|
|
98
|
+
when Resolv::DNS::RCode::NXDomain
|
|
99
|
+
return []
|
|
100
|
+
when Resolv::DNS::RCode::NoError
|
|
101
|
+
# NODATA is a NoError reply with no TXT answers: an empty answer below
|
|
102
|
+
else
|
|
103
|
+
raise Resolv::ResolvError, "DNS result has error: rcode #{reply.rcode} for #{domain}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# c-ares (`ares_parse_txt_reply`) returns every TXT record of the answer section whatever its owner name, so a CNAME
|
|
107
|
+
# chain is followed to its target's records; the DoH resolvers filter by name instead, as `dns_json.js` does
|
|
108
|
+
records = []
|
|
109
|
+
reply.each_answer do |_n, ttl, data|
|
|
110
|
+
if data.is_a?(Resolv::DNS::Resource::IN::TXT)
|
|
111
|
+
records.push([ttl, data.strings])
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
return records.flat_map do |ttl, strings|
|
|
116
|
+
# A TXT record's character-strings concatenate in order with no separator;
|
|
117
|
+
# Resolv delivers them as `strings`, an array of binary chunks, one byte per octet.
|
|
118
|
+
raw = strings.join
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
val = Helpers.utf8_bytes_to_string(raw)
|
|
122
|
+
rescue StandardError => err
|
|
123
|
+
Helpers.log(config, :debug, err)
|
|
124
|
+
# A record whose bytes are not well-formed UTF-8 is ignored as a whole
|
|
125
|
+
next []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Resolv reports the TTL of the resource record (`node:dns` reports none); the AD bit is not exposed, so `dnssec` stays nil
|
|
129
|
+
[{
|
|
130
|
+
value: val,
|
|
131
|
+
ttl: ttl,
|
|
132
|
+
dnssec: nil
|
|
133
|
+
}]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# The port of the `dns.Resolver` + `resolveTxt` primitive: performs the TXT query for the absolute name through
|
|
140
|
+
# `Resolv::DNS::Requester::UnconnectedUDP` and returns the final `Resolv::DNS::Message` reply, whatever its rcode
|
|
141
|
+
# (`resolve` maps the rcode where `node_dns.js` maps `err.code`). `Resolv::DNS#getresources` cannot be used here:
|
|
142
|
+
# it swallows SERVFAIL and timeouts into an empty answer, which `node:dns` reports as `ESERVFAIL`/`ETIMEOUT`. Each
|
|
143
|
+
# try sends the query to every configured nameserver with the try's timeout; a truncated UDP reply repeats the
|
|
144
|
+
# query over TCP through `tcp_exchange`, bounded by the same timeout (Resolv's own `Requester::TCP` connects without
|
|
145
|
+
# one). A timeout of either exchange ends the try (the next nameserver, then the next try); other socket errors
|
|
146
|
+
# propagate. The unit tests replace this method on the instance with `define_singleton_method`.
|
|
147
|
+
#
|
|
148
|
+
# @param domain [String] The domain name to query (the query is made for its absolute form).
|
|
149
|
+
# @param timeouts [Array<Numeric>] The per-try timeouts in seconds, one entry per try.
|
|
150
|
+
#
|
|
151
|
+
# @return [Resolv::DNS::Message] the reply.
|
|
152
|
+
#
|
|
153
|
+
# @raise [Resolv::ResolvError] when every try times out.
|
|
154
|
+
def resolve_txt(domain, timeouts)
|
|
155
|
+
name = Resolv::DNS::Name.create("#{domain}.")
|
|
156
|
+
config = Resolv::DNS::Config.new
|
|
157
|
+
config.lazy_initialize
|
|
158
|
+
nameserver_port = config.nameserver_port
|
|
159
|
+
requester = Resolv::DNS::Requester::UnconnectedUDP.new(*nameserver_port)
|
|
160
|
+
begin
|
|
161
|
+
timeouts.each do |tout|
|
|
162
|
+
nameserver_port.each do |host, port|
|
|
163
|
+
msg = Resolv::DNS::Message.new
|
|
164
|
+
msg.rd = 1
|
|
165
|
+
msg.add_question(name, Resolv::DNS::Resource::IN::TXT)
|
|
166
|
+
sender = requester.sender(msg, name, host, port)
|
|
167
|
+
next if sender.nil? # no socket for the nameserver's address family (as `Resolv::DNS#fetch_resource` skips it)
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
reply, = requester.request(sender, tout)
|
|
171
|
+
if reply.tc == 1
|
|
172
|
+
# a truncated UDP reply: the same query over TCP, under the same try timeout
|
|
173
|
+
reply = tcp_exchange(msg, name, host, port, tout)
|
|
174
|
+
end
|
|
175
|
+
rescue Resolv::ResolvTimeout
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
return reply
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
raise Resolv::ResolvError, "DNS resolv timeout: #{domain}"
|
|
182
|
+
ensure
|
|
183
|
+
requester.close
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The TCP fallback of `resolve_txt` for a truncated UDP reply: one query over a fresh TCP connection, every phase
|
|
188
|
+
# under the try's timeout `tout` as one deadline - `Socket.tcp(host, port, connect_timeout: tout)`, the
|
|
189
|
+
# length-prefixed message written once the socket is writable, then the 2-byte length and the message read with
|
|
190
|
+
# `wait_readable` and `read_nonblock` until the deadline. The query goes out under a fresh random id (allocated
|
|
191
|
+
# and freed through `Resolv::DNS.allocate_request_id` / `free_request_id`, as Resolv's own senders do), which the
|
|
192
|
+
# reply must carry. A connect timeout, the deadline passing or the peer closing early raise `Resolv::ResolvTimeout`
|
|
193
|
+
# (the try is over, as after a UDP timeout); a reply whose id is not the query's raises `Resolv::ResolvError`;
|
|
194
|
+
# other socket errors propagate. The socket is closed in an `ensure`.
|
|
195
|
+
#
|
|
196
|
+
# @param msg [Resolv::DNS::Message] The query message (sent under a fresh id, see above).
|
|
197
|
+
# @param name [Resolv::DNS::Name] The queried name, for the error message.
|
|
198
|
+
# @param host [String] The nameserver address.
|
|
199
|
+
# @param port [Integer] The nameserver port.
|
|
200
|
+
# @param tout [Numeric] The try's timeout in seconds.
|
|
201
|
+
#
|
|
202
|
+
# @return [Resolv::DNS::Message] the reply.
|
|
203
|
+
#
|
|
204
|
+
# @raise [Resolv::ResolvTimeout] when the exchange does not complete within `tout`.
|
|
205
|
+
# @raise [Resolv::ResolvError] when the reply carries another id.
|
|
206
|
+
def tcp_exchange(msg, name, host, port, tout)
|
|
207
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + tout
|
|
208
|
+
begin
|
|
209
|
+
sock = Socket.tcp(host, port, connect_timeout: tout)
|
|
210
|
+
rescue Errno::ETIMEDOUT
|
|
211
|
+
raise Resolv::ResolvTimeout
|
|
212
|
+
end
|
|
213
|
+
begin
|
|
214
|
+
# a fresh random query id, as Resolv's own senders (and c-ares, across its TCP retry) give every query: the
|
|
215
|
+
# Message's default id is a process-wide counter, which the reply check below must not rely on; freed with the socket
|
|
216
|
+
id = Resolv::DNS.allocate_request_id(host, port)
|
|
217
|
+
data = msg.encode
|
|
218
|
+
data[0, 2] = [id].pack('n')
|
|
219
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
if remaining <= 0 || sock.wait_writable(remaining).nil?
|
|
221
|
+
raise Resolv::ResolvTimeout
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
sock.write([data.bytesize].pack('n') + data)
|
|
225
|
+
|
|
226
|
+
# the 2-byte length prefix, then the message, read until the deadline
|
|
227
|
+
buffer = ''.b
|
|
228
|
+
expected = 2
|
|
229
|
+
while buffer.bytesize < expected
|
|
230
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
231
|
+
if remaining <= 0 || sock.wait_readable(remaining).nil?
|
|
232
|
+
raise Resolv::ResolvTimeout
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
chunk = sock.read_nonblock(expected - buffer.bytesize, exception: false)
|
|
236
|
+
if chunk.nil? # EOF: the peer closed before the whole reply arrived
|
|
237
|
+
raise Resolv::ResolvTimeout
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
buffer << chunk if chunk != :wait_readable
|
|
241
|
+
if expected == 2 && buffer.bytesize == 2
|
|
242
|
+
expected = 2 + buffer.unpack1('n')
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
reply = Resolv::DNS::Message.decode(buffer.byteslice(2, expected - 2))
|
|
247
|
+
if reply.id != id
|
|
248
|
+
raise Resolv::ResolvError, "DNS result has an unexpected id for #{name}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
return reply
|
|
252
|
+
ensure
|
|
253
|
+
sock.close
|
|
254
|
+
Resolv::DNS.free_request_id(host, port, id) if !id.nil?
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
module Triauth
|
|
21
|
+
# The namespace for DNS resolvers.
|
|
22
|
+
module Resolvers
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require_relative 'resolvers/base'
|
|
27
|
+
require_relative 'resolvers/dns_json'
|
|
28
|
+
require_relative 'resolvers/multi_resolver'
|
|
29
|
+
require_relative 'resolvers/caching_resolver'
|
|
30
|
+
require_relative 'resolvers/cloudflare'
|
|
31
|
+
require_relative 'resolvers/google'
|
|
32
|
+
require_relative 'resolvers/system_dns'
|
|
33
|
+
require_relative 'resolvers/dns_sb'
|