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,156 @@
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
+ # An implementation of simple logger that is set as the default value for `Triauth.config[:logger]` with output to the console.
22
+ class Logger
23
+ # The port of the JS `console`, the default `output` of the logger that `Triauth.config[:logger]` is set to: `debug` and
24
+ # `info` write one line to `$stdout`, `warn` and `error` one line to `$stderr`. A line is the parts joined by a space,
25
+ # Strings as they are, other values via `inspect`.
26
+ module CONSOLE
27
+ # @param args [Array<Object>] The parts of one log line.
28
+ def self.debug(*args)
29
+ write($stdout, args)
30
+ end
31
+
32
+ # @param args [Array<Object>] The parts of one log line.
33
+ def self.info(*args)
34
+ write($stdout, args)
35
+ end
36
+
37
+ # @param args [Array<Object>] The parts of one log line.
38
+ def self.warn(*args)
39
+ write($stderr, args)
40
+ end
41
+
42
+ # @param args [Array<Object>] The parts of one log line.
43
+ def self.error(*args)
44
+ write($stderr, args)
45
+ end
46
+
47
+ # Writes the parts as one line to `io`: Strings as they are, other values via `inspect`.
48
+ def self.write(io, args)
49
+ io.puts(args.map { |part| part.is_a?(String) ? part : part.inspect }.join(' '))
50
+ end
51
+ private_class_method :write
52
+ end
53
+
54
+ # @return [Object, nil] The object to which log messages are sent (see {#initialize}).
55
+ attr_accessor :output
56
+ # @return [String] A minimal level of messages that get logged to output. One of `debug`, `info`, `warn`, `error`.
57
+ attr_accessor :log_level
58
+ # @return [String] A prefix to use when logging messages.
59
+ attr_accessor :prefix
60
+ # @return [String, false, nil] The correlation/context identifier shown in the `[cid:...]` tag.
61
+ attr_accessor :cid
62
+
63
+ # @param output [Object, nil] An object to which log messages will be sent, that should implement `debug`, `info`, `warn`, `error` methods, just like {CONSOLE} (the port of the native `console`). Defaults to nil.
64
+ # @param options [Hash] Additional configurations options. Defaults to `{}`.
65
+ # @option options [String] :logLevel ('warn') A minimal level of messages that get logged to output. One of `debug`, `info`, `warn`, `error`. Defaults to 'warn'.
66
+ # @option options [String] :prefix ('[triauth]') A prefix to use when logging messages. Defaults to '[triauth]'.
67
+ # @option options [String, false, nil] :cid (nil) A correlation/context identifier shown in the `[cid:...]` tag, to identify log lines belonging to a single API call. A nil value leaves the tag empty here; `false` explicitly suppresses it. (The `spawn` factory resolves nil into an auto-generated id.)
68
+ def initialize(output = nil, options = {})
69
+ @output = output
70
+ @log_level = options[:logLevel].nil? || options[:logLevel].empty? ? 'warn' : options[:logLevel]
71
+ @prefix = options[:prefix].nil? || options[:prefix].empty? ? '[triauth]' : options[:prefix]
72
+ @cid = options[:cid]
73
+ end
74
+
75
+ # Returns a child logger for a single API call, resolving the correlation id (`cid`):
76
+ # - nil: "autofill" - inherit this logger's `cid` or mint a fresh one.
77
+ # - `false`: explicit opt-out - stored as-is and suppressed by `format_message` (no `[cid:...]` tag).
78
+ # - string: used verbatim as the correlation id.
79
+ #
80
+ # @param cid [String, false, nil] explicit id, `false` to opt out, or nil to autofill. Defaults to nil.
81
+ # @return [Logger]
82
+ def spawn(cid = nil)
83
+ return Logger.new(
84
+ @output,
85
+ {
86
+ logLevel: @log_level,
87
+ prefix: @prefix,
88
+ cid: if cid.nil?
89
+ # `this.cid || this.#generateCid()`: the JS falsy set of the `string|false|null` cid (nil, false and '') mints a fresh id
90
+ @cid.nil? || @cid == false || @cid == '' ? generate_cid : @cid
91
+ else
92
+ cid
93
+ end
94
+ }
95
+ )
96
+ end
97
+
98
+ # Logs a message at the `debug` level, when the logger's `logLevel` admits it.
99
+ #
100
+ # @param args [Array<Object>] Values to log. Passed through verbatim to the output's `debug` function,
101
+ # behind the logger's prefix, correlation id (when set), and level tag.
102
+ def debug(*args)
103
+ if %w[debug].include?(@log_level)
104
+ @output&.debug(*format_message('debug', args))
105
+ end
106
+ end
107
+
108
+ # Logs a message at the `info` level, when the logger's `logLevel` admits it.
109
+ #
110
+ # @param args [Array<Object>] Values to log. Passed through verbatim to the output's `info` function,
111
+ # behind the logger's prefix, correlation id (when set), and level tag.
112
+ def info(*args)
113
+ if %w[debug info].include?(@log_level)
114
+ @output&.info(*format_message('info', args))
115
+ end
116
+ end
117
+
118
+ # Logs a message at the `warn` level, when the logger's `logLevel` admits it.
119
+ #
120
+ # @param args [Array<Object>] Values to log. Passed through verbatim to the output's `warn` function,
121
+ # behind the logger's prefix, correlation id (when set), and level tag.
122
+ def warn(*args)
123
+ if %w[debug info warn].include?(@log_level)
124
+ @output&.warn(*format_message('warn', args))
125
+ end
126
+ end
127
+
128
+ # Logs a message at the `error` level, when the logger's `logLevel` admits it.
129
+ #
130
+ # @param args [Array<Object>] Values to log. Passed through verbatim to the output's `error` function,
131
+ # behind the logger's prefix, correlation id (when set), and level tag.
132
+ def error(*args)
133
+ if %w[debug info warn error].include?(@log_level)
134
+ @output&.error(*format_message('error', args))
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def format_message(log_level, args)
141
+ parts = [@prefix]
142
+
143
+ if @cid && @cid != ''
144
+ parts.push("[cid:#{@cid}]")
145
+ end
146
+
147
+ parts.push("[#{log_level}]")
148
+
149
+ return [*parts, *args]
150
+ end
151
+
152
+ def generate_cid
153
+ return rand(36**8).to_s(36).rjust(8, '0')
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,177 @@
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 'error'
21
+ require_relative 'helpers'
22
+ require_relative 'protocol'
23
+ require_relative 'signature'
24
+
25
+ module Triauth
26
+ # A multi-signature envelope, which is one or more `Triauth::Signature` segments joined and wrapped by the `|` character.
27
+ class MultiSignature
28
+ # @return [Hash{Symbol => Object}] The effective settings: the global `Triauth.config` with the constructor's overrides merged over it.
29
+ attr_reader :config
30
+ # @return [String] The multi-signature envelope this instance was parsed from.
31
+ attr_reader :raw_data
32
+ # @return [Array<Triauth::Signature>] The signature segments the envelope carries, in their order.
33
+ attr_reader :signatures
34
+
35
+ # Concatenates individual signature envelopes into one multi-signature envelope.
36
+ #
37
+ # @param signatures [Array<String>] Individual `Triauth::Signature` envelopes, each wrapped in `|`.
38
+ #
39
+ # @return [String] the multi-signature envelope carrying the given signatures, in the given order.
40
+ def self.generate(*signatures)
41
+ return Protocol::WRAPPER + signatures.map { |s| s[1...-1] }.join(Protocol::WRAPPER) + Protocol::WRAPPER
42
+ end
43
+
44
+ # Parses a multi-signature envelope into the individual `Triauth::Signature` segments it carries.
45
+ #
46
+ # @param raw_data [String] A multi-signature envelope: one or more signature segments wrapped in,
47
+ # and delimited by, the `|` character.
48
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
49
+ #
50
+ # @raise [Triauth::Error] `invalid_signature` when the envelope is not a string, is not `|`-wrapped, exceeds
51
+ # `LIMITS[:signatureBytesize]`, carries more than `LIMITS[:maxMultiSignatures]`
52
+ # segments, or repeats a byte-identical segment.
53
+ def initialize(raw_data, config = {})
54
+ @config = Helpers.merge_config(config)
55
+
56
+ # Check the basic sanity and format of received arguments
57
+ if !Helpers.is_string(raw_data) ||
58
+ raw_data.length <= 3 ||
59
+ (raw_data[0] != Protocol::WRAPPER || raw_data[-1] != Protocol::WRAPPER) ||
60
+ Helpers.byte_size(raw_data) > Protocol::LIMITS[:signatureBytesize]
61
+ raise Triauth::Error.new({ code: 'invalid_signature', reason: 'invalid', message: 'Invalid signature' })
62
+ end
63
+
64
+ @raw_data = raw_data
65
+
66
+ segments = raw_data[1...-1].split(Protocol::WRAPPER, -1)
67
+
68
+ max_multi_signatures = Protocol::LIMITS[:maxMultiSignatures]
69
+ if segments.length < 1 || segments.length > max_multi_signatures
70
+ raise Triauth::Error.new({ code: 'invalid_signature', reason: 'invalid', message: 'Invalid signature' })
71
+ end
72
+
73
+ # Do not allow byte-identical duplicate segments
74
+ if segments.uniq.length != segments.length
75
+ raise Triauth::Error.new({ code: 'invalid_signature', reason: 'invalid', message: 'Invalid signature' })
76
+ end
77
+
78
+ # Extract and parse signature segments
79
+ @signatures = segments.map { |sig| Signature.new(Protocol::WRAPPER + sig + Protocol::WRAPPER, config) }
80
+ end
81
+
82
+ # Verifies that the signature is valid for the given message.
83
+ #
84
+ # @param message [String] Message, should be the same as originally signed.
85
+ # @param constraints [Hash] Optional constraints that the signature must satisfy. Defaults to `{}`.
86
+ # @option constraints [String] :type If given, passed through to each contributing `Signature#verify` for type matching.
87
+ # @option constraints [String] :identifier If given, must match the identifier for which it was created.
88
+ # @option constraints [String] :actor If given, passed through to each contributing `Signature#verify` for actor matching ('' = no actor).
89
+ # @option constraints [Integer] :ver If given, passed through to each contributing `Signature#verify` for version matching.
90
+ # @option constraints [String] :via If given, passed through to each contributing `Signature#verify` for client URL matching.
91
+ # @option constraints [Integer] :notBefore If given, must be a timestamp after which the signature was created.
92
+ # @option constraints [Integer] :notAfter If given, must be a timestamp before which the signature was created.
93
+ # @option constraints [Integer] :minSignatures If given, the multi-signature envelope must contain at least this many signatures.
94
+ # @option constraints [Integer] :maxSignatures If given, the multi-signature envelope must contain at most this many signatures.
95
+ #
96
+ # @return [Hash, false, nil] `{type: 'multisig', valid: Boolean, secure: (Boolean, nil), expires: (Integer, nil), verifiedAt: (Integer, nil), signatures: Array<Hash>}`, false or nil:
97
+ # - `false` upon failed verification,
98
+ # - `nil` upon time-related constraint failure (so callers can distinguish "expired" from generic failure),
99
+ # - or a Hash with:
100
+ # `type` - always the literal `'multisig'`,
101
+ # `valid` - `true` when every contributing signature verified,
102
+ # `secure` - `true` only when every contributing signature's identity was DNSSEC-validated; `nil` when there are no results to aggregate,
103
+ # `expires` - the earliest defined per-signature expiry (ms timestamp), or `nil` if none of the contributing key groups had a TTL,
104
+ # `verifiedAt` - the latest (max) per-signature verification timestamp (ms) - when the whole envelope finished verifying; `nil` when there are no results to aggregate,
105
+ # `signatures` - array of per-signature verification results (see `Triauth::Signature#verify` for each entry's shape).
106
+ def verify(message, constraints = {})
107
+ verification_results = []
108
+
109
+ constraints = Helpers.object_assign({}, constraints)
110
+
111
+ if !Helpers.has_only_known_properties(
112
+ constraints,
113
+ [
114
+ :minSignatures, :maxSignatures, # enforced by this method
115
+ :type, :identifier, :actor, :ver, :via, :notBefore, :notAfter # enforced by Triauth.Signature.verify
116
+ ],
117
+ false
118
+ )
119
+ Helpers.log(@config, :error, 'Unrecognized constraint')
120
+ return false
121
+ end
122
+
123
+ # Fail if signature count constraints are provided in a non-numeric form
124
+ if (!constraints[:minSignatures].nil? && (!Helpers.is_number(constraints[:minSignatures]) || (constraints[:minSignatures].is_a?(Float) && constraints[:minSignatures].nan?))) ||
125
+ (!constraints[:maxSignatures].nil? && (!Helpers.is_number(constraints[:maxSignatures]) || (constraints[:maxSignatures].is_a?(Float) && constraints[:maxSignatures].nan?)))
126
+ Helpers.log(@config, :error, 'Invalid minSignatures/maxSignatures constraint')
127
+ return false
128
+ end
129
+
130
+ if !constraints[:minSignatures].nil? && @signatures.length < constraints[:minSignatures]
131
+ return false
132
+ end
133
+
134
+ constraints.delete(:minSignatures)
135
+
136
+ if !constraints[:maxSignatures].nil? && @signatures.length > constraints[:maxSignatures]
137
+ return false
138
+ end
139
+
140
+ constraints.delete(:maxSignatures)
141
+
142
+ @signatures.each do |signature|
143
+ sig_ver_res = signature.verify(message, constraints)
144
+
145
+ if !sig_ver_res || !sig_ver_res[:valid]
146
+ return sig_ver_res.nil? ? nil : false
147
+ end
148
+
149
+ verification_results.push(sig_ver_res)
150
+ end
151
+
152
+ valid = verification_results.length > 0 && verification_results.all? { |r| r && r[:valid] }
153
+ secure = verification_results.length > 0 ? verification_results.all? { |r| r && r[:secure] } : nil
154
+
155
+ # Combine expiries by taking the earliest defined value. An `undefined` expires on any single
156
+ # result means "no expiry constraint from this key" and should not poison the min.
157
+ # When no result carries an expiry, the combined expires is undefined.
158
+ expiries_with_values = verification_results.map { |r| r && r[:expires] }.select { |e| Helpers.is_number(e) }
159
+ expires = expiries_with_values.length > 0 ? expiries_with_values.min : nil
160
+
161
+ # Combine per-signature verification timestamps by taking the LATEST: the whole envelope is only
162
+ # fully verified once its slowest signature (e.g., one whose identity DNS blocked) has confirmed.
163
+ # Mirrors the expires filter above so a stray undefined cannot turn Math.max into NaN.
164
+ verified_at_values = verification_results.map { |r| r && r[:verifiedAt] }.select { |v| Helpers.is_number(v) }
165
+ verified_at = verified_at_values.length > 0 ? verified_at_values.max : nil
166
+
167
+ return {
168
+ type: 'multisig',
169
+ valid: valid,
170
+ secure: secure,
171
+ expires: expires,
172
+ verifiedAt: verified_at,
173
+ signatures: verification_results
174
+ }
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,74 @@
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
+ # Constants as defined by the triauth protocol.
22
+ module Protocol
23
+ # Protocol version, and its token form used in the signature envelope's version slot
24
+ VERSION = 1
25
+ VERSION_TOKEN = "v#{VERSION}"
26
+
27
+ # Signature envelope wrapper and field delimiter characters
28
+ WRAPPER = '|'
29
+ DELIMITER = ';'
30
+
31
+ # The marker that replaces '@' when deriving an identity domain from an identifier
32
+ AT_MARKER = '._at.'
33
+
34
+ # The default mode= and include= for domains that do not specify ones
35
+ DEFAULT_MODE = 'private'
36
+ DEFAULT_INCLUDE = 'any'
37
+
38
+ # Private-mode identity domains label length
39
+ PRIVATE_LABEL_LENGTH = 10
40
+
41
+ # The challenge-response flows: signature envelope `type` slots and key/include `use=` options
42
+ FLOWS = %w[attest auth ping sign stamp].freeze
43
+ FLOWS_USE_REGEXP = /\A(#{FLOWS.join('|')})(,(#{FLOWS.join('|')})){0,#{FLOWS.length - 1}}\z/
44
+
45
+ # The `include` record's `scope=` reserved values
46
+ SCOPE_ANY = 'any'
47
+ SCOPE_NONE = 'none'
48
+
49
+ # Shared validation/parsing limits.
50
+ LIMITS = {
51
+ identifierBytesize: 120, # the maximal bytesize of a triauth identifier (`username@domain`)
52
+ domainNameBytesize: 253, # the maximal bytesize of a DNS name (RFC 1035), bounding every host and domain name read from the wire
53
+ urlBytesize: 2048, # the maximal bytesize of URLs (e.g., callbackUrl, attachment's sourceUrl, etc.)
54
+ challengeBytesize: 16 * 1024, # the maximal bytesize of a base64url-encoded challenge string
55
+ signatureBytesize: 16 * 1024, # the maximal bytesize of a Signature/MultiSignature envelope, also bounds the `response` string returned by the authenticator
56
+ extBytesize: 4 * 1024, # the maximal bytesize of the JSON-serialized `ext` protocol-extensions object
57
+ messageBytesize: 2 * 1024, # the maximal bytesize of a message to be signed or stamped
58
+ attachmentsCount: 10, # the maximal number of attachments accepted by Triauth.sign
59
+ deviceNameBytesize: 20, # the maximal bytesize of a device name, as visible in the identity records in DNS
60
+ maxDevices: 10, # the maximal number of devices per user, as visible in the identity records in DNS
61
+ maxKeysPerDevice: 5, # the maximal number of keys associated with a single device, as visible in the identity records in DNS
62
+ maxIncludes: 50, # the maximal number of include statements per user, as visible in the identity records in DNS
63
+ maxGroups: 200, # the maximal number of group names per user, as visible in the identity records in DNS
64
+ maxKeysPerSignature: 10, # the maximal number of keys that may be used in a single Signature, defaults to 2 * maxKeysPerDevice (to allow for key rotation)
65
+ maxMultiSignatures: 5, # the maximal number of individual Signatures inside MultiSignature (multi-signatures are typically used for attestations)
66
+ jsonMaxBytesize: 256 * 1024, # the maximal allowable bytesize of JSON string that is considered safe for parsing
67
+ jsonMaxNestingDepth: 8, # the maximal nesting depth of objects in JSON strings (root counts as depth 1, so `{}` is depth 1)
68
+ jsonMaxKeyLength: 255, # the maximal length of a single object key in JSON strings
69
+ publicProfileMaxKeyBytesize: 64, # per-key bytesize cap for publicProfile fields read from DNS, so a domain cannot bloat results
70
+ publicProfileMaxValueBytesize: 255, # per-value bytesize cap for publicProfile fields read from DNS, so a domain cannot bloat results
71
+ publicProfileMaxExtensions: 16 # max number of distinct `x-` extension fields kept in publicProfile (reserved keywords are not counted)
72
+ }.freeze
73
+ end
74
+ end
@@ -0,0 +1,121 @@
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 'openssl'
21
+ require 'securerandom'
22
+ require_relative 'helpers'
23
+
24
+ module Triauth
25
+ # Seals and opens the requests that follow a sign-in (ping, sign, stamp and attest), so that the redirect URL and
26
+ # the browser history show no identifier and no message.
27
+ #
28
+ # The client application seals the challenge with AES-128-GCM, under a request key that HKDF-SHA256 (RFC 5869)
29
+ # derives from the session key of the sign-in. The request also contains `skid`, a short hash of the session key,
30
+ # so that the authenticator can find the key. Only the client application and the authenticator know the session
31
+ # key, so a request that opens also proves where it comes from.
32
+ #
33
+ # A sealed request is `<iv>.<ciphertext>`, both base64url. The plaintext is the JSON of the challenge.
34
+ class RequestCipher
35
+ # The HKDF `info` of the request key
36
+ REQUEST_KEY_INFO = Helpers.string_to_utf8_bytes('triauth/request/v1')
37
+ private_constant :REQUEST_KEY_INFO
38
+
39
+ # Computes the session key ID, which the request contains readable so that the authenticator can find the key.
40
+ # It is the first 16 bytes of the SHA-256 of the 32 bytes of the session key.
41
+ #
42
+ # @param session_key [String] the session key, 43 base64url characters, without the domain of a delegated key.
43
+ #
44
+ # @return [String] the session key ID, 22 base64url characters.
45
+ def self.skid(session_key)
46
+ digest = OpenSSL::Digest::SHA256.digest(Helpers.base64url_to_uint8(session_key))
47
+ return Helpers.array_buffer_to_base64url(digest[0, 16])
48
+ end
49
+
50
+ # Seals a challenge under the session key. This is the client application's side.
51
+ #
52
+ # @param session_key [String] the session key, 43 base64url characters, without the domain of a delegated key.
53
+ # @param challenge [String] the challenge, base64url.
54
+ #
55
+ # @return [String] the sealed request, `<iv>.<ciphertext>`.
56
+ def self.seal(session_key, challenge)
57
+ # The IV comes from `SecureRandom` and never from `randomSource`. A repeated IV under one key breaks AES-GCM.
58
+ iv = SecureRandom.random_bytes(12)
59
+ key = request_key(session_key)
60
+ pt = Helpers.base64url_to_uint8(challenge)
61
+ cipher = OpenSSL::Cipher.new('aes-128-gcm').encrypt
62
+ cipher.key = key
63
+ cipher.iv = iv
64
+ # Web Crypto appends the 16-byte tag to the ciphertext. `Cipher#update` is never called with an empty string (some
65
+ # OpenSSL bindings refuse it), so an empty challenge seals to the tag alone, as Web Crypto gives.
66
+ ct = (pt.empty? ? ''.b : cipher.update(pt)) + cipher.final + cipher.auth_tag
67
+
68
+ return "#{Helpers.array_buffer_to_base64url(iv)}.#{Helpers.array_buffer_to_base64url(ct)}"
69
+ end
70
+
71
+ # Opens a sealed request with the session key. This is the authenticator's side.
72
+ #
73
+ # @param session_key [String] the session key, 43 base64url characters, without the domain of a delegated key.
74
+ # @param sealed_request [String] the sealed request, `<iv>.<ciphertext>`.
75
+ #
76
+ # @return [String] the challenge, base64url.
77
+ #
78
+ # @raise [TypeError] when the sealed request is malformed.
79
+ # @raise [OpenSSL::Cipher::CipherError] when the request does not open under this session key.
80
+ def self.open(session_key, sealed_request)
81
+ s = sealed_request.to_s
82
+ parts = s.empty? ? [''] : s.split('.', -1) # `String(x).split('.')`: JS splits `''` into `['']`, Ruby into `[]`
83
+ iv, ct, rest = parts
84
+
85
+ if !rest.nil? || iv.nil? || iv.length != 16 || ct.nil? || ct.empty?
86
+ raise TypeError, 'Malformed sealed request'
87
+ end
88
+
89
+ iv_bytes = Helpers.base64url_to_uint8(iv)
90
+ key = request_key(session_key)
91
+ ct_bytes = Helpers.base64url_to_uint8(ct)
92
+ # Web Crypto's decrypt throws for a ciphertext shorter than its 16-byte tag, while OpenSSL would check a truncated
93
+ # tag: the length is refused here.
94
+ if ct_bytes.bytesize < 16
95
+ raise TypeError, 'Malformed ciphertext'
96
+ end
97
+
98
+ cipher = OpenSSL::Cipher.new('aes-128-gcm').decrypt
99
+ cipher.key = key
100
+ cipher.iv = iv_bytes
101
+ cipher.auth_tag = ct_bytes[-16..] # the tag follows the ciphertext, as Web Crypto lays it out
102
+ # `Cipher#update` is never called with an empty string (see `seal`); a wrong tag raises `OpenSSL::Cipher::CipherError`
103
+ pt = (ct_bytes.bytesize == 16 ? ''.b : cipher.update(ct_bytes[0...-16])) + cipher.final
104
+
105
+ return Helpers.array_buffer_to_base64url(pt)
106
+ end
107
+
108
+ # Derives the AES-128-GCM request key from the session key with HKDF-SHA256 (RFC 5869). The empty salt acts as 32
109
+ # zero bytes, and the info label keeps this key apart from any other key of the session.
110
+ #
111
+ # @param session_key [String] the session key, 43 base64url characters.
112
+ #
113
+ # @return [String] the request key, 16 raw bytes (a binary String).
114
+ def self.request_key(session_key)
115
+ # the `usage` parameter (`encrypt` or `decrypt`, a Web Crypto key-usage flag) has no counterpart: the raw bytes are the key
116
+ ikm = Helpers.base64url_to_uint8(session_key)
117
+ return OpenSSL::KDF.hkdf(ikm, salt: ''.b, info: REQUEST_KEY_INFO, length: 16, hash: 'SHA256')
118
+ end
119
+ private_class_method :request_key
120
+ end
121
+ end
@@ -0,0 +1,147 @@
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 '../error'
21
+ require_relative '../helpers'
22
+
23
+ module Triauth
24
+ module Resolvers
25
+ # A base class from which other DNS resolvers inherit common functions.
26
+ # Subclasses are expected to implement `resolve(domain_name, type, options = {}, config = {})`.
27
+ class Base
28
+ # Queries the DNS for resource records of a given type that are stored under a given domain name.
29
+ #
30
+ # This is the abstract contract that concrete subclasses (e.g., DnsJson, SystemDns, MultiResolver) implement.
31
+ #
32
+ # @param domain_name [String] A domain name to query the DNS for.
33
+ # @param type [String] A type of DNS resource record to look for (e.g., 'TXT', 'A').
34
+ # @param options [Hash] Per-call overrides for the resolver options passed to the constructor. Defaults to `{}`.
35
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
36
+ #
37
+ # @return [Array<Hash>] The array of retrieved DNS resource records in `{value, ttl, dnssec}` format
38
+ # (`value` a String, `ttl` an Integer or nil, `dnssec` a Boolean or nil).
39
+ #
40
+ # @raise [RuntimeError] always - this method is abstract and carries no implementation of its own.
41
+ def resolve(domain_name, type, options = {}, config = {}) # rubocop:disable Lint/UnusedMethodArgument -- the abstract signature is the contract
42
+ raise 'Triauth.Resolvers.Base#resolve is abstract and must be implemented by a subclass'
43
+ end
44
+
45
+ # Returns configuration entries that are stored as DNS TXT records under a given domain name.
46
+ # Each configuration entry consist of a key, optional value, and any number of options.
47
+ #
48
+ # Options are the maximal trailing run of whole whitespace-separated `key=value` tokens;
49
+ # the text before that run is the entry's value, kept verbatim — `=`-containing text outside
50
+ # the trailing run is ordinary value text and needs no escaping. Option values and the value
51
+ # are percent-decoded; a record violating these rules is ignored as a whole.
52
+ #
53
+ # @example
54
+ #
55
+ # # Given the following DNS TXT record of an example.com domain:
56
+ # # `example.com IN TXT "app triauth-authenticator style=dark logo=none"`
57
+ # resolve_config('example.com')
58
+ # => [{key: "app", value: "triauth-authenticator", options: {style: "dark", logo: "none"}, ttl: 60, dnssec: false}]
59
+ #
60
+ # @param domain_name [String] a domain name for/of which configuration should be read from the DNS
61
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
62
+ #
63
+ # @return [Array<Hash>] The array of configuration entries/objects, as read from DNS, each in form of
64
+ # `{key, value, options, ttl, dnssec}` (`key` and `value` Strings, `options` a Hash of Symbol keys to String
65
+ # values, `ttl` an Integer or nil, `dnssec` a Boolean or nil).
66
+ #
67
+ # @raise [Triauth::Error] `dns_unavailable` when the underlying DNS query fails - intentionally propagated so that
68
+ # API methods report a retryable error instead of treating the name as unconfigured.
69
+ def resolve_config(domain_name, config = {})
70
+ config = Helpers.merge_config(config)
71
+
72
+ Helpers.log(config, :debug, "[DNS] Querying DNS for config records of #{domain_name}")
73
+
74
+ rv = []
75
+
76
+ begin
77
+ txt_records = resolve(domain_name, 'TXT', {}, config)
78
+ rescue StandardError => err
79
+ Helpers.log(config, :debug, "DNS name resolution failed for #{domain_name}", { cause: err })
80
+ raise Triauth::Error.new({ code: 'dns_unavailable', message: 'DNS resolution failed, try again later' })
81
+ end
82
+
83
+ # A record is its key, a blank run, and a rest with no line terminator. An option is a whole
84
+ # whitespace-separated key=value token, so `a+b=c` never yields one. The `\S` stops the blank
85
+ # run and the rest from trading characters, so the parse stays linear in the record size.
86
+ record_regexp = /\A([A-Za-z0-9_-]+)#{Helpers::JS_WS}+(#{Helpers::JS_NOT_WS}#{Helpers::JS_DOT}*)\z/
87
+ option_token_regexp = /\A([A-Za-z0-9_\-\[\].@]+)=(#{Helpers::JS_NOT_WS}+)\z/
88
+
89
+ # The callback is a lambda, so that `return` leaves the record as in JS, from inside the option loop too (where it
90
+ # is spelled `return nil`, so the iterator lint sees it is deliberate); the block's `rescue` is the `try`/`catch`.
91
+ txt_records.each(&lambda do |record|
92
+ if (md = record[:value].match(record_regexp))
93
+ entry = { key: md[1], value: '', options: {}, ttl: record[:ttl], dnssec: record[:dnssec] }
94
+
95
+ if !Helpers.is_normal_string(entry[:key], 255)
96
+ return
97
+ end
98
+
99
+ # Split once, keeping the blank runs (odd indexes) so the value is reassembled verbatim.
100
+ # The option run is the maximal suffix of option tokens, collected right-to-left.
101
+ parts = Helpers.trim_end(md[2]).split(/(#{Helpers::JS_WS}+)/, -1)
102
+ option_matches = []
103
+ i = parts.length - 1
104
+ while i >= 0 && (option_md = parts[i].match(option_token_regexp))
105
+ option_matches.push(option_md)
106
+ i -= 2
107
+ end
108
+ entry[:value] = i < 0 ? '' : parts[0..i].join
109
+ option_matches.reverse!
110
+
111
+ # Process the run left to right; a repeated option key takes its last occurrence.
112
+ # Option values are consumed as published - no percent-decoding at this layer: options are
113
+ # machine vocabularies (and enter tag preimages), so their bytes stay those of the zone file.
114
+ option_matches.each do |m|
115
+ option_key = m[1]
116
+ option_value = m[2]
117
+
118
+ if !(Helpers.is_normal_string(option_key, 255) && Helpers.is_normal_string(option_value, nil))
119
+ return nil
120
+ end
121
+
122
+ # The map has no prototype, so a name such as `__proto__` is an ordinary key here, and the rules of
123
+ # each record family judge it like any other unknown option
124
+ entry[:options][option_key.to_sym] = option_value
125
+ end
126
+
127
+ # The text before the option run is the value. An options-only record has none and is ignored below.
128
+ entry[:value] = Helpers.trim(entry[:value])
129
+
130
+ if !Helpers.is_normal_string(entry[:value], nil)
131
+ return
132
+ end
133
+
134
+ return rv.push(entry)
135
+ end
136
+ rescue StandardError => err
137
+ Helpers.log(config, :debug, err)
138
+ # continue regardless of error
139
+ end)
140
+
141
+ Helpers.log(config, :debug, "[DNS] Parsed #{rv.length} DNS config record(s) from #{domain_name}")
142
+
143
+ return rv
144
+ end
145
+ end
146
+ end
147
+ end