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,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ require_relative '../error'
21
+ require_relative '../helpers'
22
+ require_relative '../validator'
23
+ require_relative '../protocol'
24
+ require_relative '../challenge_response_flow'
25
+
26
+ module Triauth
27
+ module Api
28
+ # Methods related to attestations that are a part of the official triauth-ruby API.
29
+ module Attestation
30
+ # The `attest` method may be used to obtain third-party verified attestations related to the given user's `identifier`.
31
+ # You can request attestations of almost anything, like the user age, not-a-robot status, location, role, etc., given that there is a verification provider that provides such attestation.
32
+ #
33
+ # Each attestation consists of a `label` (string) that is displayed to the user (e.g., 'I am not a robot'),
34
+ # and `providers` (array of `https://` URL strings), from which the user can select the desired verification provider.
35
+ # Once the user clicks 'verify' button for the given attestation, they are taken to the selected verification provider URL.
36
+ # It is then the job of verification provider to verify that the user meets the certain criteria (e.g., as read from the URL),
37
+ # and respond with a digital signature. Such signature(s) are embedded in the final response that you receive,
38
+ # and are verified during the stage '3. Verify' call of `Triauth.attest`.
39
+ #
40
+ # > [!IMPORTANT]
41
+ # > **You choose which providers to trust, and the provider defines what the attestation means.**<br/>
42
+ # > `Triauth.attest` only verifies that a signature came from an identity at the host of one of the `providers` you listed. It does not interpret what that provider actually checked.
43
+ #
44
+ # > [!NOTE]
45
+ # > **Attestations in triauth may be anonymous.**<br/>
46
+ # > By default, verification providers receive only the user-selected verification provider URL, and the domain name of user's authentication endpoint (e.g., `auth.triauth.org`).
47
+ # > They can later ask the user for their full identifier if needed and set a constraint that the attestation is valid only for the given identifier.
48
+ #
49
+ # > [!NOTE]
50
+ # > **Binders: what an attestation is tied to.**<br/>
51
+ # > A verification provider that learns the user states it in the `bind` Hash of its signature's signed metadata:
52
+ # > `identifier` (the user's identifier), `via` (the client application), and `deviceTag` (the device that completed the ceremony).
53
+ # > Every member of `bind` is critical, on every signature of the response: an unrecognized binder — or one that does not equal the verified user's value — fails the attestation (`verification_failed`),
54
+ # > so a provider's signed statement can never be silently ignored. Members of the signed metadata outside `bind` are auxiliary, and unrecognized ones are ignored.
55
+ # > An attester that binds nothing attests anonymously (the default); the binders it did state are surfaced under `attestations.<id>.signedMetadata.bind`, so you can require their presence.
56
+ #
57
+ # > [!NOTE]
58
+ # > **Use the same base callbackUrl as for authentication**<br/>
59
+ # > The `callbackUrl` used in this method must have the **same base URL** (its origin plus the path up to and including the last `/`) as the `callbackUrl` from the initial `Triauth.authenticate` call.
60
+ # > For example, if `callbackUrl` used for authentication was `https://example.com/dir1/callback`, the `callbackUrl` used in this method
61
+ # > must sit in that same `https://example.com/dir1/` base directory — only the final path segment may differ, e.g., `https://example.com/dir1/this-callback` (a deeper path such as `https://example.com/dir1/dir2/this-callback` does **not** match the base URL, and the request will be rejected).
62
+ #
63
+ # The attestations are based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
64
+ #
65
+ # **1. Request**
66
+ #
67
+ # At first, you need to call the `Triauth.attest` method with the `identifier` option set to the user provided personal identifier (lowercase string),
68
+ # a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
69
+ # an `attestations` option (Hash) whose keys are caller-chosen attestation IDs and whose values consist of a `label` (string) and `providers` (array of `https://` URL strings),
70
+ # a `sessionKey` (string) option set to the `sessionKey` of a `Triauth.authenticate` result,
71
+ # and an optional `ext` option (Hash) listing the [protocol extensions](#protocol-extensions) that you wish to use.
72
+ #
73
+ # In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
74
+ # You should temporarily store the `challenge` under the user's session, where the user can't tamper with it, and redirect the user's web browser to the `redirectUrl`.
75
+ #
76
+ # Synopsis:
77
+ #
78
+ # ```ruby
79
+ #
80
+ # result = Triauth.attest({ identifier: identifier, callbackUrl: callback_url, attestations: attestations, sessionKey: session_key, ext: ext })
81
+ # # returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
82
+ # # returns {error} on error
83
+ #
84
+ # ```
85
+ #
86
+ # Example:
87
+ #
88
+ # ```ruby
89
+ # Triauth.attest(
90
+ # {
91
+ # identifier: 'john@triauthdemo.org',
92
+ # callbackUrl: 'https://example.com/triauth-callback',
93
+ # attestations: {
94
+ # 'not-a-robot': {
95
+ # label: 'I am not a robot',
96
+ # providers: [
97
+ # 'https://attest.triauth.org/proof-of-work',
98
+ # 'https://other-provider.example.com/i-am/not-a-robot'
99
+ # ]
100
+ # }
101
+ # },
102
+ # sessionKey: '59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8'
103
+ # }
104
+ # )
105
+ # # {challenge: 'eyJjYn…l19fX0.PEq5CslSnTExe9xko1O5B-K9T4wBse5XtyClYb8WxJM', redirectUrl: 'https://auth.triauthdemo.org/attest.html#?skid=wzI1sz3n1R4biw4FL_VJ3Q&request=…'}
106
+ # ```
107
+ #
108
+ # **2. Redirect**
109
+ #
110
+ # Once the user's web browser is redirected to the `redirectUrl`, they will be taken to the Triauth Authenticator web application, where they will be presented with the requested attestations.
111
+ # For each attestation, your `label` is shown in the UI, together with a select field listing `providers`, and a 'Verify' button, allowing the user to select the desired provider and complete the attestation.
112
+ #
113
+ # > [!TIP]
114
+ # > The URLs of verification providers are shown in the UI, so keep them short and human-friendly.
115
+ #
116
+ # If the user declines the request, the Triauth Authenticator sends `false` as the `response`, which `Triauth.attest` reports as the error `request_denied`.
117
+ #
118
+ # **3. Verify**
119
+ #
120
+ # The Triauth Authenticator application redirects the user's web browser to the previously provided `callbackUrl`, by default through the [HTTP GET method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/GET), and passes the attestation in the `response` URL parameter.
121
+ # The method that is used to pass the response to your application can be configured with [callbackMethod extension](#protocol-extensions).
122
+ #
123
+ # Once you extract the `response` (string), you should pass it to the `Triauth.attest` function together with the previously stored `challenge` (string) to obtain attestation result.
124
+ # If you decide to also pass the `identifier` and/or `callbackUrl` together with `challenge` and `response`, it will be additionally verified that they are the same as embedded in the `challenge`. Every other option belongs to stage 1 and is rejected here with `invalid_arguments`.
125
+ #
126
+ # Synopsis:
127
+ #
128
+ # ```ruby
129
+ #
130
+ # attest_result = Triauth.attest({ challenge: challenge, response: response })
131
+ # # returns {attested:true, issuedAt, signedAt, verifiedAt, expires, secure, identifier, identityDomain, lookupCode, actor, actorIdentityDomain, actorLookupCode, groups, attestations, deviceName, deviceTag, keys} upon success
132
+ # # returns {error:{code, message}} upon failure
133
+ #
134
+ # ```
135
+ #
136
+ # Example:
137
+ #
138
+ # ```ruby
139
+ # Triauth.attest(
140
+ # {
141
+ # challenge: 'eyJjYn…l19fX0.PEq5CslSnTExe9xko1O5B-K9T4wBse5XtyClYb8WxJM',
142
+ # response: 'BPPDsY…OGSBac'
143
+ # }
144
+ # )
145
+ #
146
+ # #
147
+ # # {
148
+ # # attested: true,
149
+ # # issuedAt: 1787224748449,
150
+ # # signedAt: 1787224773556,
151
+ # # verifiedAt: 1787224773991,
152
+ # # expires: 1787226573991,
153
+ # # secure: true,
154
+ # # identifier: "john@triauthdemo.org",
155
+ # # identityDomain: "john._at.triauthdemo.org",
156
+ # # lookupCode: "",
157
+ # # actor: "",
158
+ # # actorIdentityDomain: "",
159
+ # # actorLookupCode: "",
160
+ # # groups: [],
161
+ # # attestations: {
162
+ # # 'not-a-robot': {
163
+ # # valid: true,
164
+ # # type: "attest",
165
+ # # identifier: "proof-of-work@attest.triauth.org",
166
+ # # identityDomain: "proof-of-work._at.attest.triauth.org",
167
+ # # lookupCode: "",
168
+ # # actor: "",
169
+ # # actorIdentityDomain: "",
170
+ # # actorLookupCode: "",
171
+ # # via: "https://attest.triauth.org/proof-of-work",
172
+ # # ver: 1,
173
+ # # signedAt: 1787224768930,
174
+ # # verifiedAt: 1787224773991,
175
+ # # publicProfile: {
176
+ # # name: "Proof Of Work"
177
+ # # },
178
+ # # groups: [],
179
+ # # deviceName: "server",
180
+ # # deviceTag: "NOtejuAqKHSdgvKVpw57cU_Qipj6UKcz2nNiugx0l8g",
181
+ # # keys: [
182
+ # # {
183
+ # # value: "BAdEISRVd_8LuQe8SV6p-cDjW2t3PSa6NlRGIhfRflkvIA9rrNJWbsQllN56YpF2Q8AIHWrcWcR6hoSnXy7QLFM",
184
+ # # options: {
185
+ # # use: "attest",
186
+ # # type: "es256"
187
+ # # },
188
+ # # verified: true,
189
+ # # skipped: false
190
+ # # }
191
+ # # ],
192
+ # # secure: true,
193
+ # # expires: 1787226573991,
194
+ # # signedMetadata: {
195
+ # # bits: 16
196
+ # # },
197
+ # # unsignedMetadata: {}
198
+ # # }
199
+ # # },
200
+ # # deviceName: "desktop",
201
+ # # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
202
+ # # keys: [
203
+ # # {
204
+ # # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
205
+ # # options: {
206
+ # # type: "es256",
207
+ # # use: "attest,auth,ping,sign,stamp"
208
+ # # },
209
+ # # verified: true,
210
+ # # skipped: false
211
+ # # }
212
+ # # ]
213
+ # # }
214
+ # #
215
+ # ```
216
+ #
217
+ # > [!IMPORTANT]
218
+ # > Upon successful attestation, the result Hash will have the `attested` property set to `true`.
219
+ # >
220
+ # > Upon failed attestation, the `attested` property will be missing or set to a falsy value, and the `error` property will contain [more details about the encountered problem](#error-codes).
221
+ #
222
+ # When `attested` is `true`, inspect the result before relying on it:
223
+ # - Check `secure`. It is `true` only when DNSSEC protected every DNS record of every identity involved in verification (the user's and every attester's, including each domain's `triauth` configuration record). When it is `false`, some of the public keys read from DNS were not cryptographically protected.
224
+ # - For attestations that must be tied to this specific user, confirm that the per-attestation `attestations.<id>.signedMetadata.bind` carries the binding you expect (the `identifier`/`deviceTag`).
225
+ # - Compare `attestations.<id>.identifier` with the attester the provider documents when the provider's host also issues identities to others.
226
+ #
227
+ # @param options [Hash] attestation options. Defaults to nil (a missing argument is rejected with `invalid_arguments`, as the empty Hash is).
228
+ #
229
+ # @option options [String] :identifier the user provided identifier (stage 1)
230
+ # @option options [String] :callbackUrl an application provided URL to which the user's web browser should be redirected with the response (stage 1)
231
+ # @option options [Hash] :attestations a Hash whose keys are caller-chosen attestation IDs (printable ASCII) and whose values are `{label, providers}` Hashes describing each requested attestation; at most 4 attestations, each listing at most 4 `https://` provider URLs (error `invalid_attestations` otherwise) (stage 1)
232
+ # @option options [String] :sessionKey the `sessionKey` of a `Triauth.authenticate` result; a stage-1 request without a valid session key returns the error `invalid_session_key` (stage 1, required)
233
+ # @option options [Hash] :ext an optional Hash of protocol extensions to use (e.g., `{callbackMethod:'GET'}`) (stage 1)
234
+ #
235
+ # @option options [String] :challenge a challenge as it was returned by the method after stage 1 call (stage 3)
236
+ # @option options [String] :response a received response, by default delivered as a `response` URL parameter of a GET to the `callbackUrl` (overridable via the `callbackMethod` extension) (stage 3)
237
+ #
238
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
239
+ #
240
+ # @return [Hash] one of the following:
241
+ # `{challenge: String, redirectUrl: String}`,
242
+ # `{attested: true, issuedAt: Integer, signedAt: Integer, verifiedAt: Integer, expires: (Integer, nil), secure: Boolean, identifier: String, identityDomain: String, lookupCode: String, actor: String, actorIdentityDomain: String, actorLookupCode: String, groups: Array<String>, attestations: Hash, deviceName: String, deviceTag: String, keys: Array<Hash>}`, or
243
+ # `{error: {code: String, reason?: String, message: String}}`
244
+ # - `{challenge: String, redirectUrl: String}`: Returned during stage 1 when a challenge is generated. The `challenge` should be stored and the user should be redirected to the `redirectUrl`.
245
+ # - `{attested: true, issuedAt: Integer, signedAt: Integer, verifiedAt: Integer, expires?: Integer, secure: Boolean, identifier: String, identityDomain: String, lookupCode: String, actor: String, actorIdentityDomain: String, actorLookupCode: String, groups: Array<String>, attestations: Hash, deviceName: String, deviceTag: String, keys: Array<Hash>}`: Returned during stage 3 upon successful attestation. Includes:
246
+ # - `attested` (true): Indicates the attestation request was fully satisfied (user signature plus every requested attester signature verified).
247
+ # - `issuedAt` (Integer): Unix timestamp on **your** server's clock at which you issued the challenge in stage 1 — the start of the flow's timeline.
248
+ # - `signedAt` (Integer): Unix timestamp on the **user's device** clock at which the user signed the request (the user's `mainSig`). Verified to fall within the freshness window, but device-asserted — do not treat it as a trusted wall clock; prefer `issuedAt`/`verifiedAt` for authoritative timing. Each attester's own signing time is on its `attestations.<id>.signedAt`.
249
+ # - `verifiedAt` (Integer): Unix timestamp on **your** server's clock at which the whole bundle finished verifying — the latest (max) across the user's and every attester's signature confirmation.
250
+ # - `expires` (Integer, optional): Unix timestamp at which the overall verification expires — the earliest expiry across all signing identities; `nil` when none of the DNS records involved carried a TTL, in which case apply your own re-check interval.
251
+ # - `secure` (Boolean): True only when, for every identity involved (the user's and every attester's), DNSSEC was reported for the domain's `triauth` configuration record and for every well-formed record of the identity answer; a single non-validated identity record anywhere degrades it.
252
+ # - `identifier` (String): The user's identifier (from the user's `mainSig`).
253
+ # - `identityDomain` (String): The DNS domain under which the user's identity records are stored (the user's own, for a delegated ceremony).
254
+ # - `lookupCode` (String): The lookup code the user identity's domain derivation consumed (under `mode=private`); `''` where none was. Each `attestations.<id>.lookupCode` names the attester's own likewise.
255
+ # - `actor` (String): The identifier of the delegate whose keys signed the user's `mainSig` (delegated ceremony), or `''` when the user's own keys signed it. Attester signatures are always first-party (their own `actor` is `''`).
256
+ # - `actorIdentityDomain` (String): For a delegated ceremony, the DNS domain under which the delegate's identity records are stored — the records the signing keys were read from; `''` when the user's own keys signed it (and always `''` on attester entries).
257
+ # - `actorLookupCode` (String): The lookup code the delegate's domain derivation consumed; `''` where none was (and always `''` on attester entries - their statements are first-party).
258
+ # - `groups` (Array): The user's fully-qualified group-membership claims (sorted, deduplicated; `[]` when none are published). This top-level field describes the user, while each `attestations.<id>.groups` describes that attester's own identity.
259
+ # - `attestations` (Hash): A map keyed by the caller-chosen attestation ID, whose values are the per-attester signature Hashes (same shape as `Triauth::Signature#verify` returns: `{valid, type:'attest', identifier, identityDomain, lookupCode, actor, actorIdentityDomain, actorLookupCode, via, ver, signedAt, verifiedAt, publicProfile, groups, deviceName, deviceTag, keys, secure, expires, signedMetadata, unsignedMetadata}`).
260
+ # - `deviceName` (String): User-given, public name of the device that signed the request.
261
+ # - `deviceTag` (String): Unique identifier of the device (and, for a delegated ceremony, the delegation) that signed the request, in the same form `Triauth.authenticate` reports it.
262
+ # - `keys` (Array): Public keys considered and/or used to verify the user's signature.
263
+ # - `{error: {code: String, reason?: String, message: String}}`: Returned during stage 1 or stage 3 when attestation fails, includes an error Hash with code and message.
264
+ def self.attest(options = nil, config = {})
265
+ config = Helpers.merge_config(config)
266
+ config[:logger] = (config[:logger].respond_to?(:spawn) ? config[:logger].spawn(config[:cid]) : nil) || config[:logger]
267
+
268
+ # `Object.assign({}, options)`: nil, a String or an Array in the options position give the same Hash as in JS
269
+ options = Helpers.object_assign({}, options)
270
+ attestations_option = options[:attestations]
271
+
272
+ validate_attestations = lambda do |attestations|
273
+ validation_error = Validator.validate_attestations(attestations)[:errors][0]
274
+
275
+ if validation_error
276
+ raise Triauth::Error.new(validation_error)
277
+ end
278
+ end
279
+
280
+ on_challenge = lambda do |args|
281
+ challenge = args[:challenge]
282
+ validate_attestations.call(attestations_option)
283
+ challenge.data[:attest] = attestations_option
284
+ end
285
+
286
+ on_response = lambda do |args|
287
+ challenge = args[:challenge]
288
+ response = args[:response]
289
+ attestations = challenge.data[:attest]
290
+ validate_attestations.call(attestations)
291
+
292
+ # For attestations the signature is verified against the SHA256 of challengeString
293
+ signed_payload = Helpers.sha256(challenge.challenge_string)
294
+
295
+ attest_timeout = config[:attestTimeout]
296
+ verify_result = response.verify(
297
+ 'attest',
298
+ signed_payload,
299
+ challenge,
300
+ {
301
+ notBefore: Helpers.date_now - (attest_timeout.nil? || attest_timeout == 0 ? 0 : attest_timeout), # `config.attestTimeout || 0`: the JS falsy set of a number, nil and 0
302
+ notAfter: Helpers.date_now,
303
+ minSignatures: 1,
304
+ maxSignatures: [attestations.keys.length + 1, Protocol::LIMITS[:maxMultiSignatures] || 0].min # `LIMITS.maxMultiSignatures || 0`: a frozen Integer constant, so Ruby's `||` yields the same value
305
+ }
306
+ )
307
+
308
+ # `verifyResult?.valid`: the result is a Hash, nil or false
309
+ if verify_result && verify_result[:valid] && verify_result[:signatures].length > 0
310
+
311
+ # Protocol invariant: the user's signature is always the first element of the
312
+ # multi-signature envelope; the remaining entries are attester signatures.
313
+ main_sig = verify_result[:signatures][0]
314
+
315
+ if challenge.identity.identifier != main_sig[:identifier]
316
+ Helpers.log(config, :debug, 'Verification failed - identifier mismatch', { expectedIdentifier: challenge.identity.identifier, receivedIdentifier: main_sig[:identifier] })
317
+ return false
318
+ end
319
+
320
+ if !Validator.validate_callback_url(challenge.data[:cburl])[:valid] ||
321
+ Helpers.get_base_url(challenge.data[:cburl]) != main_sig[:via]
322
+ Helpers.log(config, :debug, 'Verification failed - via prefix mismatch', { cburl: challenge.data[:cburl], via: main_sig[:via] })
323
+ return false
324
+ end
325
+
326
+ # What a binder must equal, by name: the verified user signature's own fields.
327
+ binder_targets = {
328
+ identifier: main_sig[:identifier], # the user the attestation is bound to
329
+ via: main_sig[:via], # the client application the user signed for
330
+ deviceTag: main_sig[:deviceTag] # the device that completed the ceremony
331
+ }
332
+
333
+ # Verify binders - a verifier's statement about WHOM it verified
334
+ # An envelope carrying a false or unrecognized binding is malformed as a whole.
335
+ # (`Object.entries(sig.signedMetadata.bind || {})`: `object_assign({}, x)` yields `{}` for the JS-falsy values and spreads a
336
+ # String or an Array into index keys exactly as `Object.entries` lists them; an unknown binder compares to `undefined` in JS,
337
+ # which no value equals, while Ruby's nil would equal a JSON null value, hence the explicit `key?` test)
338
+ verify_result[:signatures].each do |sig|
339
+ if !Validator.validate_binders(sig[:signedMetadata])[:valid] ||
340
+ !Helpers.object_assign({}, sig[:signedMetadata][:bind]).all? { |binder, value| binder_targets.key?(binder) && value == binder_targets[binder] }
341
+ Helpers.log(config, :debug, 'Verification failed - invalid or mismatched binders', { identifier: sig[:identifier], signedMetadata: sig[:signedMetadata] })
342
+ return false
343
+ end
344
+ end
345
+
346
+ attestations_result = {}
347
+
348
+ # Note: a single attestSig can satisfy multiple requested attestation IDs if their providers` lists overlap # rubocop:disable Style/CommentAnnotation -- the triauth-js comment, carried verbatim
349
+ # (`Object.entries(attestations)`: the JS object key order, an all-digit attestation ID first)
350
+ Helpers.ordered_entries(attestations).each do |attest_id, entry|
351
+ providers = entry[:providers]
352
+ matched = false
353
+
354
+ verify_result[:signatures][1..].each do |attest_sig| # `slice(1)`
355
+ # (`/.+@/` with the JS `.`; `indexOf(...) >= 0` on an Array of validated URL strings is `include?`)
356
+ if attest_sig[:valid] &&
357
+ attest_sig[:actor] == '' && # attester statements must be self-signed
358
+ providers.include?(attest_sig[:via]) &&
359
+ attest_sig[:identifier].sub(/#{Helpers::JS_DOT}+@/o, '') == Helpers.url_host(attest_sig[:via])
360
+ Helpers.log(config, :debug, 'Attestation matched', { attestId: attest_id, attestSig: attest_sig })
361
+ attestations_result[attest_id] = Helpers.object_assign({}, attest_sig)
362
+ matched = true
363
+ break
364
+ end
365
+ end
366
+
367
+ if !matched
368
+ Helpers.log(config, :info, 'Verification failed - missing or unverified attestation', { attestId: attest_id })
369
+ return false
370
+ end
371
+ end
372
+
373
+ Helpers.log(config, :info, 'Verification successful', { identifier: main_sig[:identifier], deviceTag: main_sig[:deviceTag], attestations: attestations_result.keys })
374
+
375
+ # (an `expires` of nil stays a present nil member, as in `authenticate`)
376
+ return Helpers.clone(
377
+ {
378
+ attested: true,
379
+
380
+ issuedAt: challenge.data[:iat],
381
+ signedAt: main_sig[:signedAt],
382
+ verifiedAt: verify_result[:verifiedAt],
383
+
384
+ expires: verify_result[:expires],
385
+ secure: verify_result[:secure],
386
+
387
+ identifier: main_sig[:identifier],
388
+ identityDomain: main_sig[:identityDomain],
389
+ lookupCode: main_sig[:lookupCode],
390
+ actor: main_sig[:actor],
391
+ actorIdentityDomain: main_sig[:actorIdentityDomain],
392
+ actorLookupCode: main_sig[:actorLookupCode],
393
+ groups: main_sig[:groups],
394
+
395
+ attestations: attestations_result,
396
+
397
+ deviceName: main_sig[:deviceName],
398
+ deviceTag: main_sig[:deviceTag],
399
+ keys: main_sig[:keys]
400
+ }
401
+ )
402
+
403
+ elsif verify_result.nil?
404
+ # `parseInt(challenge.data.iat, 10)` of a number: a non-number (or a non-finite one) logs `NaN` there and nil here - log data only
405
+ Helpers.log(config, :info, 'Verification failed - request expired', { identifier: challenge.identity.identifier, iat: Helpers.is_number(challenge.data[:iat]) && challenge.data[:iat].finite? ? challenge.data[:iat].to_i : nil })
406
+ return nil
407
+
408
+ else
409
+ Helpers.log(config, :info, 'Verification failed', { identifier: challenge.identity.identifier })
410
+ return false
411
+
412
+ end
413
+ end
414
+
415
+ return ChallengeResponseFlow.perform('attest', options, { onChallenge: on_challenge, onResponse: on_response }, config, %i[attestations])
416
+ end
417
+ end
418
+ end
419
+ end