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,549 @@
|
|
|
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 'json'
|
|
21
|
+
require_relative 'helpers'
|
|
22
|
+
require_relative 'protocol'
|
|
23
|
+
|
|
24
|
+
module Triauth
|
|
25
|
+
# A collection of helper methods dedicated for data validation.
|
|
26
|
+
#
|
|
27
|
+
# These validation methods are not meant to be bullet-proof, and API method calls should return correct results even
|
|
28
|
+
# if invalid or dangerous data for some reason passes the validation.
|
|
29
|
+
class Validator
|
|
30
|
+
# The longest well-formed session key, `<domain>:<key>` with a domain of at most LIMITS.domainNameBytesize
|
|
31
|
+
# bytes and a 43-character key, and the longest well-formed device tag, `<grantTag>:<actorIdentifier>:<actorDeviceTag>`
|
|
32
|
+
# with two tag components of 43 + 1 + 16 characters around an identifier of at most LIMITS.identifierBytesize bytes.
|
|
33
|
+
# The validators compare the length against these caps ahead of any split, so the cost of a rejection does not
|
|
34
|
+
# grow with the input.
|
|
35
|
+
SESSION_KEY_MAX_LENGTH = Protocol::LIMITS[:domainNameBytesize] + 1 + 43
|
|
36
|
+
DEVICE_TAG_MAX_LENGTH = (2 * (43 + 1 + 16)) + 2 + Protocol::LIMITS[:identifierBytesize]
|
|
37
|
+
private_constant :SESSION_KEY_MAX_LENGTH, :DEVICE_TAG_MAX_LENGTH
|
|
38
|
+
|
|
39
|
+
# The message of every validation error, by error code and reason. Each code names the invalid field,
|
|
40
|
+
# and each reason names the rule that the field breaks.
|
|
41
|
+
ERRORS = {
|
|
42
|
+
'invalid_identifier' => {
|
|
43
|
+
'invalid' => 'Identifier is invalid',
|
|
44
|
+
'empty' => 'Identifier must not be empty',
|
|
45
|
+
'too_long' => 'Identifier is too long',
|
|
46
|
+
'not_lowercase' => 'Identifier must be provided in lowercase',
|
|
47
|
+
'invalid_format' => 'Identifier must include one @ sign',
|
|
48
|
+
'invalid_username' => 'Identifier contains invalid username',
|
|
49
|
+
'invalid_domain' => 'Identifier contains invalid domain name'
|
|
50
|
+
},
|
|
51
|
+
'invalid_callback_url' => { 'invalid' => 'Invalid callbackUrl' },
|
|
52
|
+
'invalid_ext' => { 'invalid' => 'Invalid ext' },
|
|
53
|
+
'invalid_challenge' => { 'invalid' => 'Invalid challenge' },
|
|
54
|
+
'invalid_response' => { 'invalid' => 'Invalid response' },
|
|
55
|
+
'invalid_signature' => { 'invalid' => 'Invalid signature' },
|
|
56
|
+
'invalid_session_key' => { 'invalid' => 'Invalid session key' },
|
|
57
|
+
'invalid_message' => { 'invalid' => 'Invalid message' },
|
|
58
|
+
'invalid_attachments' => { 'invalid' => 'Invalid attachments' },
|
|
59
|
+
'invalid_attestations' => { 'invalid' => 'Invalid attestations' },
|
|
60
|
+
'invalid_lookup_code' => { 'invalid' => 'Invalid lookup code' },
|
|
61
|
+
'invalid_device_tag' => { 'invalid' => 'Invalid device tag' },
|
|
62
|
+
'invalid_device_name' => {
|
|
63
|
+
'invalid' => 'Device name is invalid',
|
|
64
|
+
'empty' => 'Device name must not be empty',
|
|
65
|
+
'too_long' => 'Device name is too long',
|
|
66
|
+
'not_lowercase' => 'Device name must be provided in lowercase',
|
|
67
|
+
'invalid_characters' => 'Device name must include only letters, numbers, and hyphens'
|
|
68
|
+
},
|
|
69
|
+
'invalid_group_name' => {
|
|
70
|
+
'invalid' => 'Group name is invalid',
|
|
71
|
+
'empty' => 'Group name must not be empty',
|
|
72
|
+
'too_long' => 'Group name is too long',
|
|
73
|
+
'not_lowercase' => 'Group name must be provided in lowercase',
|
|
74
|
+
'invalid_characters' => 'Group name must include only letters, numbers, and hyphens'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
private_constant :ERRORS
|
|
78
|
+
|
|
79
|
+
# Builds a validation result from the reasons found for one field.
|
|
80
|
+
#
|
|
81
|
+
# @param code [String] The error code of the field (e.g., `invalid_identifier`).
|
|
82
|
+
# @param reasons [Array<String>] The reasons found, empty when the field is valid.
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
85
|
+
def self.expand_result(code, reasons)
|
|
86
|
+
return {
|
|
87
|
+
valid: reasons.length == 0,
|
|
88
|
+
errors: reasons.map { |reason| { code: code, reason: reason, message: ERRORS[code][reason] } }
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
private_class_method :expand_result
|
|
92
|
+
|
|
93
|
+
# Validates the provided personal identifier's format, and returns human-friendly error messages if problems are found.
|
|
94
|
+
#
|
|
95
|
+
# @param identifier [String] Identifier that should be validated.
|
|
96
|
+
#
|
|
97
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
98
|
+
def self.validate_identifier(identifier)
|
|
99
|
+
if !Helpers.is_string(identifier)
|
|
100
|
+
return expand_result('invalid_identifier', ['invalid'])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if identifier.length <= 0
|
|
104
|
+
return expand_result('invalid_identifier', ['empty'])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if identifier.length > Protocol::LIMITS[:identifierBytesize] || Helpers.byte_size(identifier) > Protocol::LIMITS[:identifierBytesize]
|
|
108
|
+
return expand_result('invalid_identifier', ['too_long'])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# identifiers must not contain any whitespace characters and/or NULL
|
|
112
|
+
if /[#{Helpers::JS_WS}\0]/o.match?(identifier)
|
|
113
|
+
return expand_result('invalid_identifier', ['invalid'])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if identifier != identifier.downcase
|
|
117
|
+
return expand_result('invalid_identifier', ['not_lowercase'])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if identifier.scan(/@/).length != 1
|
|
121
|
+
return expand_result('invalid_identifier', ['invalid_format'])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
username, domain = identifier.split('@', -1)
|
|
125
|
+
reasons = []
|
|
126
|
+
|
|
127
|
+
# Username may be composed only of ASCII letters, numbers, dot and hyphen signs, must start with a letter or a digit,
|
|
128
|
+
# and must be no longer than 63 chars (DNS label limit),
|
|
129
|
+
if !/\A[a-z0-9][a-z0-9.-]{0,62}\z/.match?(username) ||
|
|
130
|
+
|
|
131
|
+
# consecutive dots and/or hyphens are not allowed,
|
|
132
|
+
/[.-][.-]/.match?(username) ||
|
|
133
|
+
|
|
134
|
+
# and it must not end with a dot or hyphen.
|
|
135
|
+
/[.-]\z/.match?(username)
|
|
136
|
+
reasons.push('invalid_username')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if !Helpers.is_domain_name(domain)
|
|
140
|
+
reasons.push('invalid_domain')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return expand_result('invalid_identifier', reasons)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Validates the provided callback URL's format, and returns human-friendly error messages if problems are found.
|
|
147
|
+
#
|
|
148
|
+
# @param callback_url [String] Callback URL that should be validated.
|
|
149
|
+
#
|
|
150
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
151
|
+
def self.validate_callback_url(callback_url)
|
|
152
|
+
reasons = []
|
|
153
|
+
|
|
154
|
+
# A callback URL is any canonical URL — http or https, named or IPv4-literal host — whose
|
|
155
|
+
# base URL is free of the signature-envelope delimiter ';'
|
|
156
|
+
base = Helpers.get_base_url(callback_url)
|
|
157
|
+
if base == false || base.include?(Protocol::DELIMITER)
|
|
158
|
+
reasons.push('invalid')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return expand_result('invalid_callback_url', reasons)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Validates the optional `ext` property for `Triauth.authenticate` — both as supplied in stage 1 and as received back in stage 3.
|
|
165
|
+
#
|
|
166
|
+
# `ext` must be a plain object whose values are strings, whole numbers, booleans, or plain objects one level deep (no arrays, no deeper nesting).
|
|
167
|
+
# A string value carries no unpaired surrogate and a number is a whole number of magnitude at most 2^53 - 1, the rules that
|
|
168
|
+
# `Helpers.safe_parse_json` applies to every JSON value when the ext comes back in stage 3.
|
|
169
|
+
# Keys must additionally be ASCII-only — `Helpers.safe_parse_json` rejects non-ASCII property names when the ext round-trips back in stage 3 —
|
|
170
|
+
# and never one of the prototype-poisoning names (`__proto__`, `constructor`, `prototype`)
|
|
171
|
+
#
|
|
172
|
+
# @param ext [Hash] Ext object that should be validated.
|
|
173
|
+
# @param depth [Integer] Nesting depth of the object being validated: 0 for the `ext` object itself,
|
|
174
|
+
# 1 for the single level of nested plain objects that `ext` values may contain (defaults to 0).
|
|
175
|
+
#
|
|
176
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
177
|
+
def self.validate_ext(ext, depth = 0)
|
|
178
|
+
reasons = []
|
|
179
|
+
|
|
180
|
+
if ext.nil? ||
|
|
181
|
+
!(ext.is_a?(Hash) && ext.keys.all? { |k| Helpers.is_normal_string(k.to_s, Protocol::LIMITS[:jsonMaxKeyLength]) && !/[^\x20-\x7E]/.match?(k.to_s) && !%w[__proto__ constructor prototype].include?(k.to_s) } && ext.values.all? { |e| (Helpers.is_string(e) && Helpers.is_well_formed_string(e)) || Helpers.is_safe_integer(e) || e == true || e == false || (depth == 0 && validate_ext(e, depth + 1)[:errors].length == 0) }) ||
|
|
182
|
+
Helpers.byte_size(JSON.generate(ext)) > Protocol::LIMITS[:extBytesize]
|
|
183
|
+
reasons.push('invalid')
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
return expand_result('invalid_ext', reasons)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Validates the `attestations` option for `Triauth.attest`, applied both when building the challenge (stage 1) and verifying the response (stage 3).
|
|
190
|
+
#
|
|
191
|
+
# `attestations` must be a plain object keyed by attestation name, where each value is `{label: string, providers: string[]}`.
|
|
192
|
+
# An empty object `{}` is valid; passing it will produce `{attested: true, attestations: {}}` from stage 3.
|
|
193
|
+
# An attestation name is printable ASCII, as it becomes a property name of the challenge that `Helpers.safe_parse_json` admits only in ASCII in stage 3,
|
|
194
|
+
# and never one of the prototype-poisoning keys (`__proto__`, `constructor`, `prototype`).
|
|
195
|
+
#
|
|
196
|
+
# @param attestations [Hash] Attestations object that should be validated.
|
|
197
|
+
#
|
|
198
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
199
|
+
def self.validate_attestations(attestations)
|
|
200
|
+
reasons = []
|
|
201
|
+
|
|
202
|
+
if attestations.nil? ||
|
|
203
|
+
!attestations.is_a?(Hash) ||
|
|
204
|
+
!attestations.keys.all? { |k| Helpers.is_normal_string(k.to_s) && !/[^\x20-\x7E]/.match?(k.to_s) && !%w[__proto__ constructor prototype].include?(k.to_s) }
|
|
205
|
+
return expand_result('invalid_attestations', ['invalid'])
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The multi-signature envelope holds at most maxMultiSignatures entries (1 user + N attesters),
|
|
209
|
+
# so requesting more than (maxMultiSignatures - 1) attestations creates a request that can never be satisfied.
|
|
210
|
+
if attestations.size > Protocol::LIMITS[:maxMultiSignatures] - 1
|
|
211
|
+
return expand_result('invalid_attestations', ['invalid'])
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
attestations.each_value do |entry|
|
|
215
|
+
if !entry.is_a?(Hash) || # `entry !== null && typeof entry === 'object' && !Array.isArray(entry)`: a Hash, so a nil entry is invalid here as there
|
|
216
|
+
!entry.keys.all? { |k| %i[label providers].include?(k) } ||
|
|
217
|
+
!Helpers.is_normal_string(entry[:label]) ||
|
|
218
|
+
!(
|
|
219
|
+
entry[:providers].is_a?(Array) &&
|
|
220
|
+
entry[:providers].length > 0 && entry[:providers].length <= Protocol::LIMITS[:maxMultiSignatures] - 1 &&
|
|
221
|
+
entry[:providers].all? { |p_url| Helpers.is_secure_url(p_url) }
|
|
222
|
+
)
|
|
223
|
+
reasons.push('invalid')
|
|
224
|
+
|
|
225
|
+
else
|
|
226
|
+
entry[:providers].each do |provider_url|
|
|
227
|
+
# Beyond being canonical on a secure origin, a provider URL is displayed verbatim in
|
|
228
|
+
# the authenticator UI and embedded whole as the `via` field of the attestation
|
|
229
|
+
# segment, so it must carry no query, no fragment, no percent-escapes, no envelope
|
|
230
|
+
# delimiter ';', and no apostrophe. In a canonical URL, '?' and '#' can appear only
|
|
231
|
+
# as the query/fragment introducers and '%' only in an escape — one charset test
|
|
232
|
+
# covers every excluded character.
|
|
233
|
+
if /[?#%;']/.match?(provider_url)
|
|
234
|
+
reasons.push('invalid')
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
return expand_result('invalid_attestations', reasons)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Validates the challenge string returned from stage 1 of an authentication flow, before passing it to stage 3.
|
|
244
|
+
#
|
|
245
|
+
# The challenge must be a non-empty base64url-encoded string within the allowed byte size.
|
|
246
|
+
#
|
|
247
|
+
# @param challenge [String] Challenge string that should be validated.
|
|
248
|
+
#
|
|
249
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
250
|
+
def self.validate_challenge(challenge)
|
|
251
|
+
reasons = []
|
|
252
|
+
|
|
253
|
+
if !Helpers.is_string(challenge) ||
|
|
254
|
+
challenge.length <= 0 ||
|
|
255
|
+
challenge.length > Protocol::LIMITS[:challengeBytesize] ||
|
|
256
|
+
Helpers.byte_size(challenge) > Protocol::LIMITS[:challengeBytesize] ||
|
|
257
|
+
!Helpers.is_base64url_string(challenge)
|
|
258
|
+
reasons.push('invalid')
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
return expand_result('invalid_challenge', reasons)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Validates the response string returned by the authenticator in stage 3 of an authentication flow.
|
|
265
|
+
#
|
|
266
|
+
# The response must be a pipe-delimited string of the form `|...|`, within the allowed byte size.
|
|
267
|
+
#
|
|
268
|
+
# @param response [String] Response string that should be validated.
|
|
269
|
+
#
|
|
270
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
271
|
+
def self.validate_response(response)
|
|
272
|
+
reasons = []
|
|
273
|
+
|
|
274
|
+
if !Helpers.is_string(response) ||
|
|
275
|
+
response.length <= 3 ||
|
|
276
|
+
(response[0] != '|' || response[-1] != '|') ||
|
|
277
|
+
response.length > Protocol::LIMITS[:signatureBytesize] ||
|
|
278
|
+
Helpers.byte_size(response) > Protocol::LIMITS[:signatureBytesize]
|
|
279
|
+
reasons.push('invalid')
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
return expand_result('invalid_response', reasons)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Validates the sealed response received in stage 3 of an authentication flow, before it is opened.
|
|
286
|
+
#
|
|
287
|
+
# A sealed response is `<enc>.<ciphertext>`, both base64url: an 87-character encapsulated key, and a ciphertext of
|
|
288
|
+
# at least the 16-byte authentication tag, bounded by the sealed size of the largest response envelope.
|
|
289
|
+
#
|
|
290
|
+
# @param response [String] Sealed response that should be validated.
|
|
291
|
+
#
|
|
292
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
293
|
+
def self.validate_sealed_response(response)
|
|
294
|
+
reasons = []
|
|
295
|
+
max_length = 87 + 1 + ((Protocol::LIMITS[:signatureBytesize] + 16) * 4).fdiv(3).ceil # `Math.ceil(... * 4 / 3)`: a real division, never the Integer `/`
|
|
296
|
+
|
|
297
|
+
if !Helpers.is_string(response) ||
|
|
298
|
+
response.length > max_length || # code points where JS counts UTF-16 units: a string the two counts disagree on is non-ASCII and invalid at the regex either way
|
|
299
|
+
!/\A[A-Za-z0-9_-]{87}\.[A-Za-z0-9_-]{22,}\z/.match?(response)
|
|
300
|
+
reasons.push('invalid')
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
return expand_result('invalid_response', reasons)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Validates the message string to be signed or stamped in a `Triauth.sign` or `Triauth.stamp` flow.
|
|
307
|
+
#
|
|
308
|
+
# @param message [String] Message that should be validated.
|
|
309
|
+
#
|
|
310
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
311
|
+
def self.validate_message(message)
|
|
312
|
+
reasons = []
|
|
313
|
+
|
|
314
|
+
if !Helpers.is_normal_string(message, Protocol::LIMITS[:messageBytesize])
|
|
315
|
+
reasons.push('invalid')
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
return expand_result('invalid_message', reasons)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Validates the `attachments` array for a `Triauth.sign` flow.
|
|
322
|
+
#
|
|
323
|
+
# Each attachment must be a plain object with `name` (string) and `sha256` (64-character lowercase hex string),
|
|
324
|
+
# plus a `sourceUrl` (a canonical URL). Names must be unique within the array.
|
|
325
|
+
#
|
|
326
|
+
# `sourceUrl` is only needed when building the challenge (stage 1), so the authenticator knows where to fetch
|
|
327
|
+
# the file to hash. It is intentionally NOT required on the signed response (stage 3): the durable proof binds
|
|
328
|
+
# only `name`+`sha256`, and keeping `sourceUrl` out of the signed payload avoids embedding fetch URLs (which may
|
|
329
|
+
# carry presigned credentials) in a portable, third-party-verifiable signature. Pass `require_source_url = false`
|
|
330
|
+
# to validate a response's attachments, where `sourceUrl` may be omitted (but is held to the same rule if present).
|
|
331
|
+
#
|
|
332
|
+
# @param attachments [Array<Hash>] Attachments array that should be validated (each entry `{name: String, sourceUrl?: String, sha256: String}`).
|
|
333
|
+
# @param require_source_url [Boolean] Whether a valid `sourceUrl` is mandatory on every entry (defaults to true).
|
|
334
|
+
#
|
|
335
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
336
|
+
def self.validate_attachments(attachments, require_source_url = true)
|
|
337
|
+
reasons = []
|
|
338
|
+
seen_names = {} # a dedup set keyed by the attachment name Strings (the port of `Object.create(null)`)
|
|
339
|
+
|
|
340
|
+
if !attachments.is_a?(Array) ||
|
|
341
|
+
attachments.length > Protocol::LIMITS[:attachmentsCount] ||
|
|
342
|
+
!attachments.all? do |f|
|
|
343
|
+
Helpers.has_only_known_properties(f, %i[name sourceUrl sha256]) &&
|
|
344
|
+
Helpers.is_normal_string(f[:name], 255) &&
|
|
345
|
+
((!require_source_url && !f.key?(:sourceUrl)) || Helpers.is_canonical_url(f[:sourceUrl])) && # `=== undefined` is "member absent" (`key?`); a present nil member keeps the JS null outcome (rejected)
|
|
346
|
+
Helpers.is_string(f[:sha256]) && /\A[0-9a-f]{64}\z/.match?(f[:sha256]) &&
|
|
347
|
+
!seen_names[f[:name]] && (seen_names[f[:name]] = true)
|
|
348
|
+
end
|
|
349
|
+
reasons.push('invalid')
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
return expand_result('invalid_attachments', reasons)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The binder names a signature may state inside its signed-metadata `bind` object: the verified
|
|
356
|
+
# fields the signature is bound to. Each names a field of the verified user signature and must
|
|
357
|
+
# equal it (see `Triauth.attest`).
|
|
358
|
+
#
|
|
359
|
+
# @return [Array<Symbol>]
|
|
360
|
+
BINDER_NAMES = %i[identifier via deviceTag].freeze
|
|
361
|
+
|
|
362
|
+
# Validates a signature segment's signed metadata against the binder grammar.
|
|
363
|
+
#
|
|
364
|
+
# A signature states what it is bound to in the `bind` object, and **every member of `bind` is
|
|
365
|
+
# critical**: a verifier must recognize it and it must match, so a constraint the producer signed
|
|
366
|
+
# can never be dropped without a trace. Members outside `bind` stay auxiliary and tolerant
|
|
367
|
+
# (an unrecognized one is ignored), so a compatible revision may still add signed metadata.
|
|
368
|
+
#
|
|
369
|
+
# @param signed_metadata [Hash] The signed metadata of a signature segment.
|
|
370
|
+
#
|
|
371
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
372
|
+
# Invalid (`invalid_signature`) when `bind` is present but is not an object of at least one recognized
|
|
373
|
+
# binder with a string value.
|
|
374
|
+
def self.validate_binders(signed_metadata)
|
|
375
|
+
if !signed_metadata.key?(:bind)
|
|
376
|
+
return expand_result('invalid_signature', []) # a signature that binds nothing is valid - attesters attest anonymously
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
bind = signed_metadata[:bind]
|
|
380
|
+
binders = bind.is_a?(Hash) ? bind.keys : nil
|
|
381
|
+
|
|
382
|
+
if binders.nil? ||
|
|
383
|
+
binders.length < 1 ||
|
|
384
|
+
!binders.all? { |binder| BINDER_NAMES.include?(binder) && Helpers.is_string(bind[binder]) }
|
|
385
|
+
return expand_result('invalid_signature', ['invalid'])
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
return expand_result('invalid_signature', [])
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Validates the `sessionKey` that gates stage 1 of `Triauth.ping`, `Triauth.sign`, `Triauth.stamp`, and `Triauth.attest`.
|
|
392
|
+
#
|
|
393
|
+
# A session key is the `sessionKey` of a `Triauth.authenticate` result, 32 bytes in 43 base64url characters.
|
|
394
|
+
# After a delegated sign-in it is `<domain>:<key>`, with the lowercase domain name of the actor.
|
|
395
|
+
#
|
|
396
|
+
# @param session_key [String] Session key that should be validated.
|
|
397
|
+
#
|
|
398
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
399
|
+
def self.validate_session_key(session_key)
|
|
400
|
+
parts = if Helpers.is_string(session_key) && session_key.length <= SESSION_KEY_MAX_LENGTH
|
|
401
|
+
session_key.empty? ? [''] : session_key.split(':', -1) # JS `''.split(':')` is `['']` where Ruby gives `[]`
|
|
402
|
+
else
|
|
403
|
+
[]
|
|
404
|
+
end
|
|
405
|
+
domain, key = parts.length == 2 ? parts : [nil, parts[0]]
|
|
406
|
+
|
|
407
|
+
if parts.length < 1 || parts.length > 2 ||
|
|
408
|
+
(!domain.nil? && !(Helpers.is_domain_name(domain) && domain == domain.downcase)) ||
|
|
409
|
+
key.length != 43 || !Helpers.is_base64url_string(key)
|
|
410
|
+
return expand_result('invalid_session_key', ['invalid'])
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# The canonical base64url of 32 bytes has no data in the unused bits of its last character
|
|
414
|
+
begin
|
|
415
|
+
Helpers.base64url_to_uint8(key)
|
|
416
|
+
rescue StandardError
|
|
417
|
+
return expand_result('invalid_session_key', ['invalid'])
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
return expand_result('invalid_session_key', [])
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Validates the provided device name's format, and returns human-friendly error messages if problems are found.
|
|
424
|
+
#
|
|
425
|
+
# Device names must be lowercase and composed of letters, numbers, and single hyphens between alphanumerics
|
|
426
|
+
# (no leading, trailing, or consecutive hyphens), within the allowed byte size.
|
|
427
|
+
#
|
|
428
|
+
# @param device_name [String] Device name that should be validated.
|
|
429
|
+
#
|
|
430
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
431
|
+
def self.validate_device_name(device_name)
|
|
432
|
+
return expand_result('invalid_device_name', name_errors(device_name))
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Validates the provided group name's format, and returns human-friendly error messages if problems are found.
|
|
436
|
+
#
|
|
437
|
+
# Group names follow the device-name grammar: lowercase letters, numbers, and single hyphens between alphanumerics
|
|
438
|
+
# (no leading, trailing, or consecutive hyphens), within the allowed byte size. The name is the bare name as published
|
|
439
|
+
# in a `groups` record (e.g., `admins`), not the fully-qualified form found in results (e.g., `admins@example.com`).
|
|
440
|
+
#
|
|
441
|
+
# @param group_name [String] Group name that should be validated.
|
|
442
|
+
#
|
|
443
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
444
|
+
def self.validate_group_name(group_name)
|
|
445
|
+
return expand_result('invalid_group_name', name_errors(group_name))
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Validates the provided lookup code's format.
|
|
449
|
+
#
|
|
450
|
+
# A lookup code is exactly 16 uppercase letters and digits (e.g., `K7QJ3FB9M2WZX0C4`). It addresses the records
|
|
451
|
+
# of an identity under a `mode=private` domain.
|
|
452
|
+
#
|
|
453
|
+
# @param lookup_code [String] Lookup code that should be validated.
|
|
454
|
+
#
|
|
455
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
456
|
+
def self.validate_lookup_code(lookup_code)
|
|
457
|
+
reasons = []
|
|
458
|
+
|
|
459
|
+
if !Helpers.is_lookup_code(lookup_code)
|
|
460
|
+
reasons.push('invalid')
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
return expand_result('invalid_lookup_code', reasons)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Validates the provided device tag's format.
|
|
467
|
+
#
|
|
468
|
+
# A device tag is the `deviceTag` of a result: a 43-character base64url digest (e.g., `3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ`),
|
|
469
|
+
# suffixed with `~` and a lookup code under a `mode=private` domain. After a delegated sign-in it is
|
|
470
|
+
# `<grantTag>:<actorIdentifier>:<actorDeviceTag>`, and each of its two tags may carry such a suffix.
|
|
471
|
+
#
|
|
472
|
+
# @param device_tag [String] Device tag that should be validated.
|
|
473
|
+
#
|
|
474
|
+
# @return [Hash] `{valid: Boolean, errors: Array<{code: String, reason: String, message: String}>}`
|
|
475
|
+
def self.validate_device_tag(device_tag)
|
|
476
|
+
reasons = []
|
|
477
|
+
parts = Helpers.is_string(device_tag) && device_tag.length <= DEVICE_TAG_MAX_LENGTH ? device_tag.split(':', -1) : [] # JS `''.split(':')` is `['']` where Ruby gives `[]`: neither is 1 or 3 parts
|
|
478
|
+
|
|
479
|
+
if !(parts.length == 1 && is_reported_tag(parts[0])) &&
|
|
480
|
+
!(parts.length == 3 && is_reported_tag(parts[0]) && validate_identifier(parts[1])[:valid] && is_reported_tag(parts[2]))
|
|
481
|
+
reasons.push('invalid')
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
return expand_result('invalid_device_tag', reasons)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Checks one tag component of a device tag: a 43-character base64url digest, optionally suffixed with `~` and a lookup code.
|
|
488
|
+
#
|
|
489
|
+
# @param component [String] Tag component that should be checked.
|
|
490
|
+
#
|
|
491
|
+
# @return [Boolean] true when the component is a well-formed tag
|
|
492
|
+
def self.is_reported_tag(component)
|
|
493
|
+
tag, lookup_code, *rest = component.empty? ? [''] : component.split('~', -1) # JS `''.split('~')` is `['']` where Ruby gives `[]`
|
|
494
|
+
|
|
495
|
+
if rest.length > 0 || tag.length != 43 || (!lookup_code.nil? && !Helpers.is_lookup_code(lookup_code))
|
|
496
|
+
return false
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The canonical base64url of a 32-byte digest has no data in the unused bits of its last character
|
|
500
|
+
begin
|
|
501
|
+
Helpers.base64url_to_uint8(tag)
|
|
502
|
+
rescue StandardError
|
|
503
|
+
return false
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
return true
|
|
507
|
+
end
|
|
508
|
+
private_class_method :is_reported_tag
|
|
509
|
+
|
|
510
|
+
# Checks a name against the device-name grammar, shared by device names and group names.
|
|
511
|
+
#
|
|
512
|
+
# @param name [Object] Name that should be validated.
|
|
513
|
+
#
|
|
514
|
+
# @return [Array<String>] The reasons found, empty when the name is valid.
|
|
515
|
+
def self.name_errors(name)
|
|
516
|
+
reasons = []
|
|
517
|
+
|
|
518
|
+
if !Helpers.is_string(name)
|
|
519
|
+
return ['invalid']
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
if name.length <= 0
|
|
523
|
+
reasons.push('empty')
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
if name.length > Protocol::LIMITS[:deviceNameBytesize] || Helpers.byte_size(name) > Protocol::LIMITS[:deviceNameBytesize]
|
|
527
|
+
return ['too_long']
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# The name must not contain any whitespace characters and/or NULL
|
|
531
|
+
if /[#{Helpers::JS_WS}\0]/o.match?(name)
|
|
532
|
+
reasons.push('invalid')
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
if name != name.downcase
|
|
536
|
+
reasons.push('not_lowercase')
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Lowercase alphanumerics joined by single hyphens: the name must start and end on an alphanumeric
|
|
540
|
+
# and carry no consecutive hyphens.
|
|
541
|
+
if !/\A[a-z0-9]+(-[a-z0-9]+)*\z/.match?(name)
|
|
542
|
+
reasons.push('invalid_characters')
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
return reasons
|
|
546
|
+
end
|
|
547
|
+
private_class_method :name_errors
|
|
548
|
+
end
|
|
549
|
+
end
|