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,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
|