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,740 @@
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 '../config'
21
+ require_relative '../error'
22
+ require_relative '../helpers'
23
+ require_relative '../validator'
24
+ require_relative '../challenge_response_flow'
25
+ require_relative '../multi_signature'
26
+
27
+ module Triauth
28
+ module Api
29
+ # Methods related to signing and signature verification that are a part of the official triauth-ruby API.
30
+ module Signing
31
+ # Shared implementation for `Triauth.sign` and `Triauth.stamp`.
32
+ # Embeds `message` (and, for `sign`, optional `attachments`) into the challenge,
33
+ # then verifies the response is a single fresh signature over the same `message`
34
+ # with matching attachments. Attachments are rejected for `stamp`.
35
+ # Freshness is bounded by `config[:signTimeout]` or `config[:stampTimeout]`.
36
+ #
37
+ # @api private
38
+ # @param type [String] `'sign'` or `'stamp'`
39
+ # @param options [Hash] same shape as the corresponding public `sign`/`stamp` method. Defaults to nil (the public methods pass their argument through).
40
+ # @param config [Hash] optional overrides for the global `Triauth.config` settings. Defaults to `{}`.
41
+ # @return [Hash] same return shape as the corresponding public method
42
+ def self.perform(type, options = nil, config = {})
43
+ config = Helpers.merge_config(config)
44
+ config[:logger] = (config[:logger].respond_to?(:spawn) ? config[:logger].spawn(config[:cid]) : nil) || config[:logger]
45
+
46
+ # `Object.assign({}, options)`: nil, a String or an Array in the options position give the same Hash as in JS
47
+ options = Helpers.object_assign({}, options)
48
+
49
+ message_option = options[:message]
50
+ attachments_provided = options.key?(:attachments) # `'attachments' in options`
51
+ attachments_option = options[:attachments]
52
+
53
+ validate_message = lambda do |message|
54
+ validation_error = Validator.validate_message(message)[:errors][0]
55
+
56
+ if validation_error
57
+ raise Triauth::Error.new(validation_error)
58
+ end
59
+ end
60
+
61
+ validate_attachments = lambda do |attachments, require_source_url = true|
62
+ validation_error = Validator.validate_attachments(attachments, require_source_url)[:errors][0]
63
+
64
+ if validation_error
65
+ raise Triauth::Error.new(validation_error)
66
+ end
67
+ end
68
+
69
+ on_challenge = lambda do |args|
70
+ challenge = args[:challenge]
71
+ validate_message.call(message_option)
72
+ challenge.data[:msg] = message_option
73
+
74
+ if type == 'sign'
75
+ validate_attachments.call(attachments_option.nil? ? [] : attachments_option) # `attachmentsOption ?? []`
76
+ challenge.data[:attachments] = attachments_option.nil? ? [] : attachments_option
77
+
78
+ elsif attachments_provided
79
+ raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Attachments option may only be used with Triauth.sign function' })
80
+ end
81
+ end
82
+
83
+ on_response = lambda do |args|
84
+ challenge = args[:challenge]
85
+ response = args[:response]
86
+ message = challenge.data[:msg]
87
+ validate_message.call(message)
88
+
89
+ challenge_attachments = challenge.data[:attachments]
90
+
91
+ if type == 'sign'
92
+ validate_attachments.call(challenge_attachments)
93
+
94
+ elsif challenge.data.key?(:attachments)
95
+ raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Attachments option may only be used with Triauth.sign function' })
96
+
97
+ end
98
+
99
+ # Sign/stamp signatures cover the `msg` (plus the identity header and `ts`), but NOT the
100
+ # challenge nonce/iat/cburl. This makes stamps portable across verifiers (within the `ts` window).
101
+ result = ChallengeResponseFlow.verify_challenge_response(type, message, challenge, response, (type == 'sign' ? config[:signTimeout] : config[:stampTimeout]), config)
102
+
103
+ # Propagate the failure signals unchanged: null -> request_expired, false -> verification_failed.
104
+ if !result
105
+ return result
106
+ end
107
+
108
+ sig = result[:sig]
109
+ verify_result = result[:verifyResult]
110
+
111
+ # (`sig[:signedMetadata]` is always a Hash: the envelope parser guarantees it; `?? []` is the nil test, and the nested
112
+ # ternary is an `if` expression: RuboCop `Style/NestedTernaryOperator`)
113
+ response_attachments =
114
+ if type == 'sign'
115
+ sig[:signedMetadata][:attachments].nil? ? [] : sig[:signedMetadata][:attachments]
116
+ else
117
+ sig[:signedMetadata][:attachments]
118
+ end
119
+
120
+ if type == 'sign'
121
+ # `sourceUrl` is optional on the signed response - the proof binds only name+sha256, and the
122
+ # authenticator should not embed fetch URLs (which may carry credentials) in a portable signature.
123
+ validate_attachments.call(response_attachments, false)
124
+
125
+ elsif sig[:signedMetadata].key?(:attachments)
126
+ Helpers.log(config, :info, 'Verification failed - stamp response signed-metadata carries attachments', { identifier: sig[:identifier] })
127
+ return false
128
+
129
+ end
130
+
131
+ if type == 'sign'
132
+ # Attachments are matched by name+sha256. `sourceUrl` is optional but if present must match too.
133
+ # (`ca.sha256 &&`: the JS falsy set of the validated hex string spelled out; `ra.sourceUrl === undefined` is
134
+ # "member absent", so a signed `sourceUrl: null` keeps the JS null outcome, which `validate_attachments` already rejected with `invalid_attachments`)
135
+ attachments_match = lambda do |ca, ra|
136
+ ca[:name] == ra[:name] &&
137
+ !ca[:sha256].nil? && ca[:sha256] != '' && ca[:sha256] == ra[:sha256] &&
138
+ (!ra.key?(:sourceUrl) || ra[:sourceUrl] == ca[:sourceUrl])
139
+ end
140
+
141
+ # (`every(... find(...))`: `find` answers the element or undefined, so `any?` is the same test)
142
+ attachments_verify_result =
143
+ challenge_attachments.length == response_attachments.length &&
144
+ challenge_attachments.all? { |ca| response_attachments.any? { |ra| attachments_match.call(ca, ra) } } &&
145
+ response_attachments.all? { |ra| challenge_attachments.any? { |ca| attachments_match.call(ca, ra) } }
146
+
147
+ if !attachments_verify_result
148
+ Helpers.log(config, :info, 'Verification failed - not all attachments have been signed', { identifier: sig[:identifier] })
149
+ return false
150
+ end
151
+ end
152
+
153
+ Helpers.log(config, :info, 'Verification successful', { identifier: sig[:identifier], deviceTag: sig[:deviceTag] })
154
+
155
+ retval = {}
156
+ verb = "#{type}ed"
157
+ retval[verb.to_sym] = true # the result key `:signed` / `:stamped`, a Symbol like every data key
158
+
159
+ # `Object.assign(retval, {...})` mutates and returns `retval`: so does `Hash#merge!` (`Helpers.object_assign` would return a new Hash)
160
+ retval.merge!(
161
+ {
162
+ result: response.response_string,
163
+ issuedAt: challenge.data[:iat],
164
+ signedAt: sig[:signedAt],
165
+ verifiedAt: verify_result[:verifiedAt],
166
+ verificationResult: verify_result[:signatures][0]
167
+ }
168
+ )
169
+
170
+ return Helpers.clone(retval)
171
+ end
172
+
173
+ return ChallengeResponseFlow.perform(type, options, { onChallenge: on_challenge, onResponse: on_response }, config, %i[message attachments])
174
+ end
175
+ private_class_method :perform
176
+
177
+ # The `sign` method may be used to obtain an interactive signature of a given `message` and optional `attachments` from the user with a given `identifier`.
178
+ # Interactive signatures require interaction from the user, like reviewing the message and clicking on the 'Sign' button in the Triauth Authenticator.
179
+ # Signatures generated by the `sign` function may be later verified by any party using the `Triauth.verify` method, and may act as an independent proof that e.g.,
180
+ # user has accepted certain terms or documents.
181
+ #
182
+ # > [!WARNING]
183
+ # > A `sign` result is a transferable proof, not a single-use act. The cryptographic signature covers
184
+ # > the `message` (and any `attachments`) together with the signature's own `type`, `identifier`, `via`,
185
+ # > and timestamp — but **not** the challenge nonce, which is never bound to the signature and is not
186
+ # > re-checked during verification. A `sign` response therefore re-verifies against any freshly minted
187
+ # > `sign` challenge for the same `(identifier, message, callbackUrl-base)` while its timestamp is within
188
+ # > `config[:signTimeout]` (30 minutes by default, widened by `config[:maximalAllowedClientClockDrift]`), and
189
+ # > the raw `result` is independently verifiable via `Triauth.verify` until the signer's DNS keys rotate.
190
+ # > Relying parties that need single-use / replay protection must embed a fresh, verifier-chosen nonce
191
+ # > inside the `message` itself and reject reused nonces.
192
+ #
193
+ # > [!NOTE]
194
+ # > **Use the same base callbackUrl as for authentication**<br/>
195
+ # > 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.
196
+ # > For example, if `callbackUrl` used for authentication was `https://example.com/dir1/callback`, the `callbackUrl` used in this method
197
+ # > 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).
198
+ #
199
+ # The signing is based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
200
+ #
201
+ # **1. Request**
202
+ #
203
+ # At first, you need to call the `Triauth.sign` method with the `identifier` option set to the user provided personal identifier (lowercase string),
204
+ # a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
205
+ # a `message` (string) option set to a human-readable text message to be signed,
206
+ # an optional `attachments` (array) option listing additional file attachments to be covered by the signature,
207
+ # a `sessionKey` (string) option set to the `sessionKey` of a `Triauth.authenticate` result,
208
+ # and an optional `ext` option (Hash) listing the [protocol extensions](#protocol-extensions) that you wish to use.
209
+ #
210
+ # > [!NOTE]
211
+ # > The `message` may consist of letters and digits from any language, space, and a fixed set of punctuation, and is limited to 2 KB.
212
+ # > Emojis, newlines, tabs, and other whitespace or control characters are not allowed - the same rule as for `Triauth.stamp`.
213
+ #
214
+ # The `attachments` option, if present, should be an array (of at most 10 entries) with information on additional file attachments that need to be downloaded and covered by the signature.
215
+ # Each entry in the `attachments` array should be in the form of `{name, sourceUrl, sha256}`, where `name` is a unique attachment's file name, `sourceUrl` is the URL from which the attachment can be downloaded, and `sha256` is a SHA256 hexdigest of the attachment file content.
216
+ #
217
+ # > [!TIP]
218
+ # > The server from which attachment is served must respond with `Access-Control-Allow-Origin: *` HTTP header to allow Triauth Authenticator to download it.
219
+ # > Additionally, only selected file formats and extensions are supported (`.txt`, `.pdf`, `.json`, `.xml`, `.png`, `.doc`, `.xls`, and a few others).
220
+ # > For file formats other than `txt` and `pdf`, the file name must end with the appropriate extension (e.g., `.doc`).
221
+ #
222
+ # > [!TIP]
223
+ # > You can get a SHA256 hexdigest of a content of a given `sourceUrl` with the following snippet:<br/>
224
+ # > ```ruby
225
+ # > Digest::SHA256.hexdigest(Net::HTTP.get(URI(source_url)))
226
+ # > ```
227
+ #
228
+ #
229
+ # In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
230
+ # 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`.
231
+ #
232
+ # Synopsis:
233
+ #
234
+ # ```ruby
235
+ #
236
+ # result = Triauth.sign({ identifier: identifier, callbackUrl: callback_url, message: message, attachments: attachments, sessionKey: session_key, ext: ext })
237
+ # # returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
238
+ # # returns {error} on error
239
+ #
240
+ # ```
241
+ #
242
+ # Example:
243
+ #
244
+ # ```ruby
245
+ # Triauth.sign(
246
+ # {
247
+ # identifier: 'john@triauthdemo.org',
248
+ # callbackUrl: 'https://example.com/triauth-callback',
249
+ # message: 'To continue, please read and accept the attached License',
250
+ # attachments: [
251
+ # {
252
+ # name: 'License',
253
+ # sourceUrl: 'https://raw.githubusercontent.com/spdx/license-list-data/refs/heads/main/text/AGPL-3.0-or-later.txt',
254
+ # sha256: 'd8a6cc31abc16b6748c7a21f21611f5a1ec33f67d22ca23d7da1c19b95496bee'
255
+ # }
256
+ # ],
257
+ # sessionKey: '59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8'
258
+ # }
259
+ # )
260
+ # # {
261
+ # # challenge: "eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJzaWduIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJLRXZCSVBLZFRLX1JNYldFUkZVVFpBcUkiLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCTEYxdEVWQkVZMGpBZ3lFcGM0MUJSdTRHS2loaTVneDhyalFmY2Q3UnFOUGFUd1kyeG1zYkNSZ0YxNkN5VWJuRk5XUFREZndkMV9XTVlEWldRdWFOblkiLCJtc2ciOiJUbyBjb250aW51ZSwgcGxlYXNlIHJlYWQgYW5kIGFjY2VwdCB0aGUgYXR0YWNoZWQgTGljZW5zZSIsImF0dGFjaG1lbnRzIjpbeyJuYW1lIjoiTGljZW5zZSIsInNvdXJjZVVybCI6Imh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9zcGR4L2xpY2Vuc2UtbGlzdC1kYXRhL3JlZnMvaGVhZHMvbWFpbi90ZXh0L0FHUEwtMy4wLW9yLWxhdGVyLnR4dCIsInNoYTI1NiI6ImQ4YTZjYzMxYWJjMTZiNjc0OGM3YTIxZjIxNjExZjVhMWVjMzNmNjdkMjJjYTIzZDdkYTFjMTliOTU0OTZiZWUifV19.zN__zhGHx9hT7gakwy1WZ33JRURklZVmS-crUohEchE",
262
+ # # redirectUrl: "https://auth.triauthdemo.org/sign.html#?skid=wzI1sz3n1R4biw4FL_VJ3Q&request=23luwOGXdIM6JX8f._EkvQKPuF6kR2v-ur9aIU1QkqCDURnpq-_GThw669vCmekrNYmLC6ZaVuqHLy3i77tFqNUed5hlm-Yk1KLkyroMGnQQl5_jS7CPrwTthn0MjrHrf0hBcVBdeD_3mGTm-wFDjXS6qpgZO9721poQnV3Cj-a6rxQXyX6o1tOD8_JdiEql23uFQ8axvbnpFVP7w-QLj9IuNU6jeM2hOGNrS-n6TKR11iQ40bfyERFWNtQNgrDTh1A_fnnxv_YPTTBdnzgFiDe09M0h93ZACRVJGLWNGj4dx4xsa5l08cC0-6a1BwuShxbMtoWROFLky0vUNkbAseko-4xKvuIIXAu5AmPDTXfLpiormgzmGK72f0aokY1YUUy0VzGIsTDdjCxqPJTgrDlt4g1uJu60yfSCPdHOWubbRj5eMdhTW3p6LzOWIDZPDpSeC141v9eX7wbTB_KIJiJwCvHD1dbq3fTaTM0z313WzT8VXKc5RPtaIWz8J1L7KiZcH2NjjyqFRJ1jYHPj2g8JPiPo5wohmcuPqC98l7cBPK_GI0PXEuuWvuEqk9uDXtufhBHwu2wLOx-n2n9ktLHnE1Bgw5qkYn6-YJD5NJ1YCjljc4_Une_smlyXL6-cDm-dD3ugp1rw1SbVv_f5m8uQUvkHxkxKXrSqSmljxFsG4g9kbf3myh39d5iODAxuwzLcGdOAXWHmIS9Hmkx2rLytc-v8emjPu9Ktb5iD0yZtKAX4-3eWBHzBgPr3wbA"
263
+ # # }
264
+ # ```
265
+ #
266
+ # **2. Redirect**
267
+ #
268
+ # Once the user's web browser is redirected to the `redirectUrl`, they will be taken to the Triauth Authenticator web application,
269
+ # where they will be presented with the message, and have an opportunity to view and/or download attachments.
270
+ #
271
+ # If the user declines the signing request, the Triauth Authenticator sends `false` as the `response`, which `Triauth.sign` reports as the error `request_denied`.
272
+ #
273
+ # **3. Verify**
274
+ #
275
+ # After the user approves the signing request, 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 a signature in the `response` URL parameter.
276
+ # The method that is used to pass the response to your application can be configured with [callbackMethod extension](#protocol-extensions).
277
+ #
278
+ # Once you extract the `response` (string), you should pass it to the `Triauth.sign` function together with the previously stored `challenge` (string) to obtain signing result.
279
+ # 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`.
280
+ #
281
+ # Synopsis:
282
+ #
283
+ # ```ruby
284
+ #
285
+ # signing_result = Triauth.sign({ challenge: challenge, response: response })
286
+ # # returns {signed:true, result, issuedAt, signedAt, verifiedAt, verificationResult} upon success
287
+ # # returns {error:{code, message}} upon failure
288
+ #
289
+ # ```
290
+ #
291
+ # Example:
292
+ #
293
+ # ```ruby
294
+ # Triauth.sign(
295
+ # {
296
+ # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJzaWduIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJLRXZCSVBLZFRLX1JNYldFUkZVVFpBcUkiLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCTEYxdEVWQkVZMGpBZ3lFcGM0MUJSdTRHS2loaTVneDhyalFmY2Q3UnFOUGFUd1kyeG1zYkNSZ0YxNkN5VWJuRk5XUFREZndkMV9XTVlEWldRdWFOblkiLCJtc2ciOiJUbyBjb250aW51ZSwgcGxlYXNlIHJlYWQgYW5kIGFjY2VwdCB0aGUgYXR0YWNoZWQgTGljZW5zZSIsImF0dGFjaG1lbnRzIjpbeyJuYW1lIjoiTGljZW5zZSIsInNvdXJjZVVybCI6Imh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9zcGR4L2xpY2Vuc2UtbGlzdC1kYXRhL3JlZnMvaGVhZHMvbWFpbi90ZXh0L0FHUEwtMy4wLW9yLWxhdGVyLnR4dCIsInNoYTI1NiI6ImQ4YTZjYzMxYWJjMTZiNjc0OGM3YTIxZjIxNjExZjVhMWVjMzNmNjdkMjJjYTIzZDdkYTFjMTliOTU0OTZiZWUifV19.zN__zhGHx9hT7gakwy1WZ33JRURklZVmS-crUohEchE',
297
+ # response: 'BFUYnuW1tUiltgq1DQbLeqpZTNDwDJm89hxkfrBuuNNdTmmwoRYuR5dZeVdnrJWbcSiLfO2VPYuTw-mBq6EZi0c.HUJzImw0j-Uo629gvuIMb6ePe9G835vaz7bURvqz5ICtkxc9vP4sRRXTnkhpQl8lsVVX4aYs1ltz-eft2aqxyUwfaaPYRPhWGj3rwUdr9lXnT8qtSkyInacgxWWx8MCPoveO1GBBD2iIE4-o9vUEfPziEh9dz7bBf_1Y_xM_fFXHEXZaGz1CWBN42S24BnKuVUvcT1gGfJiSIDdoG8YdU6xUFWJwhxwZupQbjIbfGuXrwXVrVejuE3_sgIy7pQ2SyLgSQcoBw329vTJns0b5QcjyRojzc9dS1iWeQWj_3TjF7nQh8F9sHWVqvdc0YCDncrcR_z-uTjDhgzShnhevHXukgQ_-Dze4kOd1wgUiwfEEIQvclIyVkSyrEcMt9bIUkmhb5Su5eicMnzdEZIvLMJQ7gssUxPE8B7_DXKZgMEnH'
298
+ # }
299
+ # )
300
+ # #
301
+ # # {
302
+ # # signed: true,
303
+ # # result: "|sign;john@triauthdemo.org;;https://example.com/;v1;1787224774435;eyJhdHRhY2htZW50cyI6W3sibmFtZSI6IkxpY2Vuc2UiLCJzaGEyNTYiOiJkOGE2Y2MzMWFiYzE2YjY3NDhjN2EyMWYyMTYxMWY1YTFlYzMzZjY3ZDIyY2EyM2Q3ZGExYzE5Yjk1NDk2YmVlIn1dfQ;;pj0gdEVQ3JBYh08jknFpY45WX462AUb1z78mINHewl1NXWzU5_g9ibD3-ffbdH9SIGtQTtcHQrsa3Ivt1wg1sg|",
304
+ # # issuedAt: 1787224748449,
305
+ # # signedAt: 1787224774435,
306
+ # # verifiedAt: 1787224774536,
307
+ # # verificationResult: {
308
+ # # valid: true,
309
+ # # type: "sign",
310
+ # # identifier: "john@triauthdemo.org",
311
+ # # identityDomain: "john._at.triauthdemo.org",
312
+ # # lookupCode: "",
313
+ # # actor: "",
314
+ # # actorIdentityDomain: "",
315
+ # # actorLookupCode: "",
316
+ # # via: "https://example.com/",
317
+ # # ver: 1,
318
+ # # signedAt: 1787224774435,
319
+ # # verifiedAt: 1787224774536,
320
+ # # publicProfile: {
321
+ # # initials: "JD",
322
+ # # name: "John Doe"
323
+ # # },
324
+ # # groups: [],
325
+ # # deviceName: "desktop",
326
+ # # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
327
+ # # keys: [
328
+ # # {
329
+ # # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
330
+ # # options: {
331
+ # # type: "es256",
332
+ # # use: "attest,auth,ping,sign,stamp"
333
+ # # },
334
+ # # verified: true,
335
+ # # skipped: false
336
+ # # }
337
+ # # ],
338
+ # # secure: true,
339
+ # # expires: 1787226574536,
340
+ # # signedMetadata: {
341
+ # # attachments: [
342
+ # # {
343
+ # # name: "License",
344
+ # # sha256: "d8a6cc31abc16b6748c7a21f21611f5a1ec33f67d22ca23d7da1c19b95496bee"
345
+ # # }
346
+ # # ]
347
+ # # },
348
+ # # unsignedMetadata: {}
349
+ # # }
350
+ # # }
351
+ # #
352
+ # ```
353
+ #
354
+ # > [!IMPORTANT]
355
+ # > Upon successful signing, the result Hash will have the `signed` property set to `true`, and the raw signature (string) available in the `result` property.
356
+ # > This signature may also be verified by third parties using the `Triauth.verify` method.
357
+ # >
358
+ # > Upon failed signing, the `signed` property will be missing or set to a falsy value, and the `error` property will contain [more details about the encountered problem](#error-codes).
359
+ #
360
+ # > [!NOTE]
361
+ # > An attachment is bound to the signature by its `name` and `sha256` only. The `sourceUrl` is used at request time to fetch the file, but is optional in the signed response. If a response does sign a `sourceUrl`, it must match the one requested in the challenge.
362
+ #
363
+ # @param options [Hash] signing options. Defaults to nil (a missing argument is rejected with `invalid_arguments`, as the empty Hash is).
364
+ #
365
+ # @option options [String] :identifier (stage 1) the user provided identifier
366
+ # @option options [String] :callbackUrl (stage 1) an application provided URL to which the user's web browser should be redirected with the response
367
+ # @option options [String] :message (stage 1) a human-readable text message to be signed
368
+ # @option options [Array<Hash>] :attachments (stage 1) optional list of at most 10 attachments to be covered by the signature, each `{name: String, sourceUrl: String, sha256: String}`; `name` must be unique within the list and `sha256` a lowercase hex digest
369
+ # @option options [String] :sessionKey (stage 1, required) the `sessionKey` of a `Triauth.authenticate` result; a stage-1 request without a valid session key returns the error `invalid_session_key`
370
+ # @option options [Hash] :ext (stage 1) an optional Hash of protocol extensions to use (e.g., `{ callbackMethod: 'GET' }`)
371
+ #
372
+ # @option options [String] :challenge (stage 3) a challenge as it was returned by the method after stage 1 call
373
+ # @option options [String] :response (stage 3) a received response, by default delivered as a `response` URL parameter of a GET to the `callbackUrl` (overridable via the `callbackMethod` extension)
374
+ #
375
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
376
+ #
377
+ # @return [Hash] one of the following shapes:
378
+ # `{challenge: String, redirectUrl: String}`,
379
+ # `{signed: true, result: String, issuedAt: Integer, signedAt: Integer, verifiedAt: Integer, verificationResult: Hash}`, or
380
+ # `{error: {code: String, reason?: String, message: String}}`
381
+ def self.sign(options = nil, config = {})
382
+ return perform('sign', options, config)
383
+ end
384
+
385
+ # The `stamp` method may be used to automatically obtain a signature of a given `message` made on behalf of the user with a given `identifier`.
386
+ # Stamps do not typically require any interaction from the user, and may be later verified by any party using the `Triauth.verify` method.
387
+ # They can be used e.g., to prove to other websites that the user is logged in to your website, to authenticate third-party API requests,
388
+ # or in [Diffie–Hellman key exchange](https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange) to prove the identities of communicating parties for E2E encryption.
389
+ #
390
+ # > [!WARNING]
391
+ # > Stamps are not single-use proofs. The cryptographic signature covers the `message` together
392
+ # > with the stamp's `type`, `identifier`, `via`, and timestamp — but **not** the challenge nonce,
393
+ # > which is never bound to the signature. A stamp over a fixed `message` thus re-verifies against
394
+ # > any challenge that reuses that `message`, within the `ts` freshness window. Relying parties that
395
+ # > need replay protection must include a fresh, verifier-chosen nonce inside `message` itself.
396
+ #
397
+ # > [!NOTE]
398
+ # > **Get the `stamp` permission and use the same base callbackUrl as for authentication**<br/>
399
+ # > To get the permission, provide the `ext: { permissions: { stamp: true } }` parameter to the `Triauth.authenticate` function during the initial call (i.e., when building the challenge).
400
+ # > The authentication result shows the decision in `ext.permissions`. You can use `stamp` only when the authenticator responds with `ext.dig(:permissions, :stamp) == true`.
401
+ # >
402
+ # > Also, 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.
403
+ # > For example, if `callbackUrl` used for authentication was `https://example.com/dir1/callback`, the `callbackUrl` used in this method
404
+ # > 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).
405
+ #
406
+ # The stamping is based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
407
+ #
408
+ # **1. Request**
409
+ #
410
+ # At first, you need to call the `Triauth.stamp` method with the `identifier` option set to the user provided personal identifier (lowercase string),
411
+ # a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
412
+ # a `message` (string) option set to a text message to be stamped,
413
+ # a `sessionKey` (string) option set to the `sessionKey` of a `Triauth.authenticate` result,
414
+ # and an optional `ext` option (Hash) listing the [protocol extensions](#protocol-extensions) that you wish to use.
415
+ #
416
+ # > [!NOTE]
417
+ # > The `message` may consist of letters and digits from any language, space, and a fixed set of punctuation, and is limited to 2 KB.
418
+ # > Emojis, newlines, tabs, and other whitespace or control characters are not allowed.
419
+ # > To stamp binary data or multi-line content (e.g., a public key, a pretty-printed JSON object, a PEM blob),
420
+ # > base64- or hex-encode it before passing it as `message`.
421
+ #
422
+ # In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
423
+ # 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`.
424
+ #
425
+ # Synopsis:
426
+ #
427
+ # ```ruby
428
+ #
429
+ # result = Triauth.stamp({ identifier: identifier, callbackUrl: callback_url, message: message, sessionKey: session_key, ext: ext })
430
+ # # returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
431
+ # # returns {error} on error
432
+ #
433
+ # ```
434
+ #
435
+ # Example:
436
+ #
437
+ # ```ruby
438
+ # Triauth.stamp(
439
+ # {
440
+ # identifier: 'john@triauthdemo.org',
441
+ # callbackUrl: 'https://example.com/triauth-callback',
442
+ # message: 'login-nonce:4f2d9c715b6e4a3c', # a fresh, single-use nonce from your server, when the stamp gates anything
443
+ # sessionKey: '59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8'
444
+ # }
445
+ # )
446
+ # #
447
+ # # {
448
+ # # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJzdGFtcCIsImlkZW50aWZpZXIiOiJqb2huQHRyaWF1dGhkZW1vLm9yZyIsIm5vbmNlIjoic1ZoVXRZOXQzMEVDQjJUR0t2WTktZXpBIiwiaWF0IjoxNzg3MjI0NzQ4NDQ5LCJ2ZXIiOjEsImVrIjoiQkdpOE1kZG11ZnFQdGNncEdwZFFvNDMzSTdxVzVHUm1uU0NmOGZaLVJHclppNng1ZHJNWkZIa3dQZXRkemFyZ21MQ2VvVU9HaF80dXJ6RWt6T3RKU0NrIiwibXNnIjoibG9naW4tbm9uY2U6NGYyZDljNzE1YjZlNGEzYyJ9.osPmHctBwGQUdU346YuJtHPJ-hh01Hia9bxCFZtYhNs',
449
+ # # redirectUrl: 'https://auth.triauthdemo.org/stamp.html#?skid=wzI1sz3n1R4biw4FL_VJ3Q&request=qKIqh6XTsNbjhjis.huEd1iwa_Xd1q1OhHA7bUmFhfjxCtTEEoPs3l8JSkxJXMrIzBEbjk58CA24JnV6O6LIJ35YTzf3cfjBfBiZyjLdHRbHlpbanM-VTF0Utke_G6Y2Ol3onMk4vmWn40GN6XhyEYRitzXJfCKawNGjhjBoif6r5niNFsOh1f-RCVauYVKUpvSOoVPztSSwYAwtRs9FohwIBG48FpB6kI5xKQLtFJZbhBze0L02gWSwnXwQr5hMh4jOMPV9po7HZUfA1mmRgBkZPhUBSYknBBKYTa1qFadizopi9a9_M7UH1kNTWWEyIXxXt_ibIXhSu1n5-yxVwx2r-M-q7BnH0Nu-V5MDqCnE9fxJ19KoBjj1zpMfZMbx6b2T_zQ7ULPmmZrQKWZYlIDx_XY4f15M_AyZD72i5Awi9JA'
450
+ # # }
451
+ # #
452
+ # ```
453
+ #
454
+ # **2. Redirect**
455
+ #
456
+ # Once the user's web browser is redirected to the `redirectUrl`, the Triauth Authenticator web application immediately responds with the stamp to the `callbackUrl`. This needs a valid session key with the `stamp` permission.
457
+ #
458
+ # **3. Verify**
459
+ #
460
+ # 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 a stamp in the `response` URL parameter.
461
+ # The method that is used to pass the response to your application can be configured with [callbackMethod extension](#protocol-extensions).
462
+ #
463
+ # Once you extract the `response` (string), you should pass it to the `Triauth.stamp` function together with the previously stored `challenge` (string) to obtain stamping result.
464
+ # 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`.
465
+ #
466
+ # Synopsis:
467
+ #
468
+ # ```ruby
469
+ #
470
+ # stamping_result = Triauth.stamp({ challenge: challenge, response: response })
471
+ # # returns {stamped:true, result, issuedAt, signedAt, verifiedAt, verificationResult} upon success
472
+ # # returns {error:{code, message}} upon failure
473
+ #
474
+ # ```
475
+ #
476
+ # Example:
477
+ #
478
+ # ```ruby
479
+ # Triauth.stamp(
480
+ # {
481
+ # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJzdGFtcCIsImlkZW50aWZpZXIiOiJqb2huQHRyaWF1dGhkZW1vLm9yZyIsIm5vbmNlIjoic1ZoVXRZOXQzMEVDQjJUR0t2WTktZXpBIiwiaWF0IjoxNzg3MjI0NzQ4NDQ5LCJ2ZXIiOjEsImVrIjoiQkdpOE1kZG11ZnFQdGNncEdwZFFvNDMzSTdxVzVHUm1uU0NmOGZaLVJHclppNng1ZHJNWkZIa3dQZXRkemFyZ21MQ2VvVU9HaF80dXJ6RWt6T3RKU0NrIiwibXNnIjoibG9naW4tbm9uY2U6NGYyZDljNzE1YjZlNGEzYyJ9.osPmHctBwGQUdU346YuJtHPJ-hh01Hia9bxCFZtYhNs',
482
+ # response: 'BFpQrxQ6mXjTWkiCFbd1d1QmQdwwdTT56QAffbbiQf_in1BW_nwpZcWqOHgVOV_FnGZuUlFc0bKEZiUBp-SQW2g.dWHtvEeGPf217Qg3SoQ5BBFrytjc5IumJZDwPmGvd4n04pwr7BD8sQQFCGM6WTwc0zu1_nIy9v6_rG4oqtEVyAzVrG7Siepi6AIBwiLgQQqdjcFBX3LNH-Xh6xD_XBOyM4P9QSDJjicL5dg-KUg3P2XXp8J4Tke6F2QK1WyepNxfIY-wi6HeKmsVqTbUYjzj_tJs23jdDv7Ik_7m5CCYkZtq0Xxj8NZSU1BmEw'
483
+ # }
484
+ # )
485
+ #
486
+ # #
487
+ # # {
488
+ # # stamped: true,
489
+ # # result: "|stamp;john@triauthdemo.org;;https://example.com/;v1;1787224751380;;;0lWRFGKZsAC18au2DqtYEavf7zXDkCQH0fTOqwB2QeWsKhSkm29NY0mz1r6VckOVL2utg3wp1QMnySEO56zupg|",
490
+ # # issuedAt: 1787224748449,
491
+ # # signedAt: 1787224751380,
492
+ # # verifiedAt: 1787224751481,
493
+ # # verificationResult: {
494
+ # # valid: true,
495
+ # # type: "stamp",
496
+ # # identifier: "john@triauthdemo.org",
497
+ # # identityDomain: "john._at.triauthdemo.org",
498
+ # # lookupCode: "",
499
+ # # actor: "",
500
+ # # actorIdentityDomain: "",
501
+ # # actorLookupCode: "",
502
+ # # via: "https://example.com/",
503
+ # # ver: 1,
504
+ # # signedAt: 1787224751380,
505
+ # # verifiedAt: 1787224751481,
506
+ # # publicProfile: {
507
+ # # initials: "JD",
508
+ # # name: "John Doe"
509
+ # # },
510
+ # # groups: [],
511
+ # # deviceName: "desktop",
512
+ # # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
513
+ # # keys: [
514
+ # # {
515
+ # # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
516
+ # # options: {
517
+ # # type: "es256",
518
+ # # use: "attest,auth,ping,sign,stamp"
519
+ # # },
520
+ # # verified: true,
521
+ # # skipped: false
522
+ # # }
523
+ # # ],
524
+ # # secure: true,
525
+ # # expires: 1787226551481,
526
+ # # signedMetadata: {},
527
+ # # unsignedMetadata: {}
528
+ # # }
529
+ # # }
530
+ # #
531
+ # ```
532
+ #
533
+ # > [!IMPORTANT]
534
+ # > Upon successful stamping, the result Hash will have the `stamped` property set to `true`, and the stamp (string) available in the `result` property.
535
+ # > This stamp may also be verified by third parties using the `Triauth.verify` method.
536
+ # >
537
+ # > Upon failed stamping, the `stamped` property will be missing or set to a falsy value, and the `error` property will contain [more details about the encountered problem](#error-codes).
538
+ #
539
+ # @param options [Hash] stamping options. Defaults to nil (a missing argument is rejected with `invalid_arguments`, as the empty Hash is).
540
+ #
541
+ # @option options [String] :identifier (stage 1) the user provided identifier
542
+ # @option options [String] :callbackUrl (stage 1) an application provided URL to which the user's web browser should be redirected with the response
543
+ # @option options [String] :message (stage 1) a text message to be stamped
544
+ # @option options [String] :sessionKey (stage 1, required) the `sessionKey` of a `Triauth.authenticate` result whose `ext.permissions.stamp` is `true`; a stage-1 request without a valid session key returns the error `invalid_session_key`
545
+ # @option options [Hash] :ext (stage 1) an optional Hash of protocol extensions to use (e.g., `{ callbackMethod: 'GET' }`)
546
+ #
547
+ # @option options [String] :challenge (stage 3) a challenge as it was returned by the method after stage 1 call
548
+ # @option options [String] :response (stage 3) a received response, by default delivered as a `response` URL parameter of a GET to the `callbackUrl` (overridable via the `callbackMethod` extension)
549
+ #
550
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
551
+ #
552
+ # @return [Hash] one of the following shapes:
553
+ # `{challenge: String, redirectUrl: String}`,
554
+ # `{stamped: true, result: String, issuedAt: Integer, signedAt: Integer, verifiedAt: Integer, verificationResult: Hash}`, or
555
+ # `{error: {code: String, reason?: String, message: String}}`
556
+ def self.stamp(options = nil, config = {})
557
+ return perform('stamp', options, config)
558
+ end
559
+
560
+ # The verify method verifies triauth signatures and stamps.
561
+ # For the verification to succeed, the public keys associated with the device that made the signature/stamp need to be still present in the DNS.
562
+ #
563
+ # > [!TIP]
564
+ # > For signatures that need to be verifiable even after the respective device keys are removed from the DNS,
565
+ # > consider using a DNS History Service.
566
+ #
567
+ # > [!TIP]
568
+ # > All responses in triauth's 3-step challenge-response protocol are triauth signatures, and to some extent can also be verified later with this method.
569
+ # > But never use this method in place of the original (i.e., never use `Triauth.verify(challenge, response)` in place of `Triauth.authenticate({ challenge: challenge, response: response })` or any other such method),
570
+ # > as it may report `{valid:true}`, even when important constraints are not met (e.g., the authentication request has timed out).
571
+ #
572
+ # > [!IMPORTANT]
573
+ # > By default `Triauth.verify` accepts only a **single-signature** envelope (`maxSignatures` defaults to 1).
574
+ # > To verify a multi-signature envelope (e.g. an attestation bundle), raise `maxSignatures` to the number of
575
+ # > segments you expect; otherwise a multi-segment envelope is reported as `{valid:false, reason:'declined'}`.
576
+ #
577
+ # Synopsis:
578
+ #
579
+ # ```ruby
580
+ # verification_result = Triauth.verify(message, signature)
581
+ # # returns {valid:true, type, identifier, identityDomain, lookupCode, actor, actorIdentityDomain, actorLookupCode, via, ver, signedAt, verifiedAt, publicProfile, groups, deviceName, deviceTag, keys, secure, expires, signedMetadata, unsignedMetadata} if signature has been successfully verified
582
+ # # returns {valid:true, type:'multisig', secure, expires, verifiedAt, signatures} when a multi-signature was verified, the signatures array carries the individual verification results
583
+ # # returns {valid:false, reason} when the envelope was evaluated and not accepted; reason is one of 'malformed' (unparseable envelope), 'invalid' (bad crypto, an unresolvable signer, or an identifier/actor/type/ver/via mismatch), 'expired' (signature timestamp outside notBefore/notAfter), or 'declined' (segment count outside minSignatures/maxSignatures)
584
+ # # returns {error} when the signature could not be evaluated at all (e.g. bad arguments, DNS failure, or a requireSecure veto)
585
+ #
586
+ # ```
587
+ #
588
+ # > [!IMPORTANT]
589
+ # > Accept a signature only when `valid == true`. Treat every other result as not verified. The `reason` string on a `{valid:false}` result is diagnostic only (for logging / UX / retry classification) - never use it as an accept/reject signal.
590
+ # > If you raise `maxSignatures`, expect that you may get `type:'multisig'` back without a top-level `identifier` set.
591
+ #
592
+ # Example:
593
+ #
594
+ # ```ruby
595
+ # Triauth.verify(
596
+ # 'login-nonce:4f2d9c715b6e4a3c',
597
+ # '|stamp;john@triauthdemo.org;;https://example.com/;v1;1787224751380;;;0lWRFGKZsAC18au2DqtYEavf7zXDkCQH0fTOqwB2QeWsKhSkm29NY0mz1r6VckOVL2utg3wp1QMnySEO56zupg|'
598
+ # )
599
+ # #
600
+ # # {
601
+ # # valid: true,
602
+ # # type: "stamp",
603
+ # # identifier: "john@triauthdemo.org",
604
+ # # identityDomain: "john._at.triauthdemo.org",
605
+ # # lookupCode: "",
606
+ # # actor: "",
607
+ # # actorIdentityDomain: "",
608
+ # # actorLookupCode: "",
609
+ # # via: "https://example.com/",
610
+ # # ver: 1,
611
+ # # signedAt: 1787224751380,
612
+ # # verifiedAt: 1787224808550,
613
+ # # publicProfile: {
614
+ # # initials: "JD",
615
+ # # name: "John Doe"
616
+ # # },
617
+ # # groups: [],
618
+ # # deviceName: "desktop",
619
+ # # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
620
+ # # keys: [
621
+ # # {
622
+ # # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
623
+ # # options: {
624
+ # # type: "es256",
625
+ # # use: "attest,auth,ping,sign,stamp"
626
+ # # },
627
+ # # verified: true,
628
+ # # skipped: false
629
+ # # }
630
+ # # ],
631
+ # # secure: true,
632
+ # # expires: 1787226608550,
633
+ # # signedMetadata: {},
634
+ # # unsignedMetadata: {}
635
+ # # }
636
+ # #
637
+ # ```
638
+ #
639
+ # @param message [String] A message that was signed. Defaults to nil (a missing argument is rejected with `invalid_arguments`).
640
+ # @param signature [String] The received signature of the message. Defaults to nil (a missing argument is rejected with `invalid_arguments`).
641
+ # @param constraints [Hash] Optional additional constraints that should be verified prior to calling the time-taking internal crypto functions. Defaults to `{}`.
642
+ # @option constraints [String] :identifier identifier on behalf of which the signature was generated
643
+ # @option constraints [String] :actor the actor (delegate) whose keys made the signature on behalf of the `identifier`; `''` requires a non-delegated signature
644
+ # @option constraints [Integer] :ver the triauth signature envelope version to require
645
+ # @option constraints [String] :type the type of signature to require — one of 'attest'|'auth'|'ping'|'sign'|'stamp'. Pin this to the expected signature type when possible.
646
+ # @option constraints [String] :via the base URL (e.g., https://example.com/) of the client application the signature was issued to (the base URL of its `callbackUrl`). Pin it when possible. Otherwise a signature requested by another application verifies just as well.
647
+ # @option constraints [Integer] :notBefore a Unix timestamp in milliseconds (a non-number or `NaN` gives `{valid:false, reason:'invalid'}`). If the timestamp encoded in the signature is earlier than this value, the result is `{valid:false, reason:'expired'}`. Note: unlike the internal verify path used by `authenticate`/`ping`/`sign`/`stamp`, `Triauth.verify` does **not** widen this bound by `config[:maximalAllowedClientClockDrift]` - the comparison is strict.
648
+ # @option constraints [Integer] :notAfter a Unix timestamp in milliseconds (a non-number or `NaN` gives `{valid:false, reason:'invalid'}`). If the timestamp encoded in the signature is greater than this value, the result is `{valid:false, reason:'expired'}`. Note: strict comparison; no clock-drift slack is applied (see `notBefore`).
649
+ # @option constraints [Integer] :minSignatures the minimum number of signature segments the (multi-)signature envelope must contain. A count below this is a signature-count policy decline, reported as `{valid:false, reason:'declined'}`. Note: this counts segments, NOT distinct signers - the same identity may legitimately contribute multiple (e.g., per-device) signatures; to require distinct signers, additionally check `signatures[].identifier` and/or `signatures[].deviceTag` in the result. Because `maxSignatures` defaults to 1, requiring more than one signature means you must also raise `maxSignatures`.
650
+ # @option constraints [Integer] :maxSignatures the maximum number of signature segments the (multi-)signature envelope may contain. **Defaults to 1** (secure-by-default): when omitted, `Triauth.verify` accepts only a single signature, so a multi-segment envelope is reported as `{valid:false, reason:'declined'}`. To verify a multi-signature envelope, pass an explicit `maxSignatures` (>= the number of segments you expect). The default of 1 also pins the result to the flat single-signature shape and bounds the per-segment DNS resolution work for untrusted input.
651
+ # @param config [Hash] optional overrides for the global Triauth.config settings. Defaults to `{}`. When `config[:requireSecure]` is set, an otherwise-valid signature whose DNS chain was not reported DNSSEC-validated (`secure != true`) is rejected with the error `dnssec_required` (surfaced as `{error}`).
652
+ #
653
+ # @return [Hash] one of the following shapes:
654
+ # `{valid: true, type: String, identifier: String, identityDomain: String, lookupCode: String, actor: String, actorIdentityDomain: String, actorLookupCode: String, via: String, ver: Integer, signedAt: Integer, verifiedAt: Integer, publicProfile: Hash, groups: Array<String>, deviceName: String, deviceTag: String, keys: Array<Hash>, secure: Boolean, expires: (Integer, nil), signedMetadata: Hash, unsignedMetadata: Hash}`,
655
+ # `{valid: true, type: 'multisig', secure: Boolean, expires: (Integer, nil), verifiedAt: Integer, signatures: Array<Hash>}`,
656
+ # `{valid: false, reason: 'malformed'|'invalid'|'expired'|'declined'}`, or
657
+ # `{error: {code: String, reason?: String, message: String}}`
658
+ def self.verify(message = nil, signature = nil, constraints = {}, config = {})
659
+ config = Helpers.merge_config(config)
660
+ config[:logger] = (config[:logger].respond_to?(:spawn) ? config[:logger].spawn(config[:cid]) : nil) || config[:logger]
661
+
662
+ Helpers.log(config, :info, 'Processing Triauth.verify')
663
+ ts = Helpers.now
664
+
665
+ begin
666
+ # Check the sanity of received arguments
667
+ # (`typeof constraints !== 'object'` admits null and Arrays, which `hasOnlyKnownProperties` then rejects, so "not a Hash" is the
668
+ # same accept-set; `typeof config !== 'object'` can never fire after `mergeConfig` in either language)
669
+ if !Helpers.is_string(message) || !Helpers.is_string(signature) || !constraints.is_a?(Hash) ||
670
+ !Helpers.has_only_known_properties(constraints, %i[identifier actor ver type via notBefore notAfter minSignatures maxSignatures])
671
+ raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Invalid or missing function arguments' })
672
+ end
673
+
674
+ # Reject config overrides carrying unknown keys
675
+ if !Helpers.has_only_known_properties(config, Triauth::KNOWN_CONFIG_KEYS)
676
+ raise Triauth::Error.new({ code: 'invalid_config', message: 'Unrecognized configuration key' })
677
+ end
678
+
679
+ # Signature-count bounds, when provided, must be numbers
680
+ min = constraints[:minSignatures]
681
+ max = constraints[:maxSignatures]
682
+ if (!min.nil? && (!Helpers.is_number(min) || (min.is_a?(Float) && min.nan?))) ||
683
+ (!max.nil? && (!Helpers.is_number(max) || (max.is_a?(Float) && max.nan?)))
684
+ raise Triauth::Error.new({ code: 'invalid_arguments', message: 'Invalid or missing function arguments' })
685
+ end
686
+
687
+ # Secure-by-default: when the caller does not specify maxSignatures, it defaults to 1, so an
688
+ # un-opted-in multi-signature envelope is declined below.
689
+ if constraints[:maxSignatures].nil?
690
+ constraints = Helpers.object_assign({}, constraints, { maxSignatures: 1 })
691
+ end
692
+
693
+ sig = MultiSignature.new(signature, config)
694
+
695
+ # A signature-count policy mismatch means we DECLINE cryptographic verification and report `{valid:false, reason:'declined'}`
696
+ seg_count = sig.signatures.length
697
+ min_signatures = constraints[:minSignatures]
698
+ max_signatures = constraints[:maxSignatures]
699
+ if (!max_signatures.nil? && seg_count > max_signatures) ||
700
+ (!min_signatures.nil? && seg_count < min_signatures)
701
+ Helpers.log(config, :info, 'Verification declined - signature count outside minSignatures/maxSignatures policy', { segCount: seg_count, minSignatures: min_signatures, maxSignatures: max_signatures })
702
+ return { valid: false, reason: 'declined' }
703
+ end
704
+
705
+ verify_result = sig.verify(message, constraints)
706
+
707
+ if !verify_result || !verify_result[:valid]
708
+ Helpers.log(config, :info, 'Verification failed')
709
+
710
+ # `null` => time-related failure ("expired"); `false` (or a defensive object with valid:false)
711
+ # => any other failure ("invalid"). Preserves the 3-state verdict the lower layers compute.
712
+ return { valid: false, reason: verify_result.nil? ? 'expired' : 'invalid' }
713
+ end
714
+
715
+ # Enforce config.requireSecure (mirrors the challenge-response flows and Triauth.check): an
716
+ # otherwise-valid envelope whose DNS chain was not reported DNSSEC-validated is rejected with
717
+ # the error dnssec_required, surfaced as {error} via the catch below.
718
+ ChallengeResponseFlow.assert_secure(verify_result[:secure], config)
719
+
720
+ Helpers.log(config, :info, 'Verification successful')
721
+
722
+ retval = Helpers.clone(verify_result[:signatures].length > 1 ? verify_result : verify_result[:signatures][0])
723
+
724
+ return retval
725
+ rescue StandardError => err
726
+ # A validation/parse error (one that carries a reason) means the envelope could not be parsed into a well-formed
727
+ # signature - a definitive negative verdict reported as {valid:false, reason:'malformed'}. Other
728
+ # TriauthErrors (e.g. dnssec_required from requireSecure, or a dns_unavailable DNS failure) surface as {error}.
729
+ if err.is_a?(Triauth::Error) && !err.reason.nil?
730
+ return { valid: false, reason: 'malformed' }
731
+ end
732
+
733
+ return Triauth::Error.process(err, config)
734
+ ensure
735
+ Helpers.log(config, :info, "Completed in #{(Helpers.now - ts).round}ms")
736
+ end
737
+ end
738
+ end
739
+ end
740
+ end