triauth 0.0.1 → 1.0.0.beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/LICENSE +202 -28
  4. data/README.md +2036 -7
  5. data/lib/triauth/api/attestation.rb +419 -0
  6. data/lib/triauth/api/authentication.rb +651 -0
  7. data/lib/triauth/api/identity.rb +245 -0
  8. data/lib/triauth/api/signing.rb +740 -0
  9. data/lib/triauth/api/validation.rb +162 -0
  10. data/lib/triauth/api.rb +33 -0
  11. data/lib/triauth/authentication_endpoint.rb +196 -0
  12. data/lib/triauth/challenge.rb +115 -0
  13. data/lib/triauth/challenge_response_flow.rb +344 -0
  14. data/lib/triauth/config.rb +93 -0
  15. data/lib/triauth/error.rb +95 -0
  16. data/lib/triauth/helpers.rb +1222 -0
  17. data/lib/triauth/identity.rb +301 -0
  18. data/lib/triauth/identity_domain.rb +212 -0
  19. data/lib/triauth/identity_includes.rb +249 -0
  20. data/lib/triauth/identity_keys.rb +309 -0
  21. data/lib/triauth/logger.rb +156 -0
  22. data/lib/triauth/multi_signature.rb +177 -0
  23. data/lib/triauth/protocol.rb +74 -0
  24. data/lib/triauth/request_cipher.rb +121 -0
  25. data/lib/triauth/resolvers/base.rb +147 -0
  26. data/lib/triauth/resolvers/caching_resolver.rb +202 -0
  27. data/lib/triauth/resolvers/cloudflare.rb +40 -0
  28. data/lib/triauth/resolvers/dns_json.rb +391 -0
  29. data/lib/triauth/resolvers/dns_sb.rb +40 -0
  30. data/lib/triauth/resolvers/google.rb +39 -0
  31. data/lib/triauth/resolvers/multi_resolver.rb +202 -0
  32. data/lib/triauth/resolvers/system_dns.rb +259 -0
  33. data/lib/triauth/resolvers.rb +33 -0
  34. data/lib/triauth/response.rb +142 -0
  35. data/lib/triauth/response_cipher.rb +341 -0
  36. data/lib/triauth/signature.rb +405 -0
  37. data/lib/triauth/validator.rb +549 -0
  38. data/lib/triauth/verifiers/base.rb +147 -0
  39. data/lib/triauth/verifiers/ecdsa.rb +87 -0
  40. data/lib/triauth/verifiers/ed25519.rb +89 -0
  41. data/lib/triauth/verifiers/registry.rb +52 -0
  42. data/lib/triauth/verifiers/web_authn.rb +253 -0
  43. data/lib/triauth/verifiers.rb +37 -0
  44. data/lib/triauth/version.rb +23 -0
  45. data/lib/triauth.rb +104 -7
  46. metadata +55 -13
@@ -0,0 +1,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