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,249 @@
|
|
|
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 'helpers'
|
|
21
|
+
require_relative 'validator'
|
|
22
|
+
require_relative 'protocol'
|
|
23
|
+
|
|
24
|
+
module Triauth
|
|
25
|
+
# Holds information about include statements for identifier, as extracted from identity records.
|
|
26
|
+
class IdentityIncludes
|
|
27
|
+
# @return [Hash{Symbol => Object}] The effective settings: the global `Triauth.config` with the constructor's overrides merged over it.
|
|
28
|
+
attr_reader :config
|
|
29
|
+
# @return [Array<Hash>] The include statements, each `{ref, options, use, scope, domainName, tag, expires}`, in publication order until sorted.
|
|
30
|
+
attr_reader :includes
|
|
31
|
+
# @return [Boolean] Whether `includes` is in its canonical (tag-sorted) order.
|
|
32
|
+
attr_reader :sorted
|
|
33
|
+
|
|
34
|
+
# Constructs an empty include-statement list.
|
|
35
|
+
#
|
|
36
|
+
# @param config [Hash] optional overrides for the global Triauth.config settings. Defaults to `{}`.
|
|
37
|
+
def initialize(config = {})
|
|
38
|
+
@config = Helpers.merge_config(config)
|
|
39
|
+
|
|
40
|
+
# Holds entries in form of {ref, options, use, scope, domainName, tag, expires}
|
|
41
|
+
@includes = []
|
|
42
|
+
@sorted = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Parses the endpoint record's `include` option into the include policy list.
|
|
46
|
+
#
|
|
47
|
+
# @param raw_option [String, nil] the `include` option value as published; nil defaults to `any`.
|
|
48
|
+
#
|
|
49
|
+
# @return [Array<String>] the policy entries (`none`|`any`|`local`|allowlisted domain names), lowercased.
|
|
50
|
+
def self.parse_policy(raw_option = nil)
|
|
51
|
+
# `rawOption !== undefined`: nil takes the JS `undefined` branch (a `null` option would throw there; a DNS option is never null)
|
|
52
|
+
if !raw_option.nil?
|
|
53
|
+
policy = Helpers.ascii_lowercase(raw_option)
|
|
54
|
+
return policy.empty? ? [''] : policy.split(',', -1) # JS `''.split(',')` is `['']`
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return [Protocol::DEFAULT_INCLUDE]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Adds an include statement to the list of includes
|
|
61
|
+
#
|
|
62
|
+
# @param value [String] the ref of the include as visible in the identity record - either the actor's
|
|
63
|
+
# identifier (e.g., 'actor@triauthdemo.org') or its identity domain (e.g., 'actor._at.triauthdemo.org')
|
|
64
|
+
# @param options [Hash] the include options as specified in the identity record. Defaults to `{}`.
|
|
65
|
+
# @param metadata [Hash] the resolve-layer context for the include record (`{expires, includePolicy, localDomainName}`);
|
|
66
|
+
# `includePolicy` is the endpoint record's `include` option as a list of policies: `none` rejects every include (and dominates any combination),
|
|
67
|
+
# `any` as the sole entry allows all refs, `local` allows refs on `localDomainName`, and any other entry allowlists that exact domain;
|
|
68
|
+
# when absent it defaults to `['none']` (every include is ignored). Defaults to `{}`.
|
|
69
|
+
# @return [Boolean] true when the include was successfully added, false when it was ignored (e.g., unrecognized ref or options, or the `LIMITS[:maxIncludes]` cap was exceeded)
|
|
70
|
+
def add(value, options = {}, metadata = {})
|
|
71
|
+
# Work on a private copy of `options`. `Helpers.object_assign` copies a `__proto__` key as data, so the options
|
|
72
|
+
# schema below judges it like any other unknown option
|
|
73
|
+
options = Helpers.object_assign({}, options)
|
|
74
|
+
|
|
75
|
+
# Normalize value to downcase
|
|
76
|
+
# (an ill-formed (invalid UTF-8) `value` raises here, where JS folds it and fails the ref grammar; records reach this method `is_normal_string`-validated)
|
|
77
|
+
value = Helpers.ascii_lowercase(value.to_s) # ASCII-only: a non-ASCII uppercase letter must fail the ref grammar, not fold into it
|
|
78
|
+
|
|
79
|
+
# The value must be either an identifier (`actor@example.org`) or a lowercase identity domain (`actor._at.example.org`, `_4gibdu58b3._at.example.org`)
|
|
80
|
+
# The separator (`@` or AT_MARKER) tells the two spellings apart,
|
|
81
|
+
separator =
|
|
82
|
+
if Validator.validate_identifier(value)[:valid]
|
|
83
|
+
'@'
|
|
84
|
+
elsif Helpers.is_domain_name(value.sub(/\A_/, '').sub(Protocol::AT_MARKER, '.')) && value.split(Protocol::AT_MARKER, -1).length == 2
|
|
85
|
+
Protocol::AT_MARKER
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if separator.nil?
|
|
89
|
+
Helpers.log(@config, :debug, 'Ignoring include due to unrecognized ref')
|
|
90
|
+
return false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Parse options
|
|
94
|
+
|
|
95
|
+
# All non-x- prefixed options are critical, and unknown critical options MUST cause include to be ignored.
|
|
96
|
+
# `scope` is required and names the service hosts the grant works at: the sole entry `any` (every host),
|
|
97
|
+
# or a comma-separated allowlist of hostnames. `any` beside other entries and `none` are not valid.
|
|
98
|
+
options_schema = {
|
|
99
|
+
use: [Protocol::FLOWS_USE_REGEXP],
|
|
100
|
+
scope: [lambda do |scope|
|
|
101
|
+
# JS `''.split(',')` is `['']`, so an empty scope is rejected; `-1` keeps the empty slot of `svc.example,`
|
|
102
|
+
scope == Protocol::SCOPE_ANY || (scope.empty? ? [''] : scope.split(',', -1)).all? do |host|
|
|
103
|
+
host != Protocol::SCOPE_ANY && host != Protocol::SCOPE_NONE && Helpers.url_host("https://#{host}/") == host
|
|
104
|
+
end
|
|
105
|
+
end]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Set defaults
|
|
109
|
+
options[:use] = Protocol::FLOWS.join(',') if options[:use].nil?
|
|
110
|
+
|
|
111
|
+
# `scope` has no safe default
|
|
112
|
+
# (`options['scope'] === undefined` is "member absent": a present nil member goes on to the schema check, which rejects it, as in JS)
|
|
113
|
+
if !options.key?(:scope)
|
|
114
|
+
Helpers.log(@config, :debug, 'Ignoring include without a scope option', { ref: value })
|
|
115
|
+
return false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate options
|
|
119
|
+
if !Helpers.verify_options_schema(options, options_schema)
|
|
120
|
+
return false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Extract domainName from the ref
|
|
124
|
+
domain_name = value.split(separator, -1)[1..].join(separator)
|
|
125
|
+
|
|
126
|
+
# Double check that we have extracted non-empty domainName - should be unreachable under normal conditions
|
|
127
|
+
if domain_name.empty?
|
|
128
|
+
return false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Check the include policy (the endpoint record's `include` option), as passed from the resolve layer
|
|
132
|
+
include_policy = metadata[:includePolicy].nil? ? ['none'] : metadata[:includePolicy]
|
|
133
|
+
if include_policy.include?('none') ||
|
|
134
|
+
!(
|
|
135
|
+
(include_policy.length == 1 && include_policy[0] == 'any') ||
|
|
136
|
+
(include_policy.include?('local') && domain_name == metadata[:localDomainName]) ||
|
|
137
|
+
include_policy.include?(domain_name)
|
|
138
|
+
)
|
|
139
|
+
Helpers.log(@config, :debug, 'Ignoring include due to policy', { ref: value, includePolicy: include_policy })
|
|
140
|
+
return false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Enforce the include count cap - includes beyond it are ignored
|
|
144
|
+
if @includes.length >= Protocol::LIMITS[:maxIncludes]
|
|
145
|
+
Helpers.log(@config, :debug, 'Ignoring include - too many include statements')
|
|
146
|
+
return false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A stable tag of this grant (its ref and critical options), so the grant's presence can later be re-checked from the tag alone
|
|
150
|
+
tag = Helpers.sha256(
|
|
151
|
+
"\x01#{value}\x02" +
|
|
152
|
+
options.reject { |k, _| k.to_s[0] == 'x' && k.to_s[1] == '-' }.map { |k, v| "#{k}=#{v}" }.sort.join("\x1E")
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# At this point the `include` has been validated, so we can add it to the list and continue
|
|
156
|
+
@includes.push(
|
|
157
|
+
{
|
|
158
|
+
ref: value,
|
|
159
|
+
options: options, # the published spelling - the tag preimage and whois read this
|
|
160
|
+
use: options[:use].split(',', -1), # parsed once for matching
|
|
161
|
+
scope: options[:scope] == Protocol::SCOPE_ANY ? nil : options[:scope].split(',', -1), # nil = every service host
|
|
162
|
+
domainName: domain_name,
|
|
163
|
+
tag: tag,
|
|
164
|
+
expires: metadata[:expires]
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@sorted = false
|
|
169
|
+
|
|
170
|
+
return true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Lists the include statements in their canonical (tag-sorted) order.
|
|
174
|
+
#
|
|
175
|
+
# @return [Array<Hash>] the include statements, each `{ref: String, options: Hash, use: Array<String>, scope: (Array<String>, nil), domainName: String, tag: String, expires: (Integer, nil)}`
|
|
176
|
+
# (`scope` is `nil` for a `scope=any` grant, which covers every service host); an empty array when the identity publishes none.
|
|
177
|
+
def list
|
|
178
|
+
sort_includes
|
|
179
|
+
return @includes.dup
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Checks if the include statement references the given actor (by identifier or identity domain), allows the
|
|
183
|
+
# given mode/use, and covers the given service host with its `scope`.
|
|
184
|
+
#
|
|
185
|
+
# @param include_statement [Hash] the include statement to check.
|
|
186
|
+
# @param actor_identity [Triauth::Identity] the resolved actor identity to match against.
|
|
187
|
+
# @param mode [String, nil] the flow the include must allow; `nil` skips the use check.
|
|
188
|
+
# @param host [String, nil] the service host the include must cover; `nil` skips the scope check.
|
|
189
|
+
#
|
|
190
|
+
# @return [Boolean] true when the include statement covers the actor, the mode, and the host.
|
|
191
|
+
def check_match(include_statement, actor_identity, mode, host)
|
|
192
|
+
return (
|
|
193
|
+
(include_statement[:ref] == actor_identity.identifier || include_statement[:ref] == actor_identity.identity_domain.domain_name.downcase) &&
|
|
194
|
+
(mode.nil? || include_statement[:use].include?(mode)) &&
|
|
195
|
+
(host.nil? || include_statement[:scope].nil? || include_statement[:scope].include?(host))
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
private :check_match
|
|
199
|
+
|
|
200
|
+
# Sorts the include statements by their `tag` values (idempotent).
|
|
201
|
+
#
|
|
202
|
+
# The scan order matters as DNS may return the records in any order, and without the sort the code
|
|
203
|
+
# could pick different includes (with different tags - which end up in deviceTag) on each API method call.
|
|
204
|
+
def sort_includes
|
|
205
|
+
if !@sorted
|
|
206
|
+
@includes.sort! do |a, b|
|
|
207
|
+
x = a[:tag]
|
|
208
|
+
y = b[:tag]
|
|
209
|
+
if x < y
|
|
210
|
+
-1
|
|
211
|
+
elsif x > y
|
|
212
|
+
1
|
|
213
|
+
else
|
|
214
|
+
0
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
@sorted = true
|
|
220
|
+
end
|
|
221
|
+
private :sort_includes
|
|
222
|
+
|
|
223
|
+
# Finds an include statement that grants the given actor the given mode, resolving the actor's identity in the process.
|
|
224
|
+
#
|
|
225
|
+
# @param actor_identity [Triauth::Identity] the actor (delegate) to match include statements against.
|
|
226
|
+
# @param include_tag [String, nil] when given, only the include statement with this tag is considered. Defaults to nil.
|
|
227
|
+
# @param mode [String, nil] the flow that the include must allow; `nil` skips the use check. Defaults to 'auth'.
|
|
228
|
+
# @param host [String, nil] the service host that the include's `scope` must cover; `nil` skips the scope check. Defaults to nil.
|
|
229
|
+
#
|
|
230
|
+
# @return [Hash, nil, false] the matching include statement, `nil` when none matches (revoked), or `false` when the actor's identity could not be resolved.
|
|
231
|
+
def find(actor_identity, include_tag = nil, mode = 'auth', host = nil)
|
|
232
|
+
sort_includes
|
|
233
|
+
candidates = !include_tag.nil? ? @includes.select { |incl| incl[:tag] == include_tag } : @includes
|
|
234
|
+
|
|
235
|
+
if candidates.length == 0
|
|
236
|
+
Helpers.log(@config, :debug, 'IdentityIncludes.find failed - include statement revoked', { includeTag: include_tag })
|
|
237
|
+
return nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
actor_identity.resolve
|
|
241
|
+
if !actor_identity.resolved
|
|
242
|
+
Helpers.log(@config, :debug, 'IdentityIncludes.find failed - failed to resolve actor', { actorIdentifier: actor_identity.identifier })
|
|
243
|
+
return false
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
return candidates.find { |incl| check_match(incl, actor_identity, mode, host) } # the `?? null`: `find` answers nil already
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
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 'helpers'
|
|
21
|
+
require_relative 'validator'
|
|
22
|
+
require_relative 'protocol'
|
|
23
|
+
require_relative 'verifiers'
|
|
24
|
+
|
|
25
|
+
module Triauth
|
|
26
|
+
# Holds information about devices and their public keys that belong to the identifier, as extracted from identity records.
|
|
27
|
+
class IdentityKeys
|
|
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
|
+
# The key groups (devices) published for the identity, keyed by device name (a Symbol).
|
|
31
|
+
# Only groups marked `valid:true` can be used; a tainted group can never become valid.
|
|
32
|
+
# @return [Hash{Symbol => Hash}] each group `{keyCount: Integer, keys: Array<{value: String, options: Hash}>, expires: (Integer, nil), tainted: (Boolean, nil), valid: (Boolean, nil), tag: (String, nil)}`
|
|
33
|
+
attr_reader :key_groups
|
|
34
|
+
# The total number of valid key groups (devices).
|
|
35
|
+
# @return [Integer]
|
|
36
|
+
attr_reader :count
|
|
37
|
+
|
|
38
|
+
# Constructs a new, empty instance of IdentityKeys; key groups are populated through `add`.
|
|
39
|
+
#
|
|
40
|
+
# @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
|
|
41
|
+
def initialize(config = {})
|
|
42
|
+
@config = Helpers.merge_config(config)
|
|
43
|
+
|
|
44
|
+
@key_groups = {}
|
|
45
|
+
|
|
46
|
+
@count = 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Adds a key to the correct group of identity keys
|
|
50
|
+
#
|
|
51
|
+
# @param key_data [String] the value of the key as visible in the identity record that includes the prefix with device name and key index
|
|
52
|
+
# @param options [Hash] the key options as specified in the identity record; recognized critical options are
|
|
53
|
+
# `type` (a registered verifier type), `use` (comma-separated list of allowed modes),
|
|
54
|
+
# and `uv=required` (demand user verification; valid only on `webauthn-*` typed keys) - any other non-`x-` option or value taints the keyGroup. Defaults to `{}`.
|
|
55
|
+
# @param key_metadata [Hash] the metadata about the key record, as passed from the DNS resolver. Defaults to `{}`.
|
|
56
|
+
# @return [Boolean] true when key was successfully added, false if there are problems with the key structure (e.g., invalid group index)
|
|
57
|
+
def add(key_data, options = {}, key_metadata = {})
|
|
58
|
+
# Work on a private copy of `options`. `Helpers.object_assign` copies a `__proto__` key as data, so the options
|
|
59
|
+
# schema below judges it like any other unknown option
|
|
60
|
+
options = Helpers.object_assign({}, options)
|
|
61
|
+
|
|
62
|
+
# an ill-formed (invalid UTF-8) `key_data` raises here, as a non-string does in JS; records reach this method `is_normal_string`-validated
|
|
63
|
+
match_data = key_data.match(%r{\A(?<deviceName>[a-z0-9-]{1,20})\[(?<keyIdx>[0-9]{1,6})/(?<keyCount>[0-9]{1,6})\]:(?<keyVal>#{Helpers::JS_NOT_WS}+)\z}o)
|
|
64
|
+
|
|
65
|
+
if match_data.nil?
|
|
66
|
+
Helpers.log(@config, :debug, 'Ignoring key due to unrecognized syntax')
|
|
67
|
+
return false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# At this point we know that deviceName conforms to the required syntax, but everything else needs additional validation.
|
|
71
|
+
# We intentionally let through things like non-base64-url keyVals, as when invalid, they should taint the whole keyGroup.
|
|
72
|
+
|
|
73
|
+
device_name = match_data[:deviceName]
|
|
74
|
+
|
|
75
|
+
# Map leading-zero forms (e.g., "01") to literal 0 so they fail the downstream range check
|
|
76
|
+
# and taint the whole keyGroup, rather than being silently skipped at the regex stage.
|
|
77
|
+
# Skipping would leave the device's other published keys potentially usable, which we don't
|
|
78
|
+
# want when any single record on that device looks malformed.
|
|
79
|
+
key_idx = match_data[:keyIdx][0] == '0' ? 0 : Integer(match_data[:keyIdx], 10)
|
|
80
|
+
key_count = match_data[:keyCount][0] == '0' ? 0 : Integer(match_data[:keyCount], 10)
|
|
81
|
+
key_val = match_data[:keyVal]
|
|
82
|
+
|
|
83
|
+
max_devices, max_keys_per_device = Protocol::LIMITS.values_at(:maxDevices, :maxKeysPerDevice)
|
|
84
|
+
|
|
85
|
+
# Make sure device name conforms to the requirements
|
|
86
|
+
if !Validator.validate_device_name(device_name)[:valid]
|
|
87
|
+
Helpers.log(@config, :debug, 'Ignoring key due to invalid device name')
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `keyGroups` is keyed by the device name as a Symbol (the profile's key rule)
|
|
92
|
+
device_key = device_name.to_sym
|
|
93
|
+
|
|
94
|
+
# Make sure we are under the maxDevices limit which is the same as the number of defined keyGroups
|
|
95
|
+
if @key_groups[device_key].nil? && @key_groups.size >= max_devices
|
|
96
|
+
Helpers.log(@config, :debug, "Too many devices defined, ignoring key for device #{device_name}")
|
|
97
|
+
return false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Pre-initialize the entry with defaults
|
|
101
|
+
@key_groups[device_key] ||= { keyCount: key_count, keys: [], expires: nil }
|
|
102
|
+
|
|
103
|
+
key_group = @key_groups[device_key]
|
|
104
|
+
|
|
105
|
+
# Parse options
|
|
106
|
+
|
|
107
|
+
# All non-x- prefixed options are critical, and unknown critical options MUST taint the keyGroup
|
|
108
|
+
options_schema = {
|
|
109
|
+
type: Triauth::Verifiers.list,
|
|
110
|
+
use: [Protocol::FLOWS_USE_REGEXP],
|
|
111
|
+
uv: ['required']
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Set defaults
|
|
115
|
+
options[:type] = 'es256' if options[:type].nil?
|
|
116
|
+
options[:use] = Protocol::FLOWS.join(',') if options[:use].nil?
|
|
117
|
+
|
|
118
|
+
# Validate options
|
|
119
|
+
options_tainted = !Helpers.verify_options_schema(options, options_schema)
|
|
120
|
+
|
|
121
|
+
# The `uv` (user verification) constraint can only be proven by WebAuthn-backed keys - on any
|
|
122
|
+
# other key type the requirement would be silently unenforceable, so it taints the keyGroup instead.
|
|
123
|
+
# (`options['uv'] !== undefined`: a present `uv` member, nil included, takes this branch; `RegExp.test` coerces a
|
|
124
|
+
# non-string `type` to text and never matches `webauthn-`, so a non-string `type` taints here as in JS)
|
|
125
|
+
if options.key?(:uv) && !(Helpers.is_string(options[:type]) && /\Awebauthn-/.match?(options[:type]))
|
|
126
|
+
options_tainted = true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Catch edge cases, mark keyGroups as tainted, and return false
|
|
130
|
+
# (the slot test runs only after `key_idx >= 1` held, so `keys[-1]` is never read)
|
|
131
|
+
if key_group[:tainted] ||
|
|
132
|
+
options_tainted || # options are tainted (invalid options)
|
|
133
|
+
(key_count < 1 || key_count > max_keys_per_device) || # there are too many keys per device/keyGroup
|
|
134
|
+
(key_idx < 1 || key_idx > key_count) || # key index is mangled - e.g., [6/5]
|
|
135
|
+
key_group[:keyCount] != key_count || # number of keys in the group is not consistent across all keys (e.g., laptop[1/10], and then laptop[2/5])
|
|
136
|
+
!key_group[:keys][key_idx - 1].nil? || # key entry with the given index is doubled (e.g., laptop[1/2]:aaa, laptop[1/2]:bbb)
|
|
137
|
+
key_val.length > 1024 || # key value must be of sane length
|
|
138
|
+
!Helpers.is_base64url_string(key_val) # key value must be base64URL encoded
|
|
139
|
+
|
|
140
|
+
# Mark the keyGroup as tainted, so that it cannot become valid
|
|
141
|
+
key_group[:tainted] = true
|
|
142
|
+
|
|
143
|
+
# If for some reason the keyGroup has been previously marked as valid and counted in, un-validate it
|
|
144
|
+
if key_group[:valid]
|
|
145
|
+
key_group[:valid] = false
|
|
146
|
+
key_group.delete(:tag)
|
|
147
|
+
@count -= 1
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Return false to let the caller know that the key was not added
|
|
151
|
+
Helpers.log(@config, :debug, 'Key failed validation and tainted all keys for device', { keyData: key_data, deviceName: device_name })
|
|
152
|
+
return false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# At this point key has been validated, so we can continue
|
|
156
|
+
|
|
157
|
+
# Store the key in the keyGroup at the appropriate index (the Array grows with nil slots: the JS sparse array)
|
|
158
|
+
key_group[:keys][key_idx - 1] = {
|
|
159
|
+
value: key_val,
|
|
160
|
+
options: options
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# The group expiry timestamp is calculated as min of expiry timestamps of individual keys
|
|
164
|
+
# If no key in keyGroup has expires set, the keyGroup.expires stays nil
|
|
165
|
+
# (number truthiness: a nil or 0 `expires` is absent, as in `keyMetadata.expires &&` and `keyGroup.expires || Infinity`)
|
|
166
|
+
expires = key_metadata[:expires]
|
|
167
|
+
if !expires.nil? && expires != 0 && expires < (key_group[:expires].nil? || key_group[:expires] == 0 ? Float::INFINITY : key_group[:expires])
|
|
168
|
+
key_group[:expires] = expires
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
populated_key_count = key_group[:keys].count { |k| !k.nil? } # `Object.keys(sparseArray).length`: the populated slots
|
|
172
|
+
|
|
173
|
+
# If we have all keys from the group, and it was not tainted, we can mark it as valid and calculate its tag
|
|
174
|
+
# The tag takes into account only options that do not start with 'x-'
|
|
175
|
+
if !key_group[:tainted] && key_group[:keyCount] == populated_key_count && populated_key_count > 0
|
|
176
|
+
key_group[:valid] = true
|
|
177
|
+
key_group[:tag] = Helpers.sha256(
|
|
178
|
+
# `Object.entries(sparseArray)` yields the string index and the populated slots, in index order
|
|
179
|
+
"\x01#{device_name}\x1E#{key_group[:keyCount]}\x02" +
|
|
180
|
+
key_group[:keys].each_with_index.reject { |key, _| key.nil? }.map do |key, idx|
|
|
181
|
+
[
|
|
182
|
+
idx.to_s,
|
|
183
|
+
key[:value],
|
|
184
|
+
*key[:options].reject { |k, _| k.to_s[0] == 'x' && k.to_s[1] == '-' }.map { |k, v| "#{k}=#{v}" }.sort
|
|
185
|
+
].join("\x1E")
|
|
186
|
+
end.join("\x1D")
|
|
187
|
+
)
|
|
188
|
+
@count += 1
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return true
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Finds a key group that matches the given tag.
|
|
195
|
+
#
|
|
196
|
+
# @param tag [String] The device tag to look for, as carried by a signature envelope.
|
|
197
|
+
#
|
|
198
|
+
# @return [Hash, nil] the matching key group (`{keyCount: Integer, keys: Array<{value: String, options: Hash}>, expires: (Integer, nil), tainted: (Boolean, nil), valid: (Boolean, nil), tag: (String, nil)}`),
|
|
199
|
+
# or nil when no valid group carries that tag
|
|
200
|
+
def find_by_tag(tag)
|
|
201
|
+
Helpers.ordered_entries(@key_groups).map { |_, key_group| key_group }.each do |key_group| # `Object.values(this.keyGroups)`: the values in the JS object key order
|
|
202
|
+
if key_group[:valid] && key_group[:tag] == tag
|
|
203
|
+
return key_group
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
return nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Verifies that the given signatures are valid for the given message and given key use constraint -
|
|
211
|
+
# i.e., that they were made using private keys for which a matching set of public keys are present
|
|
212
|
+
# in one key group of this identity keys, and such keys are marked for such use.
|
|
213
|
+
#
|
|
214
|
+
# Signatures are verified using verifiers defined under the Triauth::Verifiers namespace, that may be specific
|
|
215
|
+
# for each of the identity keys (as loaded from the DNS), and specified in the key's "type" option. For example,
|
|
216
|
+
# for the identity key visible in DNS as "key BHA...QPDR type=ecdsa", an "ECDSA" - Triauth::Verifiers::Ecdsa verifier will be used.
|
|
217
|
+
#
|
|
218
|
+
# Signatures must match all keys in a key group for the verification to be successful.
|
|
219
|
+
#
|
|
220
|
+
# @param message [String] The message (envelope payload) for which the signatures are verified.
|
|
221
|
+
# @param signatures [Array<String>] Crypto signatures (base64url) to attempt to verify against the key group's keys.
|
|
222
|
+
# @param context [Hash] Verification context (`{mode: String, webAuthnOrigin: (String, nil)}`).
|
|
223
|
+
# `mode` (typically the signature type — `auth`/`sign`/`stamp`/`ping`/`attest`) gates which keys are considered via their `use=` option.
|
|
224
|
+
# Additional fields (e.g., `webAuthnOrigin`) are threaded through to crypto verifiers.
|
|
225
|
+
# @param signed_data [Hash] The signature envelope's `signedMetadata`. Threaded to crypto verifiers but not consumed here. Defaults to `{}`.
|
|
226
|
+
# @param unsigned_data [Hash] The signature envelope's `unsignedMetadata`. Threaded to crypto verifiers (e.g., WebAuthn reads `sig[idx]` from it). Defaults to `{}`.
|
|
227
|
+
# @return [Hash, false]
|
|
228
|
+
# a `{valid: true, keys: Array, name: String, tag: String, expires: (Integer, nil)}` Hash when signatures have matched a keys group, false otherwise.
|
|
229
|
+
def verify(message, signatures, context, signed_data = {}, unsigned_data = {})
|
|
230
|
+
mode = context[:mode]
|
|
231
|
+
|
|
232
|
+
# Helpers.log(@config, :debug, 'Triauth.IdentityKeys.verify', { message: message, signatures: signatures, context: context, signedData: signed_data, unsignedData: unsigned_data })
|
|
233
|
+
|
|
234
|
+
# Make sure we do not have too many signatures to verify
|
|
235
|
+
max_keys_per_signature = Protocol::LIMITS[:maxKeysPerSignature]
|
|
236
|
+
if signatures.length > max_keys_per_signature
|
|
237
|
+
Helpers.log(@config, :debug, "Too many signatures to verify - got #{signatures.length} with a limit of #{max_keys_per_signature}")
|
|
238
|
+
return false
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if signatures.any? { |s| !Helpers.is_base64url_string(s) }
|
|
242
|
+
Helpers.log(@config, :debug, 'Non base64-url encoded signature detected')
|
|
243
|
+
return false
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Attempt to verify each of keyGroups/devices, stop at and return the first matching/verified one
|
|
247
|
+
Helpers.ordered_entries(@key_groups).each do |name, key_group| # `Object.entries(this.keyGroups)`: the JS object key order
|
|
248
|
+
# Only take into account key groups that are considered valid, and have not expired.
|
|
249
|
+
# Allow a grace window (the client clock drift allowance doubles as a generous upper bound for
|
|
250
|
+
# in-call processing time) in expiry calculation, so that DNS key records with TTL=0 may still validate.
|
|
251
|
+
if key_group[:valid] != true ||
|
|
252
|
+
(!key_group[:expires].nil? && key_group[:expires] + @config[:maximalAllowedClientClockDrift] < Helpers.date_now)
|
|
253
|
+
next
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Prepare a working copy of keys, and add the verified property
|
|
257
|
+
# (a valid group has every slot populated - `add` marks it valid only then - so no nil slot is mapped)
|
|
258
|
+
keys = key_group[:keys].map { |key| { value: key[:value], options: key[:options], verified: false, skipped: false } }
|
|
259
|
+
|
|
260
|
+
# Go through each key and each signature looking for a match
|
|
261
|
+
# This by its own has O(n^2) complexity, but should be fine, since the number of keys and signatures is limited.
|
|
262
|
+
keys.each_with_index do |key, idx| # `Object.entries(keys)`: the index and the key
|
|
263
|
+
# Only take into account keys with matching use mode (auth, sign, stamp).
|
|
264
|
+
use = key[:options][:use]&.split(',', -1)
|
|
265
|
+
if !mode.nil? && mode != '' && !use.nil? && !use.include?(mode) # `mode && use && ...`: string and array truthiness
|
|
266
|
+
key[:skipped] = true
|
|
267
|
+
next
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Get the correct crypto verifier for the given key type
|
|
271
|
+
verifier_context = Helpers.object_assign({ idx: idx }, context)
|
|
272
|
+
verifier = Triauth::Verifiers.get(key[:options][:type])&.from_publishable_key(key[:value], key[:options], verifier_context)
|
|
273
|
+
if verifier.nil?
|
|
274
|
+
Helpers.log(@config, :debug, 'Could not initialize verifier for key', { key: key })
|
|
275
|
+
next
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Delegate signature verification to the crypto verifier
|
|
279
|
+
signatures.each do |signature|
|
|
280
|
+
key[:verified] ||= verifier.verify(message, signature, signed_data, unsigned_data)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# If the required key did not match any of the signatures, skip other keys from the keyGroup
|
|
284
|
+
if key[:verified] != true
|
|
285
|
+
break
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
key_group_keys_match = keys.all? { |k| k[:verified] == true || k[:skipped] == true }
|
|
290
|
+
verified_keys = keys.select { |k| k[:verified] == true }
|
|
291
|
+
|
|
292
|
+
if keys.length > 0 && verified_keys.length > 0 && key_group_keys_match
|
|
293
|
+
Helpers.log(@config, :debug, "Successful verification for deviceName #{name}", { deviceName: name.to_s, keys: keys })
|
|
294
|
+
return {
|
|
295
|
+
valid: true,
|
|
296
|
+
keys: keys,
|
|
297
|
+
name: name.to_s,
|
|
298
|
+
tag: key_group[:tag],
|
|
299
|
+
expires: key_group[:expires]
|
|
300
|
+
}
|
|
301
|
+
else
|
|
302
|
+
Helpers.log(@config, :debug, "Failed verification for deviceName #{name}", { deviceName: name.to_s, keys: keys })
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
return false
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|