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,344 @@
|
|
|
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 'validator'
|
|
23
|
+
require_relative 'identity'
|
|
24
|
+
require_relative 'challenge'
|
|
25
|
+
require_relative 'response'
|
|
26
|
+
require_relative 'response_cipher'
|
|
27
|
+
require_relative 'request_cipher'
|
|
28
|
+
require_relative 'authentication_endpoint'
|
|
29
|
+
require_relative 'config'
|
|
30
|
+
|
|
31
|
+
module Triauth
|
|
32
|
+
# Internal helper that implements the shared 3-step challenge-response flow used by
|
|
33
|
+
# some of the top-level API methods (e.g., `authenticate`).
|
|
34
|
+
#
|
|
35
|
+
# Each API method delegates to `perform()` with a flow `type` and a pair of callbacks
|
|
36
|
+
# (`onChallenge`, `onResponse`) carrying the per-flow logic.
|
|
37
|
+
class ChallengeResponseFlow
|
|
38
|
+
# Executes one stage of the challenge-response flow:
|
|
39
|
+
# - **Stage 1** (when `identifier`+`callbackUrl` are given, `challenge`/`response` are not): builds a challenge
|
|
40
|
+
# that carries a fresh encryption key (`ek`), invokes `onChallenge` to let the caller mutate `challenge.data`,
|
|
41
|
+
# then returns `{challenge, redirectUrl}`. The returned `challenge` is `<challenge>.<decryptionKey>`.
|
|
42
|
+
# - **Stage 3** (when `challenge`+`response` are given): parses both, opens the sealed response with the
|
|
43
|
+
# decryption key of the stored challenge, sanity-checks them against optional `identifier`/`callbackUrl`
|
|
44
|
+
# constraints, then invokes `onResponse` for per-flow verification.
|
|
45
|
+
#
|
|
46
|
+
# @param type [String] Flow type: `'auth'`, `'ping'`, `'attest'`, `'sign'`, or `'stamp'`.
|
|
47
|
+
# @param options [Hash] User-facing options. Stage 1 expects `{identifier, callbackUrl, ext?, sessionKey?}`
|
|
48
|
+
# plus the flow's own options (`sessionKey` is required for every flow except `'auth'`,
|
|
49
|
+
# where it is rejected). Stage 3 expects `{challenge, response, identifier?, callbackUrl?}`
|
|
50
|
+
# (the optional ones, if provided, are additionally checked against the values embedded
|
|
51
|
+
# in the challenge) and rejects every other option as unrecognized. Defaults to `{}`.
|
|
52
|
+
# @param callbacks [Hash{Symbol => Proc}] `{onChallenge: Proc, onResponse: Proc}` -
|
|
53
|
+
# `onChallenge.call({challenge: challenge})` runs after the `Triauth::Challenge` is built (stage 1), giving the caller
|
|
54
|
+
# a chance to populate `challenge.data` (e.g., `msg`, `attest`, `attachments`).
|
|
55
|
+
# `onResponse.call({challenge: challenge, response: response, sessionKey: session_key})` performs verification (stage 3); must return the per-flow
|
|
56
|
+
# success object, `nil` for expired-style failures, or `false` for generic verification failures.
|
|
57
|
+
# `sessionKey` is the session key from opening the sealed response (see `ResponseCipher.open`).
|
|
58
|
+
# @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
|
|
59
|
+
# @param flow_options [Array<Symbol>] Names of the flow's own stage-1 options (e.g., `:message`), which `onChallenge`
|
|
60
|
+
# reads from `options` and which stage 3 rejects like any unrecognized option. Defaults to `[]`.
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] `{challenge: String, redirectUrl: String}` (stage 1), whatever `onResponse` returned
|
|
63
|
+
# (stage 3 success), or `{error: {code: String, reason: String (optional), message: String}}` if any error was caught. When
|
|
64
|
+
# `config[:requireSecure]` is set, a stage-3 result that is not DNSSEC-secure is rejected with
|
|
65
|
+
# `{error: {code: 'dnssec_required', …}}` instead of being returned (see `assert_secure`).
|
|
66
|
+
def self.perform(type, options = {}, callbacks = { onChallenge: nil, onResponse: nil }, config = {}, flow_options = [])
|
|
67
|
+
Helpers.log(config, :info, "Processing Triauth.#{type}")
|
|
68
|
+
ts = Helpers.now
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
# Ensure only recognized options are present to help avoid misspellings. Stage 3 reads the challenge and the
|
|
72
|
+
# response, and cross-checks an identifier and a callbackUrl against the challenge. Every other option shapes
|
|
73
|
+
# the challenge at stage 1: `ext`, the flow's own options, and `sessionKey` for the flows that follow a sign-in
|
|
74
|
+
# (ping/attest/sign/stamp), not for auth.
|
|
75
|
+
stage3 = options.is_a?(Hash) && !options[:challenge].nil? && !options[:response].nil? # `options !== null && typeof options === 'object'`: a Hash (an Array has no `challenge` member on either side)
|
|
76
|
+
allowed_options = if stage3 # the JS ternary nests a second one for `sessionKey`: an `if` expression (RuboCop `Style/NestedTernaryOperator`)
|
|
77
|
+
%i[challenge response identifier callbackUrl]
|
|
78
|
+
else
|
|
79
|
+
[:identifier, :callbackUrl, :challenge, :response, :ext, *flow_options, *(type != 'auth' ? [:sessionKey] : [])]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if !Helpers.has_only_known_properties(options, allowed_options)
|
|
83
|
+
raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Unrecognized function argument' })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Reject config overrides carrying unknown keys (typos that would otherwise be silently ignored,
|
|
87
|
+
# sometimes weakening security - e.g. a misspelled `requireSecure`). We validate the MERGED config,
|
|
88
|
+
# so a stray key is caught whether it came from this call's override or a global Triauth.config
|
|
89
|
+
if !Helpers.has_only_known_properties(config, Triauth::KNOWN_CONFIG_KEYS)
|
|
90
|
+
raise Triauth::Error.new({ code: 'invalid_config', message: 'Unrecognized configuration key' })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Read supported options
|
|
94
|
+
identifier = options[:identifier]
|
|
95
|
+
callback_url = options[:callbackUrl]
|
|
96
|
+
challenge = options[:challenge]
|
|
97
|
+
response = options[:response]
|
|
98
|
+
ext = options[:ext]
|
|
99
|
+
session_key = options[:sessionKey]
|
|
100
|
+
|
|
101
|
+
# Stage 1 - identifier, callbackUrl, and ext (optional) are provided, and we need to build and return a challenge and redirectUrl
|
|
102
|
+
if !identifier.nil? &&
|
|
103
|
+
!callback_url.nil? &&
|
|
104
|
+
challenge.nil? && response.nil?
|
|
105
|
+
validation_error =
|
|
106
|
+
Validator.validate_identifier(identifier)[:errors][0] ||
|
|
107
|
+
Validator.validate_callback_url(callback_url)[:errors][0] ||
|
|
108
|
+
(!ext.nil? && Validator.validate_ext(ext)[:errors][0]) ||
|
|
109
|
+
(!session_key.nil? && Validator.validate_session_key(session_key)[:errors][0])
|
|
110
|
+
|
|
111
|
+
if validation_error
|
|
112
|
+
raise Triauth::Error.new(validation_error)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Every flow that follows a sign-in (all but `auth`) requires the session key of that sign-in
|
|
116
|
+
if type != 'auth' && session_key.nil?
|
|
117
|
+
raise Triauth::Error.new({ code: 'invalid_session_key', reason: 'invalid', message: 'Invalid session key' })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
Helpers.log(config, :info, 'Request parameters:', { identifier: identifier, callbackUrl: callback_url, ext: ext })
|
|
121
|
+
|
|
122
|
+
identity = Identity.new(identifier, {}, config)
|
|
123
|
+
|
|
124
|
+
# Prepare options for the authentication request
|
|
125
|
+
challenge_options = {}
|
|
126
|
+
|
|
127
|
+
challenge_options[:cburl] = callback_url
|
|
128
|
+
|
|
129
|
+
if !ext.nil?
|
|
130
|
+
challenge_options[:ext] = ext
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Build the authentication request for the given identity with the given options - `callbackUrl` and `ext`.
|
|
134
|
+
challenge_obj = Challenge.build(type, identity, challenge_options, config)
|
|
135
|
+
|
|
136
|
+
# The authenticator seals the response to `ek`. The decryption key stays with the stored challenge.
|
|
137
|
+
pair = ResponseCipher.generate_key_pair
|
|
138
|
+
encryption_key = pair[:encryptionKey]
|
|
139
|
+
decryption_key = pair[:decryptionKey]
|
|
140
|
+
challenge_obj.data[:ek] = encryption_key
|
|
141
|
+
|
|
142
|
+
callbacks[:onChallenge].call({ challenge: challenge_obj })
|
|
143
|
+
challenge_string = challenge_obj.to_s
|
|
144
|
+
|
|
145
|
+
# Fail early if the just-built challenge is not itself a valid challenge
|
|
146
|
+
challenge_error = Validator.validate_challenge(challenge_string)[:errors][0]
|
|
147
|
+
if challenge_error
|
|
148
|
+
raise Triauth::Error.new(challenge_error)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A delegated session key is `<domain>:<key>`, and its session is at the authenticator of that domain
|
|
152
|
+
# (`sessionKey?.includes(':')`: nil, or a validated key of one or two parts; `sessionKeyDomain ?`: the empty string is the only falsy value it can hold)
|
|
153
|
+
session_key_domain, session_key_secret = !session_key.nil? && session_key.include?(':') ? session_key.split(':', -1) : ['', session_key]
|
|
154
|
+
authentication_endpoint = session_key_domain != '' ? AuthenticationEndpoint.new(session_key_domain, config) : identity.authentication_endpoint
|
|
155
|
+
|
|
156
|
+
# Check if authentication endpoint has been configured for the domain name
|
|
157
|
+
if !authentication_endpoint.resolve
|
|
158
|
+
raise Triauth::Error.new({ code: 'domain_not_configured', message: 'Domain is not configured for triauth' })
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Enforce config.requireSecure if set
|
|
162
|
+
ChallengeResponseFlow.assert_secure(authentication_endpoint.secure, config)
|
|
163
|
+
|
|
164
|
+
# A request that follows a sign-in travels sealed under the session key, with `skid` to find the key.
|
|
165
|
+
# A sign-in has no session key yet, so its challenge travels readable.
|
|
166
|
+
# (`sessionKey ?`: nil, or a validated non-empty string)
|
|
167
|
+
redirect_url_params = !session_key.nil? ? { skid: RequestCipher.skid(session_key_secret), request: RequestCipher.seal(session_key_secret, challenge_string) } : { challenge: challenge_string }
|
|
168
|
+
|
|
169
|
+
redirect_url = authentication_endpoint.url_for(type, redirect_url_params)
|
|
170
|
+
redirect_url_string = redirect_url.to_s
|
|
171
|
+
|
|
172
|
+
# Return the challenge that the client should remember (with the key that opens the response), and a redirectUrl
|
|
173
|
+
# to which the user's web browser should be redirected.
|
|
174
|
+
return Helpers.clone({ challenge: "#{challenge_string}.#{decryption_key}", redirectUrl: redirect_url_string })
|
|
175
|
+
|
|
176
|
+
# Stage 3 - both challenge and response are provided, and we need to verify them against each other and return the result.
|
|
177
|
+
elsif !challenge.nil? &&
|
|
178
|
+
!response.nil?
|
|
179
|
+
|
|
180
|
+
if response == 'false'
|
|
181
|
+
raise Triauth::Error.new({ code: 'request_denied', message: 'Request has been denied' })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The stored challenge is `<challenge>.<decryptionKey>`, and the response is sealed to the challenge's `ek`
|
|
185
|
+
# (`typeof challenge === 'string' ? challenge.split('.') : []`: JS splits `''` into `['']` where Ruby gives `[]`)
|
|
186
|
+
challenge_string, decryption_key, rest = if Helpers.is_string(challenge)
|
|
187
|
+
challenge.empty? ? [''] : challenge.split('.', -1)
|
|
188
|
+
else
|
|
189
|
+
[]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
validation_error =
|
|
193
|
+
Validator.validate_challenge(challenge_string)[:errors][0] ||
|
|
194
|
+
((!rest.nil? || !Helpers.is_base64url_string(decryption_key) || decryption_key.length != 43) && { code: 'invalid_challenge', reason: 'invalid', message: 'Invalid challenge' }) || # the `&&` yields the Hash or false, falling through as in JS; `.length` is only reached on a base64url String
|
|
195
|
+
Validator.validate_sealed_response(response)[:errors][0] ||
|
|
196
|
+
(!identifier.nil? && Validator.validate_identifier(identifier)[:errors][0]) ||
|
|
197
|
+
(!callback_url.nil? && Validator.validate_callback_url(callback_url)[:errors][0])
|
|
198
|
+
|
|
199
|
+
if validation_error
|
|
200
|
+
raise Triauth::Error.new(validation_error)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
Helpers.log(config, :info, 'Verifying response with constraints', { type: type, identifier: identifier, callbackUrl: callback_url })
|
|
204
|
+
|
|
205
|
+
# Parse the received response (string) into the Triauth.Response object.
|
|
206
|
+
challenge_obj = nil
|
|
207
|
+
|
|
208
|
+
begin
|
|
209
|
+
challenge_obj = Challenge.from_string(challenge_string, config)
|
|
210
|
+
rescue StandardError => err
|
|
211
|
+
raise Triauth::Error.new({ code: 'invalid_challenge', reason: 'invalid', message: 'Invalid challenge' }, { cause: err })
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Every challenge carries the encryption key that its response is sealed to
|
|
215
|
+
encryption_key = challenge_obj.data[:ek]
|
|
216
|
+
if !Helpers.is_base64url_string(encryption_key) || encryption_key.length != 87
|
|
217
|
+
raise Triauth::Error.new({ code: 'invalid_challenge', reason: 'invalid', message: 'Invalid challenge' })
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# `let envelope, sessionKey;`: the JS `sessionKey` is a new binding of this block, shadowing the stage-1 `const`; here it re-assigns the same local, which stage 3 never reads otherwise
|
|
221
|
+
envelope = nil
|
|
222
|
+
session_key = nil
|
|
223
|
+
|
|
224
|
+
begin
|
|
225
|
+
opened = ResponseCipher.open(encryption_key, decryption_key, response)
|
|
226
|
+
envelope = opened[:response]
|
|
227
|
+
session_key = opened[:sessionKey]
|
|
228
|
+
rescue StandardError => err
|
|
229
|
+
raise Triauth::Error.new({ code: 'invalid_response', reason: 'invalid', message: 'Invalid response' }, { cause: err })
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
response_error = Validator.validate_response(envelope)[:errors][0]
|
|
233
|
+
if response_error
|
|
234
|
+
raise Triauth::Error.new(response_error)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
response_obj = Response.new(envelope, config)
|
|
238
|
+
|
|
239
|
+
# Make sure the challenge's type matches the flow type, and (if again passed in options)
|
|
240
|
+
# that the identifier and/or callbackUrl matches the one embedded in the challenge.
|
|
241
|
+
if challenge_obj.data[:type] != type ||
|
|
242
|
+
(!identifier.nil? && challenge_obj.identity.identifier != identifier) ||
|
|
243
|
+
(!callback_url.nil? && challenge_obj.data[:cburl] != callback_url)
|
|
244
|
+
raise Triauth::Error.new({ code: 'verification_failed', message: 'Your identity could not be verified' })
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
retval = callbacks[:onResponse].call({ challenge: challenge_obj, response: response_obj, sessionKey: session_key })
|
|
248
|
+
|
|
249
|
+
# `if (retval)`: the per-flow success Hash is truthy, the `nil` and `false` of the three-state contract are not
|
|
250
|
+
if retval
|
|
251
|
+
# Enforce config.requireSecure if set
|
|
252
|
+
ChallengeResponseFlow.assert_secure(retval[:secure].nil? ? retval[:verificationResult]&.[](:secure) : retval[:secure], config)
|
|
253
|
+
return retval
|
|
254
|
+
|
|
255
|
+
elsif retval.nil?
|
|
256
|
+
raise Triauth::Error.new({ code: 'request_expired', message: 'Authentication request has expired, please try again' })
|
|
257
|
+
|
|
258
|
+
else
|
|
259
|
+
raise Triauth::Error.new({ code: 'verification_failed', message: 'Your identity could not be verified' })
|
|
260
|
+
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Fail when the given function arguments do not match to any of stages.
|
|
264
|
+
else
|
|
265
|
+
raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Invalid or missing function arguments - expected either {identifier,callbackUrl,ext,...} or {challenge,response,identifier?,callbackUrl?}' })
|
|
266
|
+
|
|
267
|
+
end
|
|
268
|
+
rescue StandardError => err
|
|
269
|
+
return Triauth::Error.process(err, config)
|
|
270
|
+
ensure
|
|
271
|
+
Helpers.log(config, :info, "Completed in #{(Helpers.now - ts).round}ms")
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Verifies a single-signature challenge response and enforces the success invariant shared by the
|
|
276
|
+
# `auth`, `ping`, `sign`, and `stamp` flows: the envelope must carry exactly one signature, that
|
|
277
|
+
# signature must be valid and backed by at least one key, its identifier must match the one bound
|
|
278
|
+
# in the challenge, and the challenge's `cburl` must be a valid callback URL whose base URL equals
|
|
279
|
+
# the signature's `via`. (The multi-signature `attest` flow has a different shape - 1 user + N
|
|
280
|
+
# attester signatures - and intentionally does NOT use this helper.)
|
|
281
|
+
#
|
|
282
|
+
# @param type [String] flow type ('auth'|'ping'|'sign'|'stamp'), passed through to Response#verify.
|
|
283
|
+
# @param signed_payload [String] the exact bytes the signature is verified against (the serialized
|
|
284
|
+
# challenge for auth/ping; the human-readable `msg` for sign/stamp).
|
|
285
|
+
# @param challenge [Triauth::Challenge] the re-hydrated challenge.
|
|
286
|
+
# @param response [Triauth::Response] the parsed response envelope.
|
|
287
|
+
# @param timeout [Integer] the flow's freshness window (e.g., `config[:authTimeout]`), subtracted from now.
|
|
288
|
+
# @param config [Hash] effective config (with an already-spawned logger).
|
|
289
|
+
#
|
|
290
|
+
# @return [Hash, nil, false] On success, `{sig: Hash, verifyResult: Hash}` - the verified first
|
|
291
|
+
# signature plus the full multi-signature result (callers read `secure`/`expires` from the
|
|
292
|
+
# latter); `nil` on a time/expiry failure; `false` on any other verification failure.
|
|
293
|
+
def self.verify_challenge_response(type, signed_payload, challenge, response, timeout, config)
|
|
294
|
+
verify_result = response.verify(
|
|
295
|
+
type,
|
|
296
|
+
signed_payload,
|
|
297
|
+
challenge,
|
|
298
|
+
{
|
|
299
|
+
identifier: challenge.identity.identifier,
|
|
300
|
+
notBefore: Helpers.date_now - (timeout.nil? || timeout == 0 ? 0 : timeout), # `timeout || 0`: the JS falsy set of a number, nil and 0
|
|
301
|
+
notAfter: Helpers.date_now,
|
|
302
|
+
minSignatures: 1,
|
|
303
|
+
maxSignatures: 1
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
sig = verify_result && verify_result[:signatures][0]
|
|
308
|
+
|
|
309
|
+
if verify_result && verify_result[:valid] && verify_result[:signatures].length == 1 &&
|
|
310
|
+
sig && sig[:valid] && sig[:keys].length > 0 &&
|
|
311
|
+
sig[:identifier] == challenge.identity.identifier &&
|
|
312
|
+
Validator.validate_callback_url(challenge.data[:cburl])[:valid] && Helpers.get_base_url(challenge.data[:cburl]) == sig[:via]
|
|
313
|
+
return { sig: sig, verifyResult: verify_result }
|
|
314
|
+
|
|
315
|
+
# For time constraints verification failure, signal "expired" (caller surfaces request_expired).
|
|
316
|
+
elsif verify_result.nil?
|
|
317
|
+
# `parseInt(challenge.data.iat, 10)` of a number: a non-number (or a non-finite one) logs `NaN` there and nil here - log data only
|
|
318
|
+
Helpers.log(config, :info, 'Verification failed - request expired', { identifier: challenge.identity.identifier, iat: Helpers.is_number(challenge.data[:iat]) && challenge.data[:iat].finite? ? challenge.data[:iat].to_i : nil })
|
|
319
|
+
return nil
|
|
320
|
+
|
|
321
|
+
# For other falsy signature verification results, signal a generic failure (caller surfaces verification_failed).
|
|
322
|
+
else
|
|
323
|
+
Helpers.log(config, :info, 'Verification failed', { identifier: challenge.identity.identifier })
|
|
324
|
+
return false
|
|
325
|
+
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Enforces the optional `config.requireSecure` policy: when the caller has opted in, an
|
|
330
|
+
# otherwise-valid result whose DNS chain was not reported DNSSEC-validated (`secure !== true`)
|
|
331
|
+
# is rejected.
|
|
332
|
+
#
|
|
333
|
+
# @param secure [Boolean, nil] the aggregate DNSSEC-validation flag of the result.
|
|
334
|
+
# @param config [Hash] effective config; `requireSecure` (default false) toggles enforcement.
|
|
335
|
+
# @raise [Triauth::Error] `dnssec_required` when `config[:requireSecure]` is set and `secure` is not strictly `true`.
|
|
336
|
+
def self.assert_secure(secure, config)
|
|
337
|
+
# `if (config.requireSecure && ...)`: the JS falsy set of a caller value (nil, false, 0 and '') spelled out
|
|
338
|
+
require_secure = config[:requireSecure]
|
|
339
|
+
if !(require_secure.nil? || require_secure == false || require_secure == 0 || require_secure == '') && secure != true
|
|
340
|
+
raise Triauth::Error.new({ code: 'dnssec_required', message: 'Your domain does not support DNSSEC, which is required to continue.' })
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
class << self
|
|
22
|
+
# The global triauth-ruby configuration, exposed as `Triauth.config` and shared by reference with every internal module.
|
|
23
|
+
# Each API method also takes a per-call override Hash as its last argument, and an unknown key in either place is
|
|
24
|
+
# rejected with the error `invalid_config`. The `logger` and `resolver` defaults are filled in by the entry module.
|
|
25
|
+
# The global `config` Hash should not be mutated while API calls are in flight.
|
|
26
|
+
#
|
|
27
|
+
# @return [Hash{Symbol => Object}] the configuration Hash, with the entries
|
|
28
|
+
# `requireSecure` (Boolean),
|
|
29
|
+
# `resolver` (Object, nil),
|
|
30
|
+
# `logger` (Object, nil),
|
|
31
|
+
# `randomSource` (Proc, nil),
|
|
32
|
+
# `maximalAllowedClientClockDrift` (Integer),
|
|
33
|
+
# `maximalAllowedServerClockDrift` (Integer),
|
|
34
|
+
# `authTimeout` (Integer),
|
|
35
|
+
# `pingTimeout` (Integer),
|
|
36
|
+
# `attestTimeout` (Integer),
|
|
37
|
+
# `signTimeout` (Integer),
|
|
38
|
+
# `stampTimeout` (Integer),
|
|
39
|
+
# `cid` (String, false, nil)
|
|
40
|
+
attr_reader :config
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@config = {
|
|
44
|
+
# When true, all challenge-response API methods (authenticate, ping, attest, sign, stamp), Triauth.check,
|
|
45
|
+
# and Triauth.verify require a successful result to be DNSSEC-secure: any otherwise-valid response that was
|
|
46
|
+
# not reported DNSSEC-validated (`secure !== true`) is rejected with the error `dnssec_required` ("Your domain does not
|
|
47
|
+
# support DNSSEC, which is required to continue."). Leave false (default) to inspect the `secure` property yourself and stay
|
|
48
|
+
# interoperable with non-DNSSEC domains; set true for mission-critical systems that demand strict
|
|
49
|
+
# cryptographic assurance.
|
|
50
|
+
requireSecure: false,
|
|
51
|
+
|
|
52
|
+
resolver: nil,
|
|
53
|
+
logger: nil,
|
|
54
|
+
|
|
55
|
+
# Source of cryptographic randomness used to generate challenge nonces. Must be a callable that
|
|
56
|
+
# receives a byte count and returns a binary String of exactly that many bytes (same shape as `SecureRandom.random_bytes`).
|
|
57
|
+
# Leave as nil to use `SecureRandom.random_bytes` (default). You may
|
|
58
|
+
# override this with a higher-entropy or hardware-backed RNG, or - in test scenarios - with
|
|
59
|
+
# a deterministic byte stream to make nonces reproducible.
|
|
60
|
+
randomSource: nil,
|
|
61
|
+
|
|
62
|
+
# The maximal allowed clock drift between the current clock (e.g., server time), and the end-user's clock (as seen by the web browser and Triauth Authenticator app),
|
|
63
|
+
# including network round-trip time and any request pre-processing delays.
|
|
64
|
+
# Applied when checking timestamps generated by the user's device (e.g., the `ts` embedded in signatures).
|
|
65
|
+
maximalAllowedClientClockDrift: 30 * 1000,
|
|
66
|
+
|
|
67
|
+
# The maximal allowed clock drift between your own servers - e.g., when, due to load balancing, a challenge
|
|
68
|
+
# is generated (stage 1) and verified (stage 3) by two different hosts with imperfectly synchronized clocks.
|
|
69
|
+
# Applied when checking timestamps generated by your own servers (the challenge `iat` freshness window).
|
|
70
|
+
# Keep it small: well-managed (NTP-synchronized) fleets stay within milliseconds of each other.
|
|
71
|
+
maximalAllowedServerClockDrift: 5 * 1000,
|
|
72
|
+
|
|
73
|
+
# Timeout for the user to approve the request before it is considered expired (basing on challenge `iat` time, widened by `maximalAllowedServerClockDrift`).
|
|
74
|
+
authTimeout: 3 * 60_000,
|
|
75
|
+
pingTimeout: 15_000,
|
|
76
|
+
attestTimeout: 15 * 60_000,
|
|
77
|
+
signTimeout: 30 * 60_000,
|
|
78
|
+
stampTimeout: 15_000,
|
|
79
|
+
|
|
80
|
+
# The correlation id, emitted by the logger to trace related log entries across the system. Three modes:
|
|
81
|
+
# - nil (default): "autofill" - the logger mints a fresh id for each API call (shown as `[cid:...]`).
|
|
82
|
+
# - false: explicit opt-out - no `[cid:...]` tag is emitted.
|
|
83
|
+
# - string: use this exact id for the call.
|
|
84
|
+
cid: nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Frozen snapshot of the recognized `Triauth.config` keys, captured at module load.
|
|
88
|
+
#
|
|
89
|
+
# The public API methods use this to reject config overrides that carry an unknown key (error `invalid_config`).
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<Symbol>] frozen
|
|
92
|
+
KNOWN_CONFIG_KEYS = config.keys.freeze
|
|
93
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
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 base error type used throughout triauth-ruby, carrying the `code`, `reason`, and `message` of the
|
|
22
|
+
# error object that Triauth API methods return, and `data` for the log entry.
|
|
23
|
+
class Error < StandardError
|
|
24
|
+
# The issue tracker to which internal errors should be reported
|
|
25
|
+
ISSUES_URL = 'https://github.com/triauth/triauth-ruby/issues'
|
|
26
|
+
private_constant :ISSUES_URL
|
|
27
|
+
|
|
28
|
+
# @return [String] The error code (e.g., `request_denied`).
|
|
29
|
+
attr_reader :code
|
|
30
|
+
# @return [String, nil] The reason (only on errors about one field, e.g., `too_long`).
|
|
31
|
+
attr_reader :reason
|
|
32
|
+
# @return [Hash] Optional metadata, with `cause` stripped.
|
|
33
|
+
attr_reader :data
|
|
34
|
+
# @return [Time] The time the error was created.
|
|
35
|
+
attr_reader :ts
|
|
36
|
+
|
|
37
|
+
# @param error [Hash] The error object returned by Triauth API methods, `{code: String, reason: String (optional), message: String}`: the error
|
|
38
|
+
# code (e.g., `request_denied`), the reason (only on errors about one field, e.g., `too_long`),
|
|
39
|
+
# and a human-readable message.
|
|
40
|
+
# @param data [Hash] Optional metadata; `cause` (if present) is stripped from `data` (defaults to `{}`).
|
|
41
|
+
def initialize(error, data = {})
|
|
42
|
+
super(error[:message])
|
|
43
|
+
|
|
44
|
+
# `this.name = this.constructor.name` has no counterpart: a Ruby exception reports its class name itself
|
|
45
|
+
|
|
46
|
+
@code = error[:code]
|
|
47
|
+
@reason = error[:reason]
|
|
48
|
+
|
|
49
|
+
@data = data.dup
|
|
50
|
+
@data.delete(:cause) # the JS `cause` is Ruby's own `Exception#cause`, set automatically when raised inside a `rescue`
|
|
51
|
+
|
|
52
|
+
@ts = Time.now
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Normalizes any thrown value into the `{error: {code, reason?, message}}` shape returned by Triauth API methods,
|
|
56
|
+
# and logs it via `config[:logger]`. Validation errors (the errors about one field, which carry a `reason`) are
|
|
57
|
+
# logged at `info`; other `Triauth::Error`s at `warn`. Anything else is an internal error (`internal_error`) logged
|
|
58
|
+
# at `error` with the original error as `cause`.
|
|
59
|
+
#
|
|
60
|
+
# @param err [Object] The thrown value (typically a `Triauth::Error`, but anything is accepted).
|
|
61
|
+
# @param config [Hash] Effective per-call config; only `config[:logger]` is consulted here.
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash] `{error: {code: String, reason: String (optional), message: String}}`
|
|
64
|
+
def self.process(err, config)
|
|
65
|
+
if err.is_a?(Error)
|
|
66
|
+
|
|
67
|
+
# Validation errors are logged with info severity
|
|
68
|
+
if !err.reason.nil?
|
|
69
|
+
Helpers.log(config, :info, "Validation error - #{err.message} (#{err.code})", { cause: err })
|
|
70
|
+
else
|
|
71
|
+
Helpers.log(config, :warn, "#{err.message} (#{err.code})", { cause: err })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
error: err.reason.nil? ? { code: err.code, message: err.message } : { code: err.code, reason: err.reason, message: err.message }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
else
|
|
79
|
+
Helpers.log(
|
|
80
|
+
config, :error,
|
|
81
|
+
"Internal error: this is a defect in triauth-ruby - please report it at #{ISSUES_URL} together with the stack trace",
|
|
82
|
+
{ cause: err }
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
error: {
|
|
87
|
+
code: 'internal_error',
|
|
88
|
+
message: 'Internal error'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|