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
data/README.md CHANGED
@@ -1,11 +1,2040 @@
1
- # triauth (placeholder)
1
+ # ⠕ triauth (Ruby)
2
2
 
3
- This is a placeholder published by the [triauth project](https://www.triauth.org/)
4
- to reserve the package name and prevent supply-chain confusion.
3
+ **Sign in to websites with just your email address and no passwords.**<br/>
4
+ Actually you don't even need an email address, just a domain will do.
5
5
 
6
- It provides **no functionality** at the moment.
6
+ **triauth** is a new decentralized protocol for password-less single sign-on where the user's web browser and domain vouches for them.
7
+ Public keys are published as DNS TXT records under the user's own domain, and each login is signed by the web browser running on user's device and verified against those records.
7
8
 
8
- The reference implementation is in progress and will take over this package name.
9
+ Think of it as **DKIM but for web authentication**.
9
10
 
10
- * Learn more: https://www.triauth.org/
11
- * GitHub: https://github.com/triauth
11
+ Try it live in the **[playground](https://play.triauth.org)**, or read more on **[triauth.org](https://www.triauth.org/)**.
12
+
13
+ <hr/>
14
+
15
+ * This is the home of **official triauth Ruby client library**.
16
+ * Complete authentication flow is [about 30 lines of code](#synopsis).
17
+ * Add **decentralized, password-less single sign-on** to your website in an afternoon.
18
+
19
+ **Get started:**
20
+
21
+ ```shell
22
+ gem install triauth
23
+ ```
24
+
25
+ > 📦 Ships as a **gem**, runs on **Ruby 3.2+**, and has **zero runtime dependencies**.
26
+
27
+ See **[what you can do with it](#what-you-can-do-with-it)** and the **[public API reference](#public-api-reference)**.
28
+
29
+ ## Table of Contents
30
+
31
+ * [Status & Version](#status--version)
32
+ * [What you can do with it](#what-you-can-do-with-it)
33
+ * [Synopsis](#synopsis)
34
+ * [Ruby + Sinatra](#user-content-synopsis-node)
35
+ * [Browser based client-side authentication](#user-content-synopsis-browser)
36
+ * [Testing & Conformance](#testing--conformance)
37
+ * [Public API Reference](#public-api-reference)
38
+ * [The 3-step challenge-response flow](#user-content-3-step-challenge-response-flow)
39
+ * [Triauth.authenticate](#user-content-triauth-authenticate)
40
+ * [Triauth.check](#user-content-triauth-check)
41
+ * [Triauth.ping](#user-content-triauth-ping)
42
+ * [Triauth.whois](#user-content-triauth-whois)
43
+ * [Triauth.attest](#user-content-triauth-attest)
44
+ * [Triauth.sign](#user-content-triauth-sign)
45
+ * [Triauth.stamp](#user-content-triauth-stamp)
46
+ * [Triauth.verify](#user-content-triauth-verify)
47
+ * [Triauth.validate](#user-content-triauth-validate)
48
+ * [Error Codes](#error-codes)
49
+ * [Protocol Extensions](#protocol-extensions)
50
+ * [Configuration](#configuration)
51
+ * [Default configuration](#default-configuration)
52
+ * [Logging](#logging)
53
+ * [DNS Resolver](#dns-resolver)
54
+ * [Caching DNS responses](#caching-dns-responses)
55
+ * [Randomness](#randomness)
56
+ * [Privacy Considerations](#privacy-considerations)
57
+ * [Identifier enumeration and private mode](#identifier-enumeration-and-private-mode)
58
+ * [DNS resolvers](#dns-resolvers)
59
+ * [Best Practices and Security Considerations](#best-practices-and-security-considerations)
60
+
61
+ ## Status & Version
62
+
63
+ Latest release: **triauth-ruby 1.0.0.beta.4** - [RubyGems](https://rubygems.org/gems/triauth/versions/1.0.0.beta.4) - [GitHub](https://github.com/triauth/triauth-ruby/tags)<br/>
64
+
65
+ triauth-ruby is a language port of [triauth-js](https://github.com/triauth/triauth-js), the reference implementation of the protocol. It follows triauth-js very closely. Problems that are not specific to the Ruby library should be reported on the [triauth-js issues tracker](https://github.com/triauth/triauth-js/issues), so that the fix reaches every port.
66
+
67
+ It is suitable for evaluation and early production use - see [Testing & Conformance](#testing--conformance) for how it's validated, and read the [Best Practices and Security Considerations](#best-practices-and-security-considerations) section before you deploy.
68
+
69
+ We welcome your feedback on how we can make it better: report problems on the [issues tracker](https://github.com/triauth/triauth-ruby/issues), and security vulnerabilities by following the instructions in [SECURITY.md](https://github.com/triauth/triauth-js/blob/main/SECURITY.md). For the trust model behind the library see [triauth.org/protocol/trust-model](https://www.triauth.org/protocol/trust-model), for release history see [CHANGELOG.md](https://github.com/triauth/triauth-ruby/blob/main/CHANGELOG.md), and to learn more about the triauth protocol please visit [triauth.org](https://www.triauth.org/).
70
+
71
+ ## What you can do with it
72
+
73
+ triauth-ruby is a Swiss Army knife with a dedicated method for each job:
74
+
75
+ **🔓 Sign in** ([`authenticate`](#user-content-triauth-authenticate))
76
+ > Get a cryptographic proof that the user is in control of the `identifier` (e.g., `john@example.com`) and may be signed in with it.
77
+
78
+ **🔒 Sign out** ([`check`](#user-content-triauth-check), [`ping`](#user-content-triauth-ping))
79
+ > Confirm that the user may remain signed-in by checking that their keys were not revoked ([`check`](#user-content-triauth-check)), or by obtaining a fresh cryptographic proof of identity and key possession ([`ping`](#user-content-triauth-ping)).
80
+
81
+ **✍️ Sign** ([`sign`](#user-content-triauth-sign))
82
+ > Ask the user to review and sign your ToS or any message, with optional file attachments, and get back a transferable signature you can store, forward to a third party, or check yourself with [`verify`](#user-content-triauth-verify).
83
+
84
+ **🏅 Attest** ([`attest`](#user-content-triauth-attest))
85
+ > Collect third-party attestations about the user, such as "I am not a robot" or "I am over 18", each signed by a verification provider that you trust.
86
+
87
+ **🔖 Stamp** ([`stamp`](#user-content-triauth-stamp))
88
+ > Obtain a silent background signature over a short message, to prove to any third-party that the user is signed in with you.
89
+
90
+ **🔎 Look up & verify** ([`whois`](#user-content-triauth-whois), [`verify`](#user-content-triauth-verify), [`validate`](#user-content-triauth-validate))
91
+ > Read any identifier's public profile, devices, and keys ([`whois`](#user-content-triauth-whois)); verify any triauth signature or stamp on its own ([`verify`](#user-content-triauth-verify)); or pre-check an identifier's format with no DNS lookup ([`validate`](#user-content-triauth-validate)).
92
+
93
+ ## Synopsis
94
+
95
+ Authentication is two calls to the same method - one to start the login, and one to finish it:
96
+
97
+ ```ruby
98
+ require 'triauth'
99
+
100
+ # 1. Start: build a challenge and send the user off to approve it.
101
+ challenge, redirect_url = Triauth.authenticate({
102
+ identifier: 'john@example.com',
103
+ callbackUrl: 'https://yourapp.com/callback'
104
+ }).values_at(:challenge, :redirectUrl)
105
+ # → store `challenge` in the session, then redirect the browser to `redirect_url`
106
+
107
+ # 2. Finish: the user is redirected back with a `response`; verify it.
108
+ result = Triauth.authenticate({ challenge: challenge, response: response })
109
+ if result[:authenticated]
110
+ # → logged in as result[:identifier]
111
+ end
112
+ # delete the stored `challenge` from the session
113
+ ```
114
+
115
+ > Easy, right?
116
+ > That's the entire authentication flow you need to implement to get started.
117
+
118
+ Complete runnable examples below:
119
+
120
+ * [Ruby + Sinatra](#user-content-synopsis-node)
121
+ * [Browser based client-side authentication](#user-content-synopsis-browser)
122
+
123
+ <a name="synopsis-node"></a>
124
+
125
+ ### Ruby
126
+
127
+ ```shell
128
+ gem install triauth sinatra puma rackup
129
+ ```
130
+
131
+ <details>
132
+ <summary><b>Complete example</b> - a <a href="https://sinatrarb.com/">Sinatra</a> web application that implements "Sign in with triauth"</summary>
133
+
134
+ ```ruby
135
+
136
+ # frozen_string_literal: true
137
+
138
+ require 'triauth'
139
+
140
+ require 'sinatra'
141
+ require 'securerandom'
142
+
143
+ enable :sessions
144
+ set :session_secret, SecureRandom.hex(64) # replace with your own secret
145
+
146
+ get '/' do
147
+ if session[:identifier]
148
+ return "Logged in as #{session[:identifier]}"
149
+ end
150
+
151
+ error_message = session[:error] ? session[:error][:message] : ''
152
+ session[:error] = nil
153
+
154
+ <<~HTML
155
+ <form action="/login" method="post">
156
+ <input type="text" name="identifier" placeholder="Enter your identifier">
157
+ <span style="color:red;">#{error_message}</span>
158
+ <input type="submit" value="Sign in with triauth">
159
+ </form>
160
+ HTML
161
+ end
162
+
163
+ post '/login' do
164
+ # Start a sign-in only from this site's own pages, so that another site cannot sign the user in as an attacker
165
+ if request.env['HTTP_SEC_FETCH_SITE'] && request.env['HTTP_SEC_FETCH_SITE'] != 'same-origin'
166
+ halt 403
167
+ end
168
+
169
+ identifier = params[:identifier]
170
+
171
+ # Demo only: in production replace with your own static URL
172
+ # Do not trust request.host_with_port unless it is validated by your reverse proxy/framework config.
173
+ callback_url = "#{request.scheme}://#{request.host_with_port}/callback"
174
+
175
+ auth_result = Triauth.authenticate({
176
+ identifier: identifier,
177
+ callbackUrl: callback_url,
178
+ ext: { callbackMethod: 'GET' } # a GET callback brings the session cookie back with it
179
+ })
180
+
181
+ if auth_result[:challenge] && auth_result[:redirectUrl]
182
+ session[:challenge] = auth_result[:challenge]
183
+ redirect auth_result[:redirectUrl], 302
184
+ else
185
+ session[:error] = auth_result[:error]
186
+ redirect '/', 302
187
+ end
188
+ end
189
+
190
+ get '/callback' do
191
+ challenge = session[:challenge]
192
+ response = params[:response]
193
+
194
+ session.delete(:challenge)
195
+
196
+ auth_result = Triauth.authenticate({
197
+ challenge: challenge,
198
+ response: response
199
+ })
200
+
201
+ if auth_result[:authenticated]
202
+ session[:identifier] = auth_result[:identifier]
203
+ else
204
+ session[:error] = auth_result[:error]
205
+ end
206
+
207
+ redirect '/', 302 # always redirect, so that the response does not stay in the address bar
208
+ end
209
+
210
+ set :port, 3000 # `ruby app.rb` serves on port 3000; `rackup -p 3000` serves through config.ru
211
+
212
+ ```
213
+
214
+ </details>
215
+
216
+ <a name="synopsis-browser"></a>
217
+
218
+ ### Web Browser
219
+
220
+ triauth-ruby runs on the server. For browser based client-side authentication use [triauth-js](https://github.com/triauth/triauth-js#user-content-synopsis-browser), which runs in the browser.
221
+
222
+ [Back to TOC](#table-of-contents)
223
+
224
+ <a name="testing--conformance"></a>
225
+
226
+ ## Testing & Conformance
227
+
228
+ triauth-ruby currently has **over 2,000 tests for correctness and security**.
229
+
230
+ The reference implementation it ports, function for function, was meticulously hand-crafted over many months, then systematically reviewed and tested to hunt down security issues and footguns.
231
+ No software is free of bugs - if you find one, please report it.
232
+
233
+ * **High test coverage** Enforced minimums of 95% line and branch coverage.
234
+ * **Public availability** The code is open, freely available on GitHub, and throughtly documented (YARD). Feel free to review it yourself or feed it to your favourite LLM for quick check.
235
+ * **AI security reviews.** In addition to human review, we have put the protocol and every public API method through a structured, high-effort security review by AI models. Reviews are committed under [`test/ai-reviews/`](https://github.com/triauth/triauth-ruby/tree/main/test/ai-reviews).
236
+ * **Shared conformance suites.** The nine JSON conformance suites under `test/fixtures/json/` are shared byte for byte with triauth-js, so this port and the reference implementation are checked against the same cases.
237
+
238
+ See the [trust model](https://www.triauth.org/protocol/trust-model) and [Best Practices and Security Considerations](#best-practices-and-security-considerations) for the security reasoning behind these tests, and [`CHANGELOG.md`](https://github.com/triauth/triauth-ruby/blob/main/CHANGELOG.md) for release history.
239
+
240
+ [Back to TOC](#table-of-contents)
241
+
242
+ ## Public API Reference
243
+
244
+ Following methods are available:
245
+
246
+ * [Triauth.authenticate](#user-content-triauth-authenticate)
247
+ * [Triauth.check](#user-content-triauth-check)
248
+ * [Triauth.ping](#user-content-triauth-ping)
249
+ * [Triauth.whois](#user-content-triauth-whois)
250
+ * [Triauth.attest](#user-content-triauth-attest)
251
+ * [Triauth.sign](#user-content-triauth-sign)
252
+ * [Triauth.stamp](#user-content-triauth-stamp)
253
+ * [Triauth.verify](#user-content-triauth-verify)
254
+ * [Triauth.validate](#user-content-triauth-validate)
255
+
256
+ ---
257
+
258
+ <a name="3-step-challenge-response-flow"></a>
259
+
260
+ > [!TIP]
261
+ > Most API methods use the following 3-step challenge-response flow:
262
+ >
263
+ > 1. **Request:** <br/>
264
+ > Call the method with `identifier` (string), `callbackUrl` (string), and any other required parameters. You'll receive a `challenge` (string) and `redirectUrl` (string).
265
+ > 2. **Redirect:** <br/>
266
+ > Securely store the `challenge` and redirect the user's browser to `redirectUrl`. The stored `challenge` also contains the key that decrypts the `response`. Keep it out of URLs and logs.
267
+ > 3. **Verify:** <br/>
268
+ > The user's browser brings an encrypted `response` back to your `callbackUrl`, by default as a URL query parameter (see [callbackMethod](#protocol-extensions)). Pass both `challenge` and `response` back to the original method to complete the flow.
269
+
270
+ <a name="preserve-referrer"></a>
271
+
272
+ > [!IMPORTANT]
273
+ > **Preserve the [referrer](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referer) when you redirect to `redirectUrl`.**
274
+ > The authenticator **may** reject a redirect that arrives without one, so the navigation to `redirectUrl` must carry a `Referer` http header.
275
+ > The bare origin (e.g. `https://example.com`) is enough.
276
+ >
277
+ > The browser's default policy (`strict-origin-when-cross-origin`) already sends the origin across a cross-origin redirect, so you usually need to do nothing - just don't suppress it. In particular, do **not** use `rel="noreferrer"` (or `window.open(…, 'noreferrer')`, or a `no-referrer` / `same-origin` `Referrer-Policy`). Watch out for hardening middleware too: [Helmet](https://helmetjs.github.io/), for example, sets `Referrer-Policy: no-referrer` by default.
278
+
279
+ ---
280
+
281
+ <a name="triauth-authenticate"></a>
282
+
283
+ ### 👉 Triauth.authenticate `({identifier, callbackUrl, challenge, response, ext})`
284
+
285
+ The authenticate method performs a challenge-response authentication based on the triauth protocol.
286
+ Successful authentication confirms that the user is in control of the provided identifier, and may be e.g., logged in to your website with it.
287
+
288
+ The authentication is based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
289
+
290
+ **1. Request**
291
+
292
+ At first, you need to call the `Triauth.authenticate` method with the `identifier` option set to the user provided personal identifier (lowercase string),
293
+ a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
294
+ and an optional `ext` option (object) listing the [protocol extensions](#protocol-extensions) that you wish to use.
295
+
296
+ In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
297
+ 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`.
298
+
299
+ Synopsis:
300
+
301
+ ```ruby
302
+
303
+ result = Triauth.authenticate({ identifier: identifier, callbackUrl: callback_url, ext: ext }) # ext is optional
304
+ # @returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
305
+ # @returns {error:{code, message}} when the input is invalid, DNS resolution fails, or the domain is not configured for triauth
306
+
307
+ ```
308
+
309
+ <details>
310
+ <summary><b>Example</b></summary>
311
+
312
+ ```ruby
313
+ Triauth.authenticate(
314
+ {
315
+ identifier: 'john@triauthdemo.org',
316
+ callbackUrl: 'https://example.com/triauth-callback'
317
+ }
318
+ )
319
+ # {
320
+ # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJhdXRoIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJTSWRJaXZRRTVJbkhHQmFUWGhDb0UxMG8iLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCQXZJbDlWVDJ3Z3JoS2FBMmw3MTFhWmIxSVJlWURkX0UtdVRfcDl2VnNzZndqWG5VWjVNcVhjRTNmSUNCRlpmOElFeXJoUXVmX0F0ODlSaHBZMFlScUUifQ.-rkB18wDNcYxPU7jK3Lb8nwmQcV-fhJ-1nLCxdDgEzE',
321
+ # redirectUrl: 'https://auth.triauthdemo.org/auth.html#?challenge=eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJhdXRoIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJTSWRJaXZRRTVJbkhHQmFUWGhDb0UxMG8iLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCQXZJbDlWVDJ3Z3JoS2FBMmw3MTFhWmIxSVJlWURkX0UtdVRfcDl2VnNzZndqWG5VWjVNcVhjRTNmSUNCRlpmOElFeXJoUXVmX0F0ODlSaHBZMFlScUUifQ'
322
+ # }
323
+ ```
324
+
325
+ </details>
326
+
327
+ **2. Redirect**
328
+
329
+ Once the user's web browser is redirected to the `redirectUrl`, they will be taken to the Triauth Authenticator web application, where they can approve or reject the authentication request.
330
+
331
+ If the user approves the authentication request, Triauth Authenticator signs the challenge using one or more private keys that are available to it, and for which matching public keys may be obtained from the identity records stored in the DNS, associated with the user's `identifier`.
332
+ It then redirects the web browser to the `callbackUrl`, by default using a `HTTP GET` method, and passes the signature, together with optional additional data, inside the encrypted `response` URL parameter.
333
+ The callback method that is used to pass the response to the client application can be configured with the [callbackMethod extension](#protocol-extensions).
334
+
335
+ If the user declines the authentication request, the Triauth Authenticator sends `false` as the `response`, which `Triauth.authenticate` reports as the error `request_denied`.
336
+
337
+ **3. Verify**
338
+
339
+ Once you receive the callback request under `callbackUrl` and extract the `response` (string), you should pass it to the `Triauth.authenticate` function together with the previously stored `challenge` (string) to obtain authentication result.
340
+ 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`.
341
+
342
+ > [!WARNING]
343
+ > **Invalidate the challenge after every authentication attempt.**
344
+ > Upon authentication attempt, remove, invalidate, or forget the associated `challenge`,
345
+ > so that it cannot be re-used in replay attacks.
346
+
347
+ Synopsis:
348
+
349
+ ```ruby
350
+
351
+ authentication_result = Triauth.authenticate({ challenge: challenge, response: response })
352
+ # @returns {authenticated:true, issuedAt, signedAt, verifiedAt, expires, secure, identifier, identityDomain, lookupCode, actor, actorIdentityDomain, actorLookupCode, publicProfile, groups, deviceName, deviceTag, keys, sessionKey, ext} upon successful authentication
353
+ # @returns {error:{code, message}} upon failed authentication
354
+
355
+ ```
356
+
357
+ <details>
358
+ <summary><b>Example</b></summary>
359
+
360
+ ```ruby
361
+ Triauth.authenticate(
362
+ {
363
+ challenge:'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJhdXRoIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJTSWRJaXZRRTVJbkhHQmFUWGhDb0UxMG8iLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCQXZJbDlWVDJ3Z3JoS2FBMmw3MTFhWmIxSVJlWURkX0UtdVRfcDl2VnNzZndqWG5VWjVNcVhjRTNmSUNCRlpmOElFeXJoUXVmX0F0ODlSaHBZMFlScUUifQ.-rkB18wDNcYxPU7jK3Lb8nwmQcV-fhJ-1nLCxdDgEzE',
364
+ response:'BNh1cQjNAGfM2AT7Kee_wcSpCMPmf393GaP5h1bOOFgrH58q9CIgGz0ChLLBzLGOUGbZB46FIgY8CPljfgKTNj4.DJguMmSAhafOTygveRsBQlHfpOcc4haXughtMSANoZO3ra2IIpYviH6H4ErvaqeRb_bEBu2e0URsJUjsA4iJ1Fu_2MMnn80zA-slH6XIsReM6i7m7a74iU_hmDmUMaL6KkwDM_LVJQw71pR1qnSLEE9EWOHHe8sqOT8t42S4TX0fhmzZFrlPm969B2eopvMFvJ0HMQ0I1USB6XB-lT0arzJt9UQB6XKsi2Dm'
365
+ }
366
+ )
367
+ #
368
+ # {
369
+ # authenticated: true,
370
+ # issuedAt: 1787224748449,
371
+ # signedAt: 1787224766683,
372
+ # verifiedAt: 1787224766784,
373
+ # expires: 1787226566784,
374
+ # secure: true,
375
+ # identifier: "john@triauthdemo.org",
376
+ # identityDomain: "john._at.triauthdemo.org",
377
+ # lookupCode: "",
378
+ # actor: "",
379
+ # actorIdentityDomain: "",
380
+ # actorLookupCode: "",
381
+ # publicProfile: {
382
+ # initials: "JD",
383
+ # name: "John Doe"
384
+ # },
385
+ # groups: [],
386
+ # deviceName: "desktop",
387
+ # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
388
+ # keys: [
389
+ # {
390
+ # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
391
+ # options: {
392
+ # type: "es256",
393
+ # use: "attest,auth,ping,sign,stamp"
394
+ # },
395
+ # verified: true,
396
+ # skipped: false
397
+ # }
398
+ # ],
399
+ # sessionKey: "59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8",
400
+ # ext: {}
401
+ # }
402
+ #
403
+ ```
404
+
405
+ </details>
406
+
407
+ > [!IMPORTANT]
408
+ > Upon successful authentication, the authentication result object will have the `authenticated` property set to `true`, and the authenticated personal identifier available in the `identifier` property.
409
+ >
410
+ > Upon failed authentication, the `authenticated` property will be missing or set to a falsy value, and the `error` property will contain [more details about the encountered problem](#error-codes).
411
+
412
+ When authentication is successful, following properties are included in the authentication result object:
413
+
414
+ | Property Name | Type | Description |
415
+ |-----------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
416
+ | `authenticated` | `boolean` | Always `true` when authentication is successful. |
417
+ | `issuedAt` | `number` | Unix timestamp on **your** server's clock at which you issued the challenge in stage 1 - the start of the flow's milestone timeline (`issuedAt` → `signedAt` → `verifiedAt`). Example: `1787224748449`. |
418
+ | `signedAt` | `number` | Unix timestamp on the **user's device** clock at which the authenticator signed the response. It is verified to fall within the freshness window, but is user-device-asserted. Example: `1787224766683`. |
419
+ | `verifiedAt` | `number` | Unix timestamp on **your** server's clock at which this library confirmed the signature. Example: `1787224766784`. |
420
+ | `expires` | `number` | If present, the Unix timestamp until which the DNS records backing this authentication are considered fresh. Treat it as a re-verification hint rather than a session or authorization lifetime. After the expires timestamp has passed, you can use the `Triauth.check` or `Triauth.ping` methods to re-authenticate user. Please note that the returned `expires` value may be `nil` or arbitrarily low. You should implement your own reasonable minimum limits for how quickly or frequently the `Triauth.check` and/or `Triauth.ping` method is called. Example: `1767788894690`. |
421
+ | `secure` | `boolean` | Indicates whether DNSSEC protocol was used and the received DNS responses were properly signed. Many domains do not support DNSSEC yet, and will report `{secure:false}`. For more details, please see the [Best Practices and Security Considerations](#best-practices-and-security-considerations) section. Example: `true`. |
422
+ | `identifier` | `string` | Contains the user identifier that has been authenticated. Example: `'john@example.com'`. |
423
+ | `identityDomain` | `string` | Contains the domain name under which identity records for the given identifier are stored. Examples: `'john._at.example.com'`, `'_7CXNKJIB._at.example.com'`. |
424
+ | `lookupCode` | `string` | For `mode=private` domains the identity lookup code that you can use e.g., with `Triauth.whois({identifier, lookupCode})`. Empty string if lookup code is not needed. |
425
+ | `actor` | `string` | For delegated authentication (i.e., when the owner of identifier has published an `include` identity record in DNS granting a delegate the right to act on their behalf) contains the identifier of the delegate that actually performed the authentication; for direct authentication (the user's own keys) it is `''`. Example: `assistant@corp.example`. |
426
+ | `actorIdentityDomain` | `string` | For delegated authentication, contains the domain name under which the delegate's identity records are stored; for direct authentication (the user's own keys) it is `''`. Example: `'assistant._at.corp.example'`. |
427
+ | `actorLookupCode` | `string` | For delegated authentication, and `mode=private` actor domains the identity lookup code that you can use e.g., with `Triauth.whois({identifier: actorIdentifier, lookupCode: actorLookupCode})`. Empty string if actor is not set or lookup code is not needed to resolve actor's identity domain. |
428
+ | `publicProfile` | `object` | Contains an object with additional, publicly available information associated with the identifier, as read from the DNS. Example: `{initials:'JD'}`. |
429
+ | `groups` | `array` | Fully-qualified group names the identity's DNS records claim membership in, sorted and deduplicated; `[]` when none are published. Group membership changes surface through `Triauth.check`, which returns the current list. Example: `['admins@example.com']`. |
430
+ | `deviceName` | `string` | Contains a user-given, public name of the device that was used for authentication, as read from the DNS key records. Its value is informational only, and `deviceTag` should be used to uniquely identify devices. Example: `'mylaptop'`. |
431
+ | `deviceTag` | `string` | Contains a string (up to 255 ASCII characters) that uniquely identifies a device, lookup code (if used), and delegation (when `actor` is present), that was used for authentication. A change in the `deviceTag` may be used to identify a situation when user is logging in from a different or new device. You can use the value of `deviceTag` with the `Triauth.check` function to determine if keys are still present in the DNS and the device and/or associated session may remain authenticated. Examples: `'VpGPcGr5psnmgaq4mPxY5tZ4Adxa5V2Vh1GFVRfQFmk'` (direct authentication), `'VpGPcGr5psnmgaq4mPxY5tZ4Adxa5V2Vh1GFVRfQFmk~K7QJ3FB9M2WZX0C4'` (private-mode authentication). |
432
+ | `keys` | `array` | Contains an array of objects, each representing a public key as read from identity records in the DNS, that was taken into account during authentication. Example: `[{value: '…', options: {…}, verified: true}]`. |
433
+ | `sessionKey` | `string` | A secret of this sign-in, in 43 base64url characters, that only your application and the user's authenticator know. Pass it to `Triauth.ping`, `Triauth.sign`, `Triauth.stamp` and `Triauth.attest`. Store it with the user's server-side or encrypted session, and keep it out of logs. After a delegated sign-in, it starts with the actor's domain and a colon. |
434
+ | `ext` | `object` | Contains additional data related to the requested [triauth protocol extensions](#protocol-extensions), or `{}` when none was returned. Example: `{permissions:{ping:true, stamp:false}}`. |
435
+
436
+ [Back to TOC](#table-of-contents)
437
+
438
+ ---
439
+
440
+ <a name="triauth-check"></a>
441
+
442
+ ### Triauth.check `({identifier, deviceTag})`
443
+
444
+ The check method verifies that the user's public keys and delegation associated with the given `deviceTag` are still present and unchanged in the Domain Name System (DNS), and that the user may remain authenticated.
445
+ It also returns the user's current group memberships, allowing the application to detect membership changes.
446
+
447
+ A key may be removed from the DNS by the user or their organization when, for example, an associated device is stolen, decommissioned, or the user leaves the organization.
448
+ Similarly, a delegation may be revoked when the user no longer allows a particular actor to act on their behalf.
449
+ Group membership changes may affect the user's access to your application features or resources.
450
+
451
+ This method may be periodically called with the `identifier` and `deviceTag` as returned by the `Triauth.authenticate` method.
452
+
453
+ Synopsis:
454
+
455
+ ```ruby
456
+
457
+ check_result = Triauth.check({ identifier: identifier, deviceTag: device_tag })
458
+ # @returns {valid:true, secure, expires, groups} keys are still present in the DNS; `expires` (when a number) is the unix timestamp until which the check method need not be called again; `expires` may be `nil` when DNS records carry no TTL (every built-in resolver reports one, SystemDns included; a custom resolver may not) - in that case apply your own minimum re-check interval; secure is true only when the domain's `triauth` configuration record and every well-formed record of the identity answer were DNSSEC-validated; `groups` is the identity's current membership list - replace any session-cached groups with it on every check
459
+ # @returns {valid:false, reason} when the device should be logged out; reason is 'revoked' (the device's keys are no longer published) or 'unresolved' (the identity itself no longer resolves)
460
+ # @returns {error:{code, message}} when status cannot be determined at the moment due to some error
461
+
462
+ ```
463
+
464
+ <details>
465
+ <summary><b>Example</b></summary>
466
+
467
+ ```ruby
468
+ Triauth.check(
469
+ {
470
+ identifier: "john@triauthdemo.org",
471
+ deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ"
472
+ }
473
+ )
474
+ #
475
+ # {
476
+ # valid: true,
477
+ # secure: true,
478
+ # expires: 1737547197157,
479
+ # groups: []
480
+ # }
481
+ #
482
+ ```
483
+
484
+ </details>
485
+
486
+ <details>
487
+ <summary><b>Result properties</b></summary>
488
+
489
+ The result object has the following properties:
490
+
491
+ | Property Name | Type | Description |
492
+ |---------------|-----------|-------------|
493
+ | `valid` | `boolean` | `true` when the keys and the delegation behind `deviceTag` are still published and the device may remain authenticated. `false` when the device must be signed out. |
494
+ | `reason` | `string` | Only when `valid` is `false`. `'revoked'` means that the keys of the device, or its delegation, are no longer published. `'unresolved'` means that the identifier itself no longer resolves. A `deviceTag` outside the format of a device tag is reported as `'revoked'` without a DNS lookup. |
495
+ | `secure` | `boolean` | Only when `valid` is `true`. `true` only when DNSSEC protected every DNS record that this result depends on. |
496
+ | `expires` | `number` | Only when `valid` is `true`. Unix timestamp until which the DNS records behind this result are considered fresh, so that you need not call `Triauth.check` again before it. It may be `nil` when the records have no TTL, or arbitrarily low. Apply your own minimum re-check interval. Example: `1737547197157`. |
497
+ | `groups` | `array` | Only when `valid` is `true`. The current fully-qualified group names that the identity records of `identifier` claim membership in, sorted and deduplicated. Replace any session-cached groups with this list on every check. Example: `['admins@example.com']`. |
498
+
499
+ </details>
500
+
501
+ [Back to TOC](#table-of-contents)
502
+
503
+ ---
504
+
505
+ <a name="triauth-ping"></a>
506
+
507
+ ### Triauth.ping `({identifier, callbackUrl, sessionKey, challenge, response, ext})`
508
+
509
+ The `ping` method may be used to re-authenticate the user in background, by obtaining a fresh proof that the user still has access to cryptographic keys.
510
+ While the `Triauth.check` method ensures only that the keys and the optional delegation used for authentication were not changed or removed from DNS,
511
+ `Triauth.ping` goes a step further, and requires a fresh, valid signature.
512
+
513
+ Pings typically do not require any interaction from the user, and may be used as one of the signals in Continuous Authentication schemes.
514
+
515
+ > [!NOTE]
516
+ > **Get the `ping` permission and use the same base callbackUrl as for authentication**
517
+ >
518
+ > <details>
519
+ > <summary>Details</summary>
520
+ >
521
+ > To get the permission, provide the `ext:{permissions:{ping:true}}` parameter to the `Triauth.authenticate` function during the initial call (i.e., when building the challenge).
522
+ > The authentication result shows the decision in `ext[:permissions]`. You can call `ping` only when `ext.dig(:permissions, :ping) == true` in the result.
523
+ >
524
+ > 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.
525
+ > For example, if `callbackUrl` used for authentication was `https://example.com/dir1/callback`, the `callbackUrl` used in this method
526
+ > 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).
527
+ >
528
+ > </details>
529
+
530
+ The ping is based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
531
+
532
+ **1. Request**
533
+
534
+ At first, you need to call the `Triauth.ping` method with the `identifier` option set to the user provided personal identifier (lowercase string),
535
+ a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
536
+ a `sessionKey` (string) option set to the `sessionKey` of a `Triauth.authenticate` result,
537
+ and an optional `ext` option (object) listing the [protocol extensions](#protocol-extensions) that you wish to use.
538
+
539
+ In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
540
+ 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`.
541
+
542
+ Synopsis:
543
+
544
+ ```ruby
545
+
546
+ result = Triauth.ping({ identifier: identifier, callbackUrl: callback_url, sessionKey: session_key, ext: ext })
547
+ # @returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
548
+ # @returns {error} on error
549
+
550
+ ```
551
+
552
+ <details>
553
+ <summary><b>Example</b></summary>
554
+
555
+ ```ruby
556
+
557
+ Triauth.ping(
558
+ {
559
+ identifier:'john@triauthdemo.org',
560
+ callbackUrl:'https://example.com/callback',
561
+ sessionKey:'59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8'
562
+ }
563
+ )
564
+ #
565
+ # {
566
+ # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vY2FsbGJhY2siLCJ0eXBlIjoicGluZyIsImlkZW50aWZpZXIiOiJqb2huQHRyaWF1dGhkZW1vLm9yZyIsIm5vbmNlIjoicWFvU1JnZlVnREpwZG5LX1FnQTd4SG9ZIiwiaWF0IjoxNzg3MjI0NzQ4NDQ5LCJ2ZXIiOjEsImVrIjoiQkVoLTJGR0JOUkUyUGZvSTVsWjh4eU9KX2IzaTlaRVFadGtQOGJVajJ6VWljWDdsaTdqalBvYzVmUnczUlZKb2tjb1BZdVRLdGdGaVVENlR1LURrM3I0In0.urITupCNu0ZNV4CF692qaZs6UaBb5LNov-Z9hhB5ArU',
567
+ # redirectUrl: 'https://auth.triauthdemo.org/ping.html#?skid=wzI1sz3n1R4biw4FL_VJ3Q&request=aTO6cylS_52etdEH.hAskvAG-kqxradXNXvr3S4yxpgI3c6EdBOWOnN3UP4CjEfhkyQ1vbcYTmYQ_L3UOtIm6VwPYczazFTW7G7mXByWzAITIVkTRaQHlwyOj9CkPiOP278pc8pQdjPsCKmVHEPSqfwYlnUvNOOp_dsVRgb1zKT2QEkWFnCAH1CUvxDb4bpYqQ_YFa-q48WjkC9cDXRSKRgpN51iwPGV-koSmoIwgYLghNpUC4Cvx72ctYq1tLyP6jrXa3dS8VrSZgy5D4EFwYx22VHg1LloHgDc730QN560wpD7yGL8ylk0eENLml357QTLextpeqZ4Q_MG6JEg0fsN1XdNduNlsiQd_GfTiVwLnaXh3'
568
+ # }
569
+ #
570
+ ```
571
+
572
+ </details>
573
+
574
+ **2. Redirect**
575
+
576
+ Once the user's web browser is redirected to the `redirectUrl`, the Triauth Authenticator web application immediately responds to the `callbackUrl`. This needs a valid session key with the `ping` permission.
577
+
578
+ 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 fresh signature in the encrypted `response` URL parameter.
579
+ The method that is used to pass the response to your application can be configured with the [callbackMethod extension](#protocol-extensions).
580
+
581
+ **3. Verify**
582
+
583
+ Once you extract the `response` (string), you should pass it to the `Triauth.ping` function together with the previously stored `challenge` (string) to obtain ping result.
584
+ 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`.
585
+ Only a `pinged:true` result proves that the user still has the keys. An `error` result, `request_denied` included, can come from anyone who sends a `response` to your `callbackUrl`. Treat it as a failed ping and not as the user's decision.
586
+
587
+ Synopsis:
588
+
589
+ ```ruby
590
+
591
+ ping_result = Triauth.ping({ challenge: challenge, response: response })
592
+ # @returns {pinged:true, issuedAt, signedAt, verifiedAt, expires, secure, identifier, identityDomain, lookupCode, actor, actorIdentityDomain, actorLookupCode, groups, deviceName, deviceTag, keys} upon success
593
+ # @returns {error:{code, message}} upon error
594
+
595
+ ```
596
+
597
+ <details>
598
+ <summary><b>Example</b></summary>
599
+
600
+ ```ruby
601
+ Triauth.ping(
602
+ {
603
+ challenge:'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vY2FsbGJhY2siLCJ0eXBlIjoicGluZyIsImlkZW50aWZpZXIiOiJqb2huQHRyaWF1dGhkZW1vLm9yZyIsIm5vbmNlIjoicWFvU1JnZlVnREpwZG5LX1FnQTd4SG9ZIiwiaWF0IjoxNzg3MjI0NzQ4NDQ5LCJ2ZXIiOjEsImVrIjoiQkVoLTJGR0JOUkUyUGZvSTVsWjh4eU9KX2IzaTlaRVFadGtQOGJVajJ6VWljWDdsaTdqalBvYzVmUnczUlZKb2tjb1BZdVRLdGdGaVVENlR1LURrM3I0In0.urITupCNu0ZNV4CF692qaZs6UaBb5LNov-Z9hhB5ArU',
604
+ response:'BGb9tY40nX1dXVl8KlCg8c6mSThXTM2UPWvkN_tHT8h05FIbypseeOplJNXLRH3mVtiTT4YarHDUVT5OqmHziOc.AV7MibGdeVKtc2hBh9BfpeB48dxc1xwu1bv2LtfvmHNEXUf_J_g0j3Njndh-_wiUI2sF2s_b8g9WJe5SewgFblFvdKIz6rXNL6SeCZsQeZhIvNSAvMtXgAHJtybBU5MmQJhx0zqh0Rf7Osuju8bceqMHFzuY9083btLzpU6iCbJeKGXqnwAMyCVOOwCDkJgCVRE_Mb2wADTsFpIUiQAQX-k6q0qAaBbZ1bec'
605
+ }
606
+ )
607
+
608
+ #
609
+ # {
610
+ # pinged: true,
611
+ # issuedAt: 1787224748449,
612
+ # signedAt: 1787224751161,
613
+ # verifiedAt: 1787224751262,
614
+ # expires: 1787226551262,
615
+ # secure: true,
616
+ # identifier: "john@triauthdemo.org",
617
+ # identityDomain: "john._at.triauthdemo.org",
618
+ # lookupCode: "",
619
+ # actor: "",
620
+ # actorIdentityDomain: "",
621
+ # actorLookupCode: "",
622
+ # groups: [],
623
+ # deviceName: "desktop",
624
+ # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
625
+ # keys: [
626
+ # {
627
+ # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
628
+ # options: {
629
+ # type: "es256",
630
+ # use: "attest,auth,ping,sign,stamp"
631
+ # },
632
+ # verified: true,
633
+ # skipped: false
634
+ # }
635
+ # ]
636
+ # }
637
+ #
638
+ ```
639
+
640
+ </details>
641
+
642
+ Upon successful pinging, the result object will have the `pinged` property set to `true`.
643
+ You can also check that the returned `identifier`, `deviceTag`, and `secure` values match your expectations.
644
+ Note that `expires` may be `nil` or arbitrarily low - apply your own minimum re-ping interval, as described for `Triauth.authenticate`.
645
+
646
+ Upon failed pinging, the `pinged` property will be missing or set to a falsy value, and the `error` property will contain [more details about the encountered problem](#error-codes).
647
+
648
+ > [!NOTE]
649
+ > Users may configure a different set of cryptographic keys (that still belongs to the same device) for the purpose of responding to ping requests.
650
+ > You can assume that the `deviceTag` in `ping` result should be the same as in the original `Triauth.authenticate` response, but the combination of verified/skipped keys may differ.
651
+
652
+ <details>
653
+ <summary><b>Result properties</b></summary>
654
+
655
+ When `pinged` is `true`, the result object has the following properties. They have the same meaning as in the [`Triauth.authenticate` result](#user-content-triauth-authenticate).
656
+
657
+ | Property Name | Type | Description |
658
+ |-----------------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
659
+ | `pinged` | `boolean` | Always `true` when the ping is successful. |
660
+ | `issuedAt` | `number` | Unix timestamp on **your** server's clock at which you issued the challenge in stage 1. Example: `1787224748449`. |
661
+ | `signedAt` | `number` | Unix timestamp on the **user's device** clock at which the authenticator signed the response. Example: `1787224751161`. |
662
+ | `verifiedAt` | `number` | Unix timestamp on **your** server's clock at which this library confirmed the signature. Example: `1787224751262`. |
663
+ | `expires` | `number` | Unix timestamp until which the DNS records behind this result are considered fresh. It may be `nil` when the records have no TTL, or arbitrarily low. Apply your own minimum re-ping interval. Example: `1787226551262`. |
664
+ | `secure` | `boolean` | `true` only when DNSSEC protected every DNS record that this result depends on. |
665
+ | `identifier` | `string` | The identifier that responded to the ping. It is the same as in the challenge. Example: `'john@example.com'`. |
666
+ | `identityDomain` | `string` | The domain name under which the identity records of `identifier` are stored. Example: `'john._at.example.com'`. |
667
+ | `lookupCode` | `string` | The lookup code of `identifier` under `mode=private`. `''` when no lookup code is needed. |
668
+ | `actor` | `string` | For a delegated ping, the identifier of the actor that used their own keys on behalf of `identifier`. `''` when the user's own keys were used. Example: `'assistant@corp.example'`. |
669
+ | `actorIdentityDomain` | `string` | For a delegated ping, the domain name under which the identity records of `actor` are stored. `''` when there is no actor. |
670
+ | `actorLookupCode` | `string` | For a delegated ping, the lookup code of `actor` under `mode=private`. `''` when there is no actor or no lookup code is needed. |
671
+ | `groups` | `array` | Fully-qualified group names that the identity records of `identifier` claim membership in, sorted and deduplicated. `[]` when none are published. Example: `['admins@example.com']`. |
672
+ | `deviceName` | `string` | The user-given name of the device that signed, as read from the key records. Informational only. Example: `'desktop'`. |
673
+ | `deviceTag` | `string` | The tag that identifies the device that signed, together with its lookup code and its delegation. It must be the same as the `deviceTag` from the `Triauth.authenticate` result. |
674
+ | `keys` | `array` | The public keys of the device that signed, as read from the key records. Each key has a `value`, its `options`, a `verified` flag, and a `skipped` flag. `verified` is `true` when the key matched the signature. `skipped` is `true` when the key's `use` option excludes this type of signature. Every key is one or the other. |
675
+
676
+ </details>
677
+
678
+ [Back to TOC](#table-of-contents)
679
+
680
+ ---
681
+
682
+ <a name="triauth-whois"></a>
683
+
684
+ ### Triauth.whois `({identifier, lookupCode?})`
685
+
686
+ The whois method checks whether a given identifier exists and retrieves the public details stored in its identity records.
687
+ For domains operating in `mode=private`, the `lookupCode` is required to look up the identity records.
688
+
689
+ Upon success, it returns a Hash with a `status` property of:
690
+ `-1` if domain does not exist or is not configured for triauth,
691
+ `0` if domain is configured but identifier does not exist or could not be located (e.g., a missing or wrong `lookupCode` under `mode=private`),
692
+ and `1` if identifier exists.
693
+
694
+ On error, it returns an `{error: {code, message}}` object instead.
695
+
696
+ Synopsis:
697
+
698
+ ```ruby
699
+
700
+ whois_result = Triauth.whois({ identifier: identifier })
701
+ whois_result = Triauth.whois({ identifier: identifier, lookupCode: lookup_code }) # for an identity under a mode=private domain
702
+ # @returns {error: {code: string, reason?: string, message: string}} upon error
703
+ # @returns {status:1, identifier, authenticationEndpoint:{…}, identityDomain, secure, publicProfile:{…}, groups:[…], devices:[…], includes:[…]} if identifier exists
704
+ # @returns {status:0, identifier, authenticationEndpoint:{…}, identityDomain} if domain is configured for triauth but the identifier does not exist or, for mode=private domains, a missing or wrong lookupCode was supplied
705
+ # @returns {status:-1, identifier} if domain is not configured for triauth
706
+ ```
707
+
708
+ <details>
709
+ <summary><b>Example</b></summary>
710
+
711
+ ```ruby
712
+ Triauth.whois({identifier: 'john@triauthdemo.org'})
713
+ # (output for the conformance suite's zone - the public demo zone publishes keys of its own)
714
+ # {
715
+ # status: 1,
716
+ # identifier: "john@triauthdemo.org",
717
+ # authenticationEndpoint: {
718
+ # url: "https://auth.triauthdemo.org/",
719
+ # options: {
720
+ # mode: "public",
721
+ # include: "any"
722
+ # },
723
+ # secure: true
724
+ # },
725
+ # identityDomain: "john._at.triauthdemo.org",
726
+ # secure: true,
727
+ # publicProfile: {
728
+ # initials: "JD",
729
+ # name: "John Doe"
730
+ # },
731
+ # groups: [],
732
+ # devices: [
733
+ # {
734
+ # deviceName: "laptop",
735
+ # deviceTag: "5I4NnX-4lW3gdTqGW3zKpLsLR9oRiWip8zp6mqQgkL4",
736
+ # keys: [
737
+ # {
738
+ # value: "BCkB_Cr-7pvY1Y2buYRksJPb09Tqld7M3SDII36cp_C7k08NPTWvbj0p14oMBGDGbGZ1qLkfnJSARBbAj1sA0fs",
739
+ # options: {
740
+ # type: "es256",
741
+ # use: "attest,auth,ping,sign,stamp"
742
+ # }
743
+ # },
744
+ # {
745
+ # value: "BLglw14iw47KUztRHdeKNfBBBMf5P4SsZg9O9za44mO2BceHZlcQwFmtO1crjVNnwwZszqtMhErs3divGeQrQI4",
746
+ # options: {
747
+ # type: "es256",
748
+ # use: "attest,auth,ping,sign,stamp"
749
+ # }
750
+ # }
751
+ # ]
752
+ # },
753
+ # {
754
+ # deviceName: "desktop",
755
+ # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
756
+ # keys: [
757
+ # {
758
+ # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
759
+ # options: {
760
+ # type: "es256",
761
+ # use: "attest,auth,ping,sign,stamp"
762
+ # }
763
+ # }
764
+ # ]
765
+ # }
766
+ # ],
767
+ # includes: []
768
+ # }
769
+ #
770
+ ```
771
+
772
+ </details>
773
+
774
+ <details>
775
+ <summary><b>Result properties</b></summary>
776
+
777
+ The result object has the following properties:
778
+
779
+ | Property Name | Type | Description |
780
+ |--------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
781
+ | `status` | `number` | `1` when the identifier exists. `0` when the domain is configured for triauth but the identifier does not exist, or when the `lookupCode` is missing or wrong under `mode=private`. `-1` when the domain is not configured for triauth. |
782
+ | `identifier` | `string` | The identifier that was looked up. Example: `'john@example.com'`. |
783
+ | `authenticationEndpoint` | `object` | Only when `status` is `0` or `1`. The domain's `triauth` record. Its `url` is the address of the authenticator. Its `options` are the `mode` and `include` of the record, at their defaults when the record does not spell them, and any `x-` extension options. Its `secure` is `true` when DNSSEC protected the record. Example: `{url:'https://auth.example.com/', options:{mode:'public', include:'any'}, secure:true}`. |
784
+ | `identityDomain` | `string` | Only when `status` is `0` or `1`. The domain name under which the identity records of `identifier` are stored. `nil` when the domain is in `mode=private` and no `lookupCode` was given. Examples: `'john._at.example.com'`, `'_7CXNKJIB._at.example.com'`. |
785
+ | `secure` | `boolean` | Only when `status` is `1`. `true` only when DNSSEC protected the domain's `triauth` record and every identity record that was read. |
786
+ | `publicProfile` | `object` | Only when `status` is `1`. Public profile entries of `identifier`, as read from the identity records. `name` and `initials` are the recognized keys, and keys with the `x-` prefix are extensions. Values are raw text. Escape them before you render them. Example: `{name:'John Doe', initials:'JD'}`. |
787
+ | `groups` | `array` | Only when `status` is `1`. Fully-qualified group names that the identity records of `identifier` claim membership in, sorted and deduplicated. `[]` when none are published. Example: `['admins@example.com']`. |
788
+ | `devices` | `array` | Only when `status` is `1`. One entry per device whose key records are complete and well-formed. Each entry has the `deviceName`, the `deviceTag`, and the `keys` of the device. Each key has a `value` and its `options`. |
789
+ | `includes` | `array` | Only when `status` is `1`. The `include` records that the domain's include policy admits, ordered by `tag`. Each entry has a `ref`, `options`, and a `tag`. The `ref` is the actor, as an identifier or an identity domain. The `options` are `use` and `scope`. The `tag` is the first segment of a delegated `deviceTag`. Example: `[{ref:'jane@example.com', options:{use:'sign', scope:'any'}, tag:'LJwdl7N9…'}]`. |
790
+
791
+ </details>
792
+
793
+ [Back to TOC](#table-of-contents)
794
+
795
+ ---
796
+
797
+ <a name="triauth-attest"></a>
798
+
799
+ ### Triauth.attest `({identifier, callbackUrl, attestations, sessionKey, challenge, response, ext})`
800
+
801
+ The `attest` method obtains third-party verified attestations associated with the user's `identifier`.
802
+ 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.
803
+
804
+ The `attestations` argument is a Hash whose keys are caller-chosen attestation IDs in printable ASCII (e.g., `'not-a-robot'`) and whose values describe each requested attestation.
805
+ Each attestation consists of a `label` (string) displayed to the user (e.g., 'I am not a robot') and `providers` (an array of `https://` URL strings) from which the user can select a verification provider.
806
+ Give each attestation its own provider URL. One signature satisfies every attestation ID whose `providers` list contains its URL, so two attestations that share a provider are satisfied together.
807
+
808
+ When the user clicks the 'verify' button for an attestation, they are directed to the selected provider's URL.
809
+ The provider verifies that the user meets the specified criteria (e.g., those indicated in the URL) and returns a digital signature.
810
+ These signatures are included in the final response you receive and verified when you call `Triauth.attest` in stage '3. Verify'.
811
+
812
+ > [!IMPORTANT]
813
+ > **You choose which providers to trust, and the provider defines what the attestation means.**<br/>
814
+ > `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.
815
+
816
+ > [!NOTE]
817
+ > **Attestations in triauth may be anonymous.**<br/>
818
+ > 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`).
819
+ > 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.
820
+
821
+ > [!NOTE]
822
+ > **Use the same base callbackUrl as for authentication**
823
+ >
824
+ > <details>
825
+ > <summary>Details</summary>
826
+ >
827
+ > 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.
828
+ > For example, if `callbackUrl` used for authentication was `https://example.com/dir1/callback`, the `callbackUrl` used in this method
829
+ > 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).
830
+ >
831
+ > </details>
832
+
833
+ The attestations are based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
834
+
835
+ **1. Request**
836
+
837
+ At first, you need to call the `Triauth.attest` method with the `identifier` option set to the user provided personal identifier (lowercase string),
838
+ a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
839
+ an `attestations` option (object) whose keys are caller-chosen attestation IDs and whose values consist of a `label` (string) and `providers` (array of `https://` URL strings),
840
+ a `sessionKey` (string) option set to the `sessionKey` of a `Triauth.authenticate` result,
841
+ and an optional `ext` option (object) listing the [protocol extensions](#protocol-extensions) that you wish to use.
842
+
843
+ In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
844
+ 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`.
845
+
846
+ > [!TIP]
847
+ > The URLs of verification providers are shown in the UI, so keep them short and human-friendly.
848
+
849
+ Synopsis:
850
+
851
+ ```ruby
852
+
853
+ result = Triauth.attest({ identifier: identifier, callbackUrl: callback_url, attestations: attestations, sessionKey: session_key, ext: ext })
854
+ # @returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
855
+ # @returns {error} on error
856
+
857
+ ```
858
+
859
+ <details>
860
+ <summary><b>Example</b></summary>
861
+
862
+ ```ruby
863
+ Triauth.attest(
864
+ {
865
+ identifier: 'john@triauthdemo.org',
866
+ callbackUrl: 'https://example.com/triauth-callback',
867
+ attestations: {
868
+ 'not-a-robot': {
869
+ label: 'I am not a robot',
870
+ providers: [
871
+ 'https://attest.triauth.org/proof-of-work',
872
+ 'https://other-provider.example.com/i-am/not-a-robot'
873
+ ]
874
+ }
875
+ },
876
+ sessionKey: '59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8'
877
+ }
878
+ )
879
+ # {challenge: 'eyJjYn…l19fX0.PEq5CslSnTExe9xko1O5B-K9T4wBse5XtyClYb8WxJM', redirectUrl: 'https://auth.triauthdemo.org/attest.html#?skid=wzI1sz3n1R4biw4FL_VJ3Q&request=…'}
880
+ ```
881
+
882
+ </details>
883
+
884
+ **2. Redirect**
885
+
886
+ Once the user's web browser is redirected to the `redirectUrl`, the Triauth Authenticator web application displays the requested attestations.
887
+ For each attestation, it shows your `label`, a dropdown listing the `providers`, and a 'Verify' button, allowing the user to select a provider and complete the verification.
888
+
889
+ If the user declines the request, the Triauth Authenticator sends `false` as the `response`, which `Triauth.attest` reports as the error `request_denied`.
890
+
891
+ **3. Verify**
892
+
893
+ 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 encrypted `response` URL parameter.
894
+ The method that is used to pass the response to your application can be configured with the [callbackMethod extension](#protocol-extensions).
895
+
896
+ 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.
897
+ 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`.
898
+
899
+ Synopsis:
900
+
901
+ ```ruby
902
+
903
+ attest_result = Triauth.attest({ challenge: challenge, response: response })
904
+ # @returns {attested:true, issuedAt, signedAt, verifiedAt, expires, secure, identifier, identityDomain, lookupCode, actor, actorIdentityDomain, actorLookupCode, groups, attestations, deviceName, deviceTag, keys} upon success
905
+ # @returns {error:{code, message}} upon failure
906
+
907
+ ```
908
+
909
+ <details>
910
+ <summary><b>Example</b></summary>
911
+
912
+ ```ruby
913
+ Triauth.attest(
914
+ {
915
+ challenge:'eyJjYn…l19fX0.PEq5CslSnTExe9xko1O5B-K9T4wBse5XtyClYb8WxJM',
916
+ response:'BPPDsY…OGSBac'
917
+ }
918
+ )
919
+
920
+ #
921
+ # {
922
+ # attested: true,
923
+ # issuedAt: 1787224748449,
924
+ # signedAt: 1787224773556,
925
+ # verifiedAt: 1787224773991,
926
+ # expires: 1787226573991,
927
+ # secure: true,
928
+ # identifier: "john@triauthdemo.org",
929
+ # identityDomain: "john._at.triauthdemo.org",
930
+ # lookupCode: "",
931
+ # actor: "",
932
+ # actorIdentityDomain: "",
933
+ # actorLookupCode: "",
934
+ # groups: [],
935
+ # attestations: {
936
+ # "not-a-robot": {
937
+ # valid: true,
938
+ # type: "attest",
939
+ # identifier: "proof-of-work@attest.triauth.org",
940
+ # identityDomain: "proof-of-work._at.attest.triauth.org",
941
+ # lookupCode: "",
942
+ # actor: "",
943
+ # actorIdentityDomain: "",
944
+ # actorLookupCode: "",
945
+ # via: "https://attest.triauth.org/proof-of-work",
946
+ # ver: 1,
947
+ # signedAt: 1787224768930,
948
+ # verifiedAt: 1787224773991,
949
+ # publicProfile: {
950
+ # name: "Proof Of Work"
951
+ # },
952
+ # groups: [],
953
+ # deviceName: "server",
954
+ # deviceTag: "NOtejuAqKHSdgvKVpw57cU_Qipj6UKcz2nNiugx0l8g",
955
+ # keys: [
956
+ # {
957
+ # value: "BAdEISRVd_8LuQe8SV6p-cDjW2t3PSa6NlRGIhfRflkvIA9rrNJWbsQllN56YpF2Q8AIHWrcWcR6hoSnXy7QLFM",
958
+ # options: {
959
+ # use: "attest",
960
+ # type: "es256"
961
+ # },
962
+ # verified: true,
963
+ # skipped: false
964
+ # }
965
+ # ],
966
+ # secure: true,
967
+ # expires: 1787226573991,
968
+ # signedMetadata: {
969
+ # bits: 16
970
+ # },
971
+ # unsignedMetadata: {}
972
+ # }
973
+ # },
974
+ # deviceName: "desktop",
975
+ # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
976
+ # keys: [
977
+ # {
978
+ # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
979
+ # options: {
980
+ # type: "es256",
981
+ # use: "attest,auth,ping,sign,stamp"
982
+ # },
983
+ # verified: true,
984
+ # skipped: false
985
+ # }
986
+ # ]
987
+ # }
988
+ #
989
+ ```
990
+
991
+ </details>
992
+
993
+ Upon successful attestation, the result object will have the `attested` property set to `true`.
994
+
995
+ 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).
996
+
997
+ When `attested` is `true`, inspect the result before relying on it:
998
+ - 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.
999
+ - 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`).
1000
+ - Compare `attestations[<id>][:identifier]` with the attester the provider documents when the provider's host also issues identities to others.
1001
+
1002
+ <details>
1003
+ <summary><b>Result properties</b></summary>
1004
+
1005
+ When `attested` is `true`, the result object has the following properties.
1006
+
1007
+ | Property Name | Type | Description |
1008
+ |-----------------------|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1009
+ | `attested` | `boolean` | Always `true` when every requested attestation was verified. |
1010
+ | `issuedAt` | `number` | Unix timestamp on **your** server's clock at which you issued the challenge in stage 1. Example: `1787224748449`. |
1011
+ | `signedAt` | `number` | Unix timestamp on the **user's device** clock at which the authenticator signed the response. Each attester's own `signedAt` is inside `attestations`. Example: `1787224773556`. |
1012
+ | `verifiedAt` | `number` | Unix timestamp on **your** server's clock at which this library confirmed the last of the signatures. Example: `1787224773991`. |
1013
+ | `expires` | `number` | The earliest `expires` of the user's signature and of every attestation. It may be `nil` when the records have no TTL, or arbitrarily low. Example: `1787226573991`. |
1014
+ | `secure` | `boolean` | `true` only when DNSSEC protected every DNS record that this result depends on. |
1015
+ | `identifier` | `string` | The identifier that requested the attestations. It is the same as in the challenge. Example: `'john@example.com'`. |
1016
+ | `identityDomain` | `string` | The domain name under which the identity records of `identifier` are stored. Example: `'john._at.example.com'`. |
1017
+ | `lookupCode` | `string` | The lookup code of `identifier` under `mode=private`. `''` when no lookup code is needed. |
1018
+ | `actor` | `string` | For a delegated attestation, the identifier of the actor that used their own keys on behalf of `identifier`. `''` when the user's own keys were used. Example: `'assistant@corp.example'`. |
1019
+ | `actorIdentityDomain` | `string` | For a delegated attestation, the domain name under which the identity records of `actor` are stored. `''` when there is no actor. |
1020
+ | `actorLookupCode` | `string` | For a delegated attestation, the lookup code of `actor` under `mode=private`. `''` when there is no actor or no lookup code is needed. |
1021
+ | `groups` | `array` | Fully-qualified group names that the identity records of `identifier` claim membership in, sorted and deduplicated. `[]` when none are published. Example: `['admins@example.com']`. |
1022
+ | `attestations` | `object` | One entry per requested attestation ID. Each entry is the verified signature of the attester, with the same properties as a [`Triauth.verify` result](#user-content-triauth-verify). Its `identifier` is the attester. Its `via` is the provider URL that you listed. Its `signedMetadata.bind` is the binding to the user, when the attester set one. |
1023
+ | `deviceName` | `string` | The user-given name of the device that signed, as read from the key records. Informational only. Example: `'desktop'`. |
1024
+ | `deviceTag` | `string` | The tag that identifies the device that signed, together with its lookup code and its delegation. Use it with `Triauth.check`. |
1025
+ | `keys` | `array` | The public keys of the device that signed, as read from the key records. Each key has a `value`, its `options`, a `verified` flag, and a `skipped` flag. `verified` is `true` when the key matched the signature. `skipped` is `true` when the key's `use` option excludes this type of signature. Every key is one or the other. |
1026
+
1027
+ </details>
1028
+
1029
+ [Back to TOC](#table-of-contents)
1030
+
1031
+ ---
1032
+
1033
+ <a name="triauth-sign"></a>
1034
+
1035
+ ### Triauth.sign `({identifier, callbackUrl, message, attachments, sessionKey, challenge, response, ext})`
1036
+
1037
+ 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`.
1038
+ Interactive signatures require interaction from the user, like reviewing the message and clicking on the 'Sign' button in the Triauth Authenticator.
1039
+ 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.,
1040
+ user has accepted certain terms or documents.
1041
+
1042
+ > [!NOTE]
1043
+ > **Use the same base callbackUrl as for authentication**
1044
+ >
1045
+ > <details>
1046
+ > <summary>Details</summary>
1047
+ >
1048
+ > 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.
1049
+ > For example, if `callbackUrl` used for authentication was `https://example.com/dir1/callback`, the `callbackUrl` used in this method
1050
+ > 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).
1051
+ >
1052
+ > </details>
1053
+
1054
+ The signing is based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
1055
+
1056
+ **1. Request**
1057
+
1058
+ At first, you need to call the `Triauth.sign` method with the `identifier` option set to the user provided personal identifier (lowercase string),
1059
+ a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
1060
+ a `message` (string) option set to a human-readable text message to be signed,
1061
+ an optional `attachments` (array) option listing additional file attachments to be covered by the signature,
1062
+ a `sessionKey` (string) option set to the `sessionKey` of a `Triauth.authenticate` result,
1063
+ and an optional `ext` option (object) listing the [protocol extensions](#protocol-extensions) that you wish to use.
1064
+
1065
+ > [!NOTE]
1066
+ > The `message` may consist of letters and digits from any language, space, and a fixed set of punctuation, and is limited to 2 KB.
1067
+ > Emojis, newlines, tabs, and other whitespace or control characters are not allowed - the same rule as for `Triauth.stamp`.
1068
+
1069
+ 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.
1070
+ 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.
1071
+
1072
+ > [!TIP]
1073
+ > The server from which attachment is served must respond with `Access-Control-Allow-Origin: *` HTTP header to allow Triauth Authenticator to download it.
1074
+ > Additionally, only selected file formats and extensions are supported (`.txt`, `.pdf`, `.json`, `.xml`, `.png`, `.doc`, `.xls`, and a few others).
1075
+ > For file formats other than `txt` and `pdf`, the file name must end with the appropriate extension (e.g., `.doc`).
1076
+
1077
+ > [!TIP]
1078
+ > You can get a SHA256 hexdigest of a content of a given `sourceUrl` with the following snippet:<br/>
1079
+ > ```ruby
1080
+ > Digest::SHA256.hexdigest(Net::HTTP.get(URI(source_url)))
1081
+ > ```
1082
+
1083
+
1084
+ In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
1085
+ 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`.
1086
+
1087
+ Synopsis:
1088
+
1089
+ ```ruby
1090
+
1091
+ result = Triauth.sign({ identifier: identifier, callbackUrl: callback_url, message: message, attachments: attachments, sessionKey: session_key, ext: ext })
1092
+ # @returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
1093
+ # @returns {error} on error
1094
+
1095
+ ```
1096
+
1097
+ <details>
1098
+ <summary><b>Example</b></summary>
1099
+
1100
+ ```ruby
1101
+ Triauth.sign(
1102
+ {
1103
+ identifier: 'john@triauthdemo.org',
1104
+ callbackUrl: 'https://example.com/triauth-callback',
1105
+ message: 'To continue, please read and accept the attached License',
1106
+ attachments: [
1107
+ {
1108
+ name: 'License',
1109
+ sourceUrl: 'https://raw.githubusercontent.com/spdx/license-list-data/refs/heads/main/text/AGPL-3.0-or-later.txt',
1110
+ sha256: 'd8a6cc31abc16b6748c7a21f21611f5a1ec33f67d22ca23d7da1c19b95496bee'
1111
+ }
1112
+ ],
1113
+ sessionKey: '59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8'
1114
+ }
1115
+ )
1116
+ # {
1117
+ # challenge: "eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJzaWduIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJLRXZCSVBLZFRLX1JNYldFUkZVVFpBcUkiLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCTEYxdEVWQkVZMGpBZ3lFcGM0MUJSdTRHS2loaTVneDhyalFmY2Q3UnFOUGFUd1kyeG1zYkNSZ0YxNkN5VWJuRk5XUFREZndkMV9XTVlEWldRdWFOblkiLCJtc2ciOiJUbyBjb250aW51ZSwgcGxlYXNlIHJlYWQgYW5kIGFjY2VwdCB0aGUgYXR0YWNoZWQgTGljZW5zZSIsImF0dGFjaG1lbnRzIjpbeyJuYW1lIjoiTGljZW5zZSIsInNvdXJjZVVybCI6Imh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9zcGR4L2xpY2Vuc2UtbGlzdC1kYXRhL3JlZnMvaGVhZHMvbWFpbi90ZXh0L0FHUEwtMy4wLW9yLWxhdGVyLnR4dCIsInNoYTI1NiI6ImQ4YTZjYzMxYWJjMTZiNjc0OGM3YTIxZjIxNjExZjVhMWVjMzNmNjdkMjJjYTIzZDdkYTFjMTliOTU0OTZiZWUifV19.zN__zhGHx9hT7gakwy1WZ33JRURklZVmS-crUohEchE",
1118
+ # 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"
1119
+ # }
1120
+ ```
1121
+
1122
+ </details>
1123
+
1124
+ **2. Redirect**
1125
+
1126
+ Once the user's web browser is redirected to the `redirectUrl`, they will be taken to the Triauth Authenticator web application,
1127
+ where they will be presented with the message, and have an opportunity to view and/or download attachments.
1128
+
1129
+ If the user declines the signing request, the Triauth Authenticator sends `false` as the `response`, which `Triauth.sign` reports as the error `request_denied`.
1130
+
1131
+ **3. Verify**
1132
+
1133
+ 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 encrypted `response` URL parameter.
1134
+ The method that is used to pass the response to your application can be configured with the [callbackMethod extension](#protocol-extensions).
1135
+
1136
+ 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.
1137
+ 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`.
1138
+
1139
+ Synopsis:
1140
+
1141
+ ```ruby
1142
+
1143
+ signing_result = Triauth.sign({ challenge: challenge, response: response })
1144
+ # @returns {signed:true, result, issuedAt, signedAt, verifiedAt, verificationResult} upon success
1145
+ # @returns {error:{code, message}} upon failure
1146
+
1147
+ ```
1148
+
1149
+ <details>
1150
+ <summary><b>Example</b></summary>
1151
+
1152
+ ```ruby
1153
+ Triauth.sign(
1154
+ {
1155
+ challenge:'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJzaWduIiwiaWRlbnRpZmllciI6ImpvaG5AdHJpYXV0aGRlbW8ub3JnIiwibm9uY2UiOiJLRXZCSVBLZFRLX1JNYldFUkZVVFpBcUkiLCJpYXQiOjE3ODcyMjQ3NDg0NDksInZlciI6MSwiZWsiOiJCTEYxdEVWQkVZMGpBZ3lFcGM0MUJSdTRHS2loaTVneDhyalFmY2Q3UnFOUGFUd1kyeG1zYkNSZ0YxNkN5VWJuRk5XUFREZndkMV9XTVlEWldRdWFOblkiLCJtc2ciOiJUbyBjb250aW51ZSwgcGxlYXNlIHJlYWQgYW5kIGFjY2VwdCB0aGUgYXR0YWNoZWQgTGljZW5zZSIsImF0dGFjaG1lbnRzIjpbeyJuYW1lIjoiTGljZW5zZSIsInNvdXJjZVVybCI6Imh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9zcGR4L2xpY2Vuc2UtbGlzdC1kYXRhL3JlZnMvaGVhZHMvbWFpbi90ZXh0L0FHUEwtMy4wLW9yLWxhdGVyLnR4dCIsInNoYTI1NiI6ImQ4YTZjYzMxYWJjMTZiNjc0OGM3YTIxZjIxNjExZjVhMWVjMzNmNjdkMjJjYTIzZDdkYTFjMTliOTU0OTZiZWUifV19.zN__zhGHx9hT7gakwy1WZ33JRURklZVmS-crUohEchE',
1156
+ response:'BFUYnuW1tUiltgq1DQbLeqpZTNDwDJm89hxkfrBuuNNdTmmwoRYuR5dZeVdnrJWbcSiLfO2VPYuTw-mBq6EZi0c.HUJzImw0j-Uo629gvuIMb6ePe9G835vaz7bURvqz5ICtkxc9vP4sRRXTnkhpQl8lsVVX4aYs1ltz-eft2aqxyUwfaaPYRPhWGj3rwUdr9lXnT8qtSkyInacgxWWx8MCPoveO1GBBD2iIE4-o9vUEfPziEh9dz7bBf_1Y_xM_fFXHEXZaGz1CWBN42S24BnKuVUvcT1gGfJiSIDdoG8YdU6xUFWJwhxwZupQbjIbfGuXrwXVrVejuE3_sgIy7pQ2SyLgSQcoBw329vTJns0b5QcjyRojzc9dS1iWeQWj_3TjF7nQh8F9sHWVqvdc0YCDncrcR_z-uTjDhgzShnhevHXukgQ_-Dze4kOd1wgUiwfEEIQvclIyVkSyrEcMt9bIUkmhb5Su5eicMnzdEZIvLMJQ7gssUxPE8B7_DXKZgMEnH'
1157
+ }
1158
+ )
1159
+ #
1160
+ # {
1161
+ # signed: true,
1162
+ # result: "|sign;john@triauthdemo.org;;https://example.com/;v1;1787224774435;eyJhdHRhY2htZW50cyI6W3sibmFtZSI6IkxpY2Vuc2UiLCJzaGEyNTYiOiJkOGE2Y2MzMWFiYzE2YjY3NDhjN2EyMWYyMTYxMWY1YTFlYzMzZjY3ZDIyY2EyM2Q3ZGExYzE5Yjk1NDk2YmVlIn1dfQ;;pj0gdEVQ3JBYh08jknFpY45WX462AUb1z78mINHewl1NXWzU5_g9ibD3-ffbdH9SIGtQTtcHQrsa3Ivt1wg1sg|",
1163
+ # issuedAt: 1787224748449,
1164
+ # signedAt: 1787224774435,
1165
+ # verifiedAt: 1787224774536,
1166
+ # verificationResult: {
1167
+ # valid: true,
1168
+ # type: "sign",
1169
+ # identifier: "john@triauthdemo.org",
1170
+ # identityDomain: "john._at.triauthdemo.org",
1171
+ # lookupCode: "",
1172
+ # actor: "",
1173
+ # actorIdentityDomain: "",
1174
+ # actorLookupCode: "",
1175
+ # via: "https://example.com/",
1176
+ # ver: 1,
1177
+ # signedAt: 1787224774435,
1178
+ # verifiedAt: 1787224774536,
1179
+ # publicProfile: {
1180
+ # initials: "JD",
1181
+ # name: "John Doe"
1182
+ # },
1183
+ # groups: [],
1184
+ # deviceName: "desktop",
1185
+ # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
1186
+ # keys: [
1187
+ # {
1188
+ # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
1189
+ # options: {
1190
+ # type: "es256",
1191
+ # use: "attest,auth,ping,sign,stamp"
1192
+ # },
1193
+ # verified: true,
1194
+ # skipped: false
1195
+ # }
1196
+ # ],
1197
+ # secure: true,
1198
+ # expires: 1787226574536,
1199
+ # signedMetadata: {
1200
+ # attachments: [
1201
+ # {
1202
+ # name: "License",
1203
+ # sha256: "d8a6cc31abc16b6748c7a21f21611f5a1ec33f67d22ca23d7da1c19b95496bee"
1204
+ # }
1205
+ # ]
1206
+ # },
1207
+ # unsignedMetadata: {}
1208
+ # }
1209
+ # }
1210
+ #
1211
+ ```
1212
+
1213
+ </details>
1214
+
1215
+ Upon successful signing, the result object will have the `signed` property set to `true`, and the raw signature (string) available in the `result` property.
1216
+ This signature may also be verified by third parties using the `Triauth.verify` method.
1217
+
1218
+ 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).
1219
+
1220
+ > [!NOTE]
1221
+ > An attachment is bound to the signature by its `name` and `sha256` only. The `sourceUrl` is used by the authenticator 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.
1222
+
1223
+ <details>
1224
+ <summary><b>Result properties</b></summary>
1225
+
1226
+ When `signed` is `true`, the result object has the following properties:
1227
+
1228
+ | Property Name | Type | Description |
1229
+ |----------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1230
+ | `signed` | `boolean` | Always `true` when the signing is successful. |
1231
+ | `result` | `string` | The signature, exactly as received in the `response`. Store it, or forward it to a third party. Any party can verify it with `Triauth.verify`, with the same `message`. It also covers the attachments by `name` and `sha256`, so the verifier must compare those digests with its own copies of the files. |
1232
+ | `issuedAt` | `number` | Unix timestamp on **your** server's clock at which you issued the challenge in stage 1. Example: `1787224748449`. |
1233
+ | `signedAt` | `number` | Unix timestamp on the **user's device** clock at which the authenticator signed the message. Example: `1787224774435`. |
1234
+ | `verifiedAt` | `number` | Unix timestamp on **your** server's clock at which this library confirmed the signature. Example: `1787224774536`. |
1235
+ | `verificationResult` | `object` | The verified signature, with the same properties as a [`Triauth.verify` result](#user-content-triauth-verify). Read the signer's `identifier`, `deviceTag`, `secure`, and `expires` from it. Its `signedMetadata.attachments` lists the attachments that the signature covers, each by `name` and `sha256`. |
1236
+
1237
+ </details>
1238
+
1239
+ [Back to TOC](#table-of-contents)
1240
+
1241
+ ---
1242
+
1243
+ <a name="triauth-stamp"></a>
1244
+
1245
+ ### Triauth.stamp `({identifier, callbackUrl, message, sessionKey, challenge, response, ext})`
1246
+
1247
+ 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`.
1248
+ Stamps do not typically require any interaction from the user, and may be later verified by any party using the `Triauth.verify` method.
1249
+ They can be used e.g., to prove to other websites that the user is currently logged in to your website.
1250
+
1251
+ > [!NOTE]
1252
+ > **Get the `stamp` permission and use the same base callbackUrl as for authentication**
1253
+ >
1254
+ > <details>
1255
+ > <summary>Details</summary>
1256
+ >
1257
+ > 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).
1258
+ > The authentication result shows the decision in `ext[:permissions]`. You can use `stamp` only when the authenticator responds with `ext.dig(:permissions, :stamp) == true`.
1259
+ >
1260
+ > 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.
1261
+ > For example, if `callbackUrl` used for authentication was `https://example.com/dir1/callback`, the `callbackUrl` used in this method
1262
+ > 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).
1263
+ >
1264
+ > </details>
1265
+
1266
+ The stamping is based on the [3-step challenge-response flow](#user-content-3-step-challenge-response-flow):
1267
+
1268
+ **1. Request**
1269
+
1270
+ At first, you need to call the `Triauth.stamp` method with the `identifier` option set to the user provided personal identifier (lowercase string),
1271
+ a `callbackUrl` option set to the URL under which your application expects to receive a response (string),
1272
+ a `message` (string) option set to a text message to be stamped,
1273
+ a `sessionKey` (string) option set to the `sessionKey` of a `Triauth.authenticate` result,
1274
+ and an optional `ext` option (object) listing the [protocol extensions](#protocol-extensions) that you wish to use.
1275
+
1276
+ > [!NOTE]
1277
+ > The `message` may consist of letters and digits from any language, space, and a fixed set of punctuation, and is limited to 2 KB.
1278
+ > Emojis, newlines, tabs, and other whitespace or control characters are not allowed.
1279
+ > To stamp binary data or multi-line content (e.g., a public key, a pretty-printed JSON object, a PEM blob), base64- or hex-encode it before passing it as `message`.
1280
+
1281
+ In return, you will receive a Hash with the `challenge` (string) and `redirectUrl` (string).
1282
+ 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`.
1283
+
1284
+ Synopsis:
1285
+
1286
+ ```ruby
1287
+
1288
+ result = Triauth.stamp({ identifier: identifier, callbackUrl: callback_url, message: message, sessionKey: session_key, ext: ext })
1289
+ # @returns {challenge, redirectUrl} the challenge should be remembered, and user redirected to the redirectUrl
1290
+ # @returns {error} on error
1291
+
1292
+ ```
1293
+
1294
+ <details>
1295
+ <summary><b>Example</b></summary>
1296
+
1297
+ ```ruby
1298
+ Triauth.stamp(
1299
+ {
1300
+ identifier:'john@triauthdemo.org',
1301
+ callbackUrl:'https://example.com/triauth-callback',
1302
+ message: 'login-nonce:4f2d9c715b6e4a3c', # a fresh, single-use nonce from your server, when the stamp gates anything
1303
+ sessionKey:'59cKjF500rMz1OrAMqUyLhSbWx1RgJDsWL7dFBDYIP8'
1304
+ }
1305
+ )
1306
+ #
1307
+ # {
1308
+ # challenge: 'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJzdGFtcCIsImlkZW50aWZpZXIiOiJqb2huQHRyaWF1dGhkZW1vLm9yZyIsIm5vbmNlIjoic1ZoVXRZOXQzMEVDQjJUR0t2WTktZXpBIiwiaWF0IjoxNzg3MjI0NzQ4NDQ5LCJ2ZXIiOjEsImVrIjoiQkdpOE1kZG11ZnFQdGNncEdwZFFvNDMzSTdxVzVHUm1uU0NmOGZaLVJHclppNng1ZHJNWkZIa3dQZXRkemFyZ21MQ2VvVU9HaF80dXJ6RWt6T3RKU0NrIiwibXNnIjoibG9naW4tbm9uY2U6NGYyZDljNzE1YjZlNGEzYyJ9.osPmHctBwGQUdU346YuJtHPJ-hh01Hia9bxCFZtYhNs',
1309
+ # 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'
1310
+ # }
1311
+ #
1312
+ ```
1313
+
1314
+ </details>
1315
+
1316
+ **2. Redirect**
1317
+
1318
+ 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.
1319
+
1320
+ **3. Verify**
1321
+
1322
+ 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 encrypted `response` URL parameter.
1323
+ The method that is used to pass the response to your application can be configured with the [callbackMethod extension](#protocol-extensions).
1324
+
1325
+ 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.
1326
+ 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`.
1327
+
1328
+ Synopsis:
1329
+
1330
+ ```ruby
1331
+
1332
+ stamping_result = Triauth.stamp({ challenge: challenge, response: response })
1333
+ # @returns {stamped:true, result, issuedAt, signedAt, verifiedAt, verificationResult} upon success
1334
+ # @returns {error:{code, message}} upon failure
1335
+
1336
+ ```
1337
+
1338
+ <details>
1339
+ <summary><b>Example</b></summary>
1340
+
1341
+ ```ruby
1342
+ Triauth.stamp(
1343
+ {
1344
+ challenge:'eyJjYnVybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdHJpYXV0aC1jYWxsYmFjayIsInR5cGUiOiJzdGFtcCIsImlkZW50aWZpZXIiOiJqb2huQHRyaWF1dGhkZW1vLm9yZyIsIm5vbmNlIjoic1ZoVXRZOXQzMEVDQjJUR0t2WTktZXpBIiwiaWF0IjoxNzg3MjI0NzQ4NDQ5LCJ2ZXIiOjEsImVrIjoiQkdpOE1kZG11ZnFQdGNncEdwZFFvNDMzSTdxVzVHUm1uU0NmOGZaLVJHclppNng1ZHJNWkZIa3dQZXRkemFyZ21MQ2VvVU9HaF80dXJ6RWt6T3RKU0NrIiwibXNnIjoibG9naW4tbm9uY2U6NGYyZDljNzE1YjZlNGEzYyJ9.osPmHctBwGQUdU346YuJtHPJ-hh01Hia9bxCFZtYhNs',
1345
+ response:'BFpQrxQ6mXjTWkiCFbd1d1QmQdwwdTT56QAffbbiQf_in1BW_nwpZcWqOHgVOV_FnGZuUlFc0bKEZiUBp-SQW2g.dWHtvEeGPf217Qg3SoQ5BBFrytjc5IumJZDwPmGvd4n04pwr7BD8sQQFCGM6WTwc0zu1_nIy9v6_rG4oqtEVyAzVrG7Siepi6AIBwiLgQQqdjcFBX3LNH-Xh6xD_XBOyM4P9QSDJjicL5dg-KUg3P2XXp8J4Tke6F2QK1WyepNxfIY-wi6HeKmsVqTbUYjzj_tJs23jdDv7Ik_7m5CCYkZtq0Xxj8NZSU1BmEw'
1346
+ }
1347
+ )
1348
+
1349
+ #
1350
+ # {
1351
+ # stamped: true,
1352
+ # result: "|stamp;john@triauthdemo.org;;https://example.com/;v1;1787224751380;;;0lWRFGKZsAC18au2DqtYEavf7zXDkCQH0fTOqwB2QeWsKhSkm29NY0mz1r6VckOVL2utg3wp1QMnySEO56zupg|",
1353
+ # issuedAt: 1787224748449,
1354
+ # signedAt: 1787224751380,
1355
+ # verifiedAt: 1787224751481,
1356
+ # verificationResult: {
1357
+ # valid: true,
1358
+ # type: "stamp",
1359
+ # identifier: "john@triauthdemo.org",
1360
+ # identityDomain: "john._at.triauthdemo.org",
1361
+ # lookupCode: "",
1362
+ # actor: "",
1363
+ # actorIdentityDomain: "",
1364
+ # actorLookupCode: "",
1365
+ # via: "https://example.com/",
1366
+ # ver: 1,
1367
+ # signedAt: 1787224751380,
1368
+ # verifiedAt: 1787224751481,
1369
+ # publicProfile: {
1370
+ # initials: "JD",
1371
+ # name: "John Doe"
1372
+ # },
1373
+ # groups: [],
1374
+ # deviceName: "desktop",
1375
+ # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
1376
+ # keys: [
1377
+ # {
1378
+ # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
1379
+ # options: {
1380
+ # type: "es256",
1381
+ # use: "attest,auth,ping,sign,stamp"
1382
+ # },
1383
+ # verified: true,
1384
+ # skipped: false
1385
+ # }
1386
+ # ],
1387
+ # secure: true,
1388
+ # expires: 1787226551481,
1389
+ # signedMetadata: {},
1390
+ # unsignedMetadata: {}
1391
+ # }
1392
+ # }
1393
+ #
1394
+ ```
1395
+
1396
+ </details>
1397
+
1398
+ Upon successful stamping, the result object will have the `stamped` property set to `true`, and the stamp (string) available in the `result` property.
1399
+ This stamp may also be verified by third parties using the `Triauth.verify` method.
1400
+
1401
+ 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).
1402
+
1403
+ <details>
1404
+ <summary><b>Result properties</b></summary>
1405
+
1406
+ When `stamped` is `true`, the result object has the following properties:
1407
+
1408
+ | Property Name | Type | Description |
1409
+ |----------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1410
+ | `stamped` | `boolean` | Always `true` when the stamping is successful. |
1411
+ | `result` | `string` | The stamp, exactly as received in the `response`. Store it, or forward it to a third party. Any party can verify it with `Triauth.verify`, with the same `message` as its first argument. |
1412
+ | `issuedAt` | `number` | Unix timestamp on **your** server's clock at which you issued the challenge in stage 1. Example: `1787224748449`. |
1413
+ | `signedAt` | `number` | Unix timestamp on the **user's device** clock at which the authenticator signed the message. Example: `1787224751380`. |
1414
+ | `verifiedAt` | `number` | Unix timestamp on **your** server's clock at which this library confirmed the stamp. Example: `1787224751481`. |
1415
+ | `verificationResult` | `object` | The verified stamp, with the same properties as a [`Triauth.verify` result](#user-content-triauth-verify). Read the signer's `identifier`, `deviceTag`, `secure`, and `expires` from it. |
1416
+
1417
+ </details>
1418
+
1419
+ [Back to TOC](#table-of-contents)
1420
+
1421
+ ---
1422
+
1423
+ <a name="triauth-verify"></a>
1424
+
1425
+ ### Triauth.verify `(message, signature, constraints={})`
1426
+
1427
+ The verify method verifies triauth signatures and stamps.
1428
+ 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.
1429
+
1430
+ > [!TIP]
1431
+ > For signatures that need to be verifiable even after the respective device keys are removed from the DNS,
1432
+ > consider using a DNS History Service.
1433
+
1434
+ > [!TIP]
1435
+ > In triauth's 3-step challenge-response protocol, the `response` to an approved request contains a triauth signature, encrypted with a key from the challenge.
1436
+ > Stage 3 decrypts it. `Triauth.sign` and `Triauth.stamp` return the signature as `result`, and to some extent you can also verify it later with this method.
1437
+ > But **never use this method in place of the original** (i.e., never use `Triauth.verify` in place of `Triauth.authenticate({challenge, response})` or any other such method),
1438
+ > as it may report `{valid:true}`, even when important constraints are not met (e.g., the authentication request has timed out, or attestation binding is not satisfied).
1439
+
1440
+ > [!IMPORTANT]
1441
+ > By default `Triauth.verify` accepts only a **single-signature** envelope (`constraints.maxSignatures` defaults to `1`).
1442
+ > To verify a multi-signature envelope, raise `maxSignatures` to the number of
1443
+ > segments you expect; otherwise a multi-segment envelope is reported as `{valid:false, reason:'declined'}`.
1444
+
1445
+ Synopsis:
1446
+
1447
+ ```ruby
1448
+ verification_result = Triauth.verify(message, signature)
1449
+ # @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
1450
+ # @returns {valid:true, type:'multisig', secure, expires, verifiedAt, signatures} if signature has been successfully verified as a multi-signature, with individual signature verification results available in the `signatures` array
1451
+ # @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)
1452
+ # @returns {error} when the signature could not be evaluated at all (e.g. bad arguments, DNS failure, or a requireSecure veto)
1453
+ ```
1454
+
1455
+ Accept a signature only when `valid == true`; treat every other result as not verified.
1456
+ If you raise `maxSignatures`, expect that you may get `type:'multisig'` back without a top-level `identifier` set.
1457
+
1458
+ <details>
1459
+ <summary><b>Example</b></summary>
1460
+
1461
+ ```ruby
1462
+ Triauth.verify(
1463
+ 'login-nonce:4f2d9c715b6e4a3c',
1464
+ '|stamp;john@triauthdemo.org;;https://example.com/;v1;1787224751380;;;0lWRFGKZsAC18au2DqtYEavf7zXDkCQH0fTOqwB2QeWsKhSkm29NY0mz1r6VckOVL2utg3wp1QMnySEO56zupg|'
1465
+ )
1466
+ #
1467
+ # {
1468
+ # valid: true,
1469
+ # type: "stamp",
1470
+ # identifier: "john@triauthdemo.org",
1471
+ # identityDomain: "john._at.triauthdemo.org",
1472
+ # lookupCode: "",
1473
+ # actor: "",
1474
+ # actorIdentityDomain: "",
1475
+ # actorLookupCode: "",
1476
+ # via: "https://example.com/",
1477
+ # ver: 1,
1478
+ # signedAt: 1787224751380,
1479
+ # verifiedAt: 1787224808550,
1480
+ # publicProfile: {
1481
+ # initials: "JD",
1482
+ # name: "John Doe"
1483
+ # },
1484
+ # groups: [],
1485
+ # deviceName: "desktop",
1486
+ # deviceTag: "3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ",
1487
+ # keys: [
1488
+ # {
1489
+ # value: "BHAILL142prn8rQsHm5ZlMPUFeMc6niVXXIM8biVY3HPjmaw4tfUD-YZ5unkRve1S9P70Mmgk7IBzgVgQPDRnk8",
1490
+ # options: {
1491
+ # type: "es256",
1492
+ # use: "attest,auth,ping,sign,stamp"
1493
+ # },
1494
+ # verified: true,
1495
+ # skipped: false
1496
+ # }
1497
+ # ],
1498
+ # secure: true,
1499
+ # expires: 1787226608550,
1500
+ # signedMetadata: {},
1501
+ # unsignedMetadata: {}
1502
+ # }
1503
+ #
1504
+ ```
1505
+
1506
+ </details>
1507
+
1508
+ <details>
1509
+ <summary><b>Result properties</b></summary>
1510
+
1511
+ When `valid` is `true` and the envelope has one signature, the result object has the following properties. `Triauth.sign` and `Triauth.stamp` return the same object as `verificationResult`, and `Triauth.attest` returns one per entry of `attestations`.
1512
+
1513
+ | Property Name | Type | Description |
1514
+ |-----------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1515
+ | `valid` | `boolean` | Always `true` when the signature was verified. Accept the signature only when it is exactly `true`. |
1516
+ | `type` | `string` | The type of the signature. One of `'auth'`, `'ping'`, `'attest'`, `'sign'`, or `'stamp'`. |
1517
+ | `identifier` | `string` | The identifier on whose behalf the signature was made. Example: `'john@example.com'`. |
1518
+ | `identityDomain` | `string` | The domain name under which the identity records of `identifier` are stored. Example: `'john._at.example.com'`. |
1519
+ | `lookupCode` | `string` | The lookup code of `identifier` under `mode=private`, as carried in the signature. `''` when no lookup code is needed. |
1520
+ | `actor` | `string` | For a delegated signature, the identifier of the actor whose keys made the signature on behalf of `identifier`. `''` when the identifier's own keys made it. Example: `'assistant@corp.example'`. |
1521
+ | `actorIdentityDomain` | `string` | For a delegated signature, the domain name under which the identity records of `actor` are stored. `''` when there is no actor. |
1522
+ | `actorLookupCode` | `string` | For a delegated signature, the lookup code of `actor` under `mode=private`. `''` when there is no actor or no lookup code is needed. |
1523
+ | `via` | `string` | The base URL of the application that the signature was made for. Example: `'https://example.com/'`. |
1524
+ | `ver` | `number` | The version of the triauth signature format. Example: `1`. |
1525
+ | `signedAt` | `number` | Unix timestamp on the **signer's device** clock at which the signature was made. It is asserted by that device. Example: `1787224751380`. |
1526
+ | `verifiedAt` | `number` | Unix timestamp on **your** clock at which this library confirmed the signature. Example: `1787224808550`. |
1527
+ | `publicProfile` | `object` | Public profile entries of `identifier`, as read from the identity records. `name` and `initials` are the recognized keys, and keys with the `x-` prefix are extensions. Values are raw text. Escape them before you render them. Example: `{name:'John Doe', initials:'JD'}`. |
1528
+ | `groups` | `array` | Fully-qualified group names that the identity records of `identifier` claim membership in, sorted and deduplicated. `[]` when none are published. Example: `['admins@example.com']`. |
1529
+ | `deviceName` | `string` | The user-given name of the device that signed, as read from the key records. Informational only. Example: `'desktop'`. |
1530
+ | `deviceTag` | `string` | The tag that identifies the device that signed, together with its lookup code and its delegation. Use it with `Triauth.check`. Example: `'3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ'`. |
1531
+ | `keys` | `array` | The public keys of the device that signed, as read from the key records. Each key has a `value`, its `options`, a `verified` flag, and a `skipped` flag. `verified` is `true` when the key matched the signature. `skipped` is `true` when the key's `use` option excludes this `type`. Every key is one or the other. |
1532
+ | `secure` | `boolean` | `true` only when DNSSEC protected every DNS record that this result depends on. |
1533
+ | `expires` | `number` | Unix timestamp until which the DNS records behind this result are considered fresh. It may be `nil` when the records have no TTL, or arbitrarily low. Example: `1787226608550`. |
1534
+ | `signedMetadata` | `object` | Metadata that the signature covers, as set by the signer. Examples are `attachments` for `sign`, `ext` for `auth`, and `bind` for `attest`. `{}` when there is none. |
1535
+ | `unsignedMetadata` | `object` | Metadata outside the signed part of the envelope. WebAuthn keys put their verification data here. `{}` when there is none. |
1536
+
1537
+ When `valid` is `true` and the envelope has more than one signature, the result object has the following properties instead:
1538
+
1539
+ | Property Name | Type | Description |
1540
+ |---------------|-----------|---------------------------------------------------------------------------------------------------|
1541
+ | `valid` | `boolean` | Always `true` when every signature in the envelope was verified. |
1542
+ | `type` | `string` | Always `'multisig'`. |
1543
+ | `secure` | `boolean` | `true` only when every signature in the envelope is `secure`. |
1544
+ | `expires` | `number` | The earliest `expires` of the signatures in the envelope. `nil` when none of them has one. |
1545
+ | `verifiedAt` | `number` | The latest `verifiedAt` of the signatures in the envelope. |
1546
+ | `signatures` | `array` | The verified signatures, in envelope order. Each entry has the single-signature properties above. |
1547
+
1548
+ When the signature was evaluated and not accepted, the result object has the following properties:
1549
+
1550
+ | Property Name | Type | Description |
1551
+ |---------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1552
+ | `valid` | `boolean` | Always `false` when the signature was evaluated and not accepted. |
1553
+ | `reason` | `string` | `'malformed'` when the envelope could not be parsed. `'invalid'` when the cryptographic check failed, the signer could not be resolved, or a value constraint did not match. `'expired'` when `signedAt` is outside `notBefore`/`notAfter`. `'declined'` when the number of signatures is outside `minSignatures`/`maxSignatures`. |
1554
+
1555
+ </details>
1556
+
1557
+ <details>
1558
+ <summary><b>Constraints argument</b></summary>
1559
+
1560
+ The optional `constraints` argument is a Hash that lets you require specific properties of the signature before the cryptographic check is even attempted. A non-matching value constraint (`identifier`, `actor`, `type`, `ver`, `via`) results in `{valid:false, reason:'invalid'}`, a signature timestamp outside `notBefore`/`notAfter` in `{valid:false, reason:'expired'}`, and a `minSignatures`/`maxSignatures` count mismatch in `{valid:false, reason:'declined'}`. The following constraints are supported:
1561
+
1562
+ | Constraint | Type | Description |
1563
+ |-----------------|------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1564
+ | `identifier` | `string` | The identifier on behalf of which the signature must have been generated. |
1565
+ | `actor` | `string` | The delegate whose keys must have made the signature on behalf of the `identifier`; `''` requires a non-delegated signature (the identifier's own keys). |
1566
+ | `type` | `string` | The type of signature - one of `'attest'`, `'auth'`, `'ping'`, `'sign'`, or `'stamp'`. |
1567
+ | `ver` | `number` | The version of the triauth signature specification. |
1568
+ | `via` | `string` | The base URL of the application the signature was issued to (e.g., `https://example.com/`). Pin it whenever the result gates access: a signature requested by another application verifies just as well otherwise. |
1569
+ | `notBefore` | `number` | Unix timestamp in milliseconds. If the signature's timestamp is earlier than this, the result is `{valid:false, reason:'expired'}`. The comparison is strict - no clock-drift slack is applied. |
1570
+ | `notAfter` | `number` | Unix timestamp in milliseconds. If the signature's timestamp is later than this, the result is `{valid:false, reason:'expired'}`. The comparison is strict - no clock-drift slack is applied. |
1571
+ | `minSignatures` | `number` | The minimum number of signature **segments** the (multi-)signature envelope must contain. Counts multi-signature segments, not distinct signers. A count outside the min/max bounds is reported as `{valid:false, reason:'declined'}`. |
1572
+ | `maxSignatures` | `number` | The maximum number of signature **segments** the (multi-)signature envelope may contain. **Defaults to `1`.** This limits DNS resolution work on untrusted input. A multi-segment envelope without an explicit `maxSignatures` is reported as `{valid:false, reason:'declined'}`. |
1573
+
1574
+ A constraint whose value is `nil` is not applied: it means "no pin".
1575
+
1576
+ ```ruby
1577
+ Triauth.verify(
1578
+ 'login-nonce:4f2d9c715b6e4a3c',
1579
+ '|stamp;john@triauthdemo.org;;https://example.com/;v1;1787224751380;;;0lWRFGKZsAC18au2DqtYEavf7zXDkCQH0fTOqwB2QeWsKhSkm29NY0mz1r6VckOVL2utg3wp1QMnySEO56zupg|',
1580
+ {identifier: 'john@triauthdemo.org', type: 'stamp', via: 'https://example.com/', notBefore: Triauth::Helpers.date_now - 24*60*60*1000}
1581
+ )
1582
+ ```
1583
+
1584
+ </details>
1585
+
1586
+ [Back to TOC](#table-of-contents)
1587
+
1588
+ ---
1589
+
1590
+ <a name="triauth-validate"></a>
1591
+
1592
+ ### Triauth.validate `({identifier?, deviceName?, groupName?, lookupCode?, deviceTag?})`
1593
+
1594
+ The validate method can be used to perform a basic format validation on the given objects.
1595
+ It is fast, synchronous, and does not perform any DNS requests.
1596
+
1597
+ Currently, it supports validation of personal identifiers (`identifier`), device names (`deviceName`), group names (`groupName`), lookup codes (`lookupCode`), and device tags (`deviceTag`).
1598
+ A group name is the bare name as published in a `groups` record (e.g., `admins`).
1599
+ A lookup code is the 16-character code of an identity under a `mode=private` domain (e.g., `K7QJ3FB9M2WZX0C4`).
1600
+ A device tag is the `deviceTag` of a result, as you pass it to [`Triauth.check`](#user-content-triauth-check).
1601
+ At least one of the supported objects must be provided, and unrecognized (e.g., misspelled) keys are rejected
1602
+ with `{valid:false}`, so that nothing silently passes as valid without being validated.
1603
+
1604
+ Synopsis:
1605
+
1606
+ ```ruby
1607
+ validation_result = Triauth.validate({ identifier: identifier })
1608
+ # @returns {valid:true, errors:[]} if all of the objects passed validation
1609
+ # @returns {valid:false, errors:[{code:…, reason:…, message:…}, …]} if at least one of the objects failed validation
1610
+ # @returns {valid:false, errors:[{code:'invalid_arguments', message:…}]} when no objects to validate were provided, or an unrecognized key was passed
1611
+ # @returns {valid:false, errors:[{code:'invalid_config', message:…}]} when the config override contains an unrecognized key
1612
+
1613
+ ```
1614
+
1615
+ <details>
1616
+ <summary><b>Example</b></summary>
1617
+
1618
+ ```ruby
1619
+ Triauth.validate({identifier: 'john@triauthdemo.org'})
1620
+ # {valid: true, errors: []}
1621
+
1622
+ Triauth.validate({identifier: 'john'})
1623
+ # {valid: false, errors: [{code: 'invalid_identifier', reason: 'invalid_format', message: 'Identifier must include one @ sign'}]}
1624
+
1625
+ Triauth.validate({groupName: 'Admins'})
1626
+ # {valid: false, errors: [
1627
+ # {code: 'invalid_group_name', reason: 'not_lowercase', message: 'Group name must be provided in lowercase'},
1628
+ # {code: 'invalid_group_name', reason: 'invalid_characters', message: 'Group name must include only letters, numbers, and hyphens'}
1629
+ # ]}
1630
+
1631
+ Triauth.validate({lookupCode: 'k7qj3fb9m2wzx0c4'})
1632
+ # {valid: false, errors: [{code: 'invalid_lookup_code', reason: 'invalid', message: 'Invalid lookup code'}]}
1633
+
1634
+ Triauth.validate({deviceTag: '3bi5oE_jXyjGY0WY5n6YqOuzRxfo40fuqDmLGaA5bKQ~K7QJ3FB9M2WZX0C4'})
1635
+ # {valid: true, errors: []}
1636
+ ```
1637
+
1638
+ </details>
1639
+
1640
+ > [!NOTE]
1641
+ > `{valid: true}` confirms the *format* only — it is not input sanitization.
1642
+ > Callers must still encode or escape the value for whatever sink it flows into (SQL, shell, file paths, HTML, log lines).
1643
+
1644
+ <details>
1645
+ <summary><b>Result properties</b></summary>
1646
+
1647
+ The result object has the following properties:
1648
+
1649
+ | Property Name | Type | Description |
1650
+ |---------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1651
+ | `valid` | `boolean` | `true` when every given object passed validation. `false` otherwise. |
1652
+ | `errors` | `array` | One entry per error, each with a `code`, a `message`, and (except for `invalid_arguments` and `invalid_config`) a `reason`. `[]` when `valid` is `true`. See [Error codes](#error-codes). Example: `[{code:'invalid_identifier', reason:'invalid_format', message:'Identifier must include one @ sign'}]`. |
1653
+
1654
+ </details>
1655
+
1656
+ [Back to TOC](#table-of-contents)
1657
+
1658
+ ---
1659
+
1660
+ ## Error codes
1661
+
1662
+ Some API methods return error information in the `error:{code, reason?, message}` format, and `Triauth.validate` returns a list of such errors in `errors`.
1663
+ Use `code` and `reason` to provide custom or localized versions of the error message if needed. The `message` is an English default, and its wording may change.
1664
+
1665
+ | Error Code | Meaning | Example Message |
1666
+ |---------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
1667
+ | **Internal and generic errors** | | |
1668
+ | `internal_error` | Internal error, logged at `error` level. May be a defect in triauth-ruby - if it happens, please report it. | "Internal error" |
1669
+ | `invalid_arguments` | Invalid, missing, or unrecognized method arguments. For example `challenge` was passed but `response` not, or an option name is misspelled. | "Invalid or missing function arguments" |
1670
+ | `invalid_config` | Unrecognized key in the `config` override (or a misspelled global `Triauth.config` key). Check for spelling mistakes. | "Unrecognized configuration key" |
1671
+ | `dns_unavailable` | DNS resolution failed - check resolver configuration and Internet connectivity | "DNS resolution failed, try again later" |
1672
+ | **Validation errors** | *Each validation error has a `reason`, see [Validation error reasons](#validation-error-reasons)* | |
1673
+ | `invalid_identifier` | The identifier is invalid. | |
1674
+ | `invalid_callback_url` | | "Invalid callbackUrl" |
1675
+ | `invalid_ext` | | "Invalid ext" |
1676
+ | `invalid_challenge` | | "Invalid challenge" |
1677
+ | `invalid_response` | | "Invalid response" |
1678
+ | `invalid_signature` | | "Invalid signature" |
1679
+ | `invalid_session_key` | | "Invalid session key" |
1680
+ | `invalid_message` | | "Invalid message" |
1681
+ | `invalid_attachments` | | "Invalid attachments" |
1682
+ | `invalid_attestations` | | "Invalid attestations" |
1683
+ | `invalid_lookup_code` | | "Invalid lookup code" |
1684
+ | `invalid_device_tag` | | "Invalid device tag" |
1685
+ | `invalid_device_name` | The device name is invalid. | |
1686
+ | `invalid_group_name` | The group name is invalid. | |
1687
+ | **Identity errors** | | |
1688
+ | `domain_not_configured` | Authentication endpoint for the given identifier is not configured. | "Domain is not configured for triauth" |
1689
+ | **Authentication errors** | | |
1690
+ | `verification_failed` | Failed authentication attempt (e.g., the received signature is invalid). | "Your identity could not be verified" |
1691
+ | `request_expired` | Authentication request has expired (e.g., the user took too long to approve it). | "Authentication request has expired, please try again" |
1692
+ | `request_denied` | Request has been denied (e.g., user clicked the "Deny" button, or the session key lacks the permission or is for another identifier or base URL). | "Request has been denied" |
1693
+ | `dnssec_required` | DNSSEC was required via `config[:requireSecure]`, but the verified response was not DNSSEC-secure (`secure != true`). | "Your domain does not support DNSSEC, which is required to continue." |
1694
+
1695
+ ### Validation error reasons
1696
+
1697
+ A validation error also has a `reason`, which shows the rule that the value breaks.
1698
+ The codes from `invalid_callback_url` to `invalid_device_tag` always have the reason `invalid`. The other validation errors have these reasons and default messages.
1699
+
1700
+ | Reason | `invalid_identifier` | `invalid_device_name` | `invalid_group_name` |
1701
+ |----------------------|--------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------------|
1702
+ | `invalid` | "Identifier is invalid" | "Device name is invalid" | "Group name is invalid" |
1703
+ | `empty` | "Identifier must not be empty" | "Device name must not be empty" | "Group name must not be empty" |
1704
+ | `too_long` | "Identifier is too long" | "Device name is too long" | "Group name is too long" |
1705
+ | `not_lowercase` | "Identifier must be provided in lowercase" | "Device name must be provided in lowercase" | "Group name must be provided in lowercase" |
1706
+ | `invalid_format` | "Identifier must include one @ sign" | | |
1707
+ | `invalid_username` | "Identifier contains invalid username" | | |
1708
+ | `invalid_domain` | "Identifier contains invalid domain name" | | |
1709
+ | `invalid_characters` | | "Device name must include only letters, numbers, and hyphens" | "Group name must include only letters, numbers, and hyphens" |
1710
+
1711
+ [Back to TOC](#table-of-contents)
1712
+
1713
+ ## Protocol extensions
1714
+
1715
+ Protocol extensions allow you to request additional data or modify triauth protocol behavior.
1716
+
1717
+ You can pass a Hash with the requested extensions inside the `ext` option of the initial `Triauth.authenticate` call in stage 1,
1718
+ and extract requested data from the `ext` property of the authentication result object returned by `Triauth.authenticate` in stage 3.
1719
+
1720
+ Some of the extensions (e.g., `callbackMethod`) also work with other triauth API methods (e.g., `Triauth.sign` or `Triauth.attest`).
1721
+
1722
+ Currently, the following extensions are available:
1723
+
1724
+ | Extension | Details |
1725
+ |---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1726
+ | **callbackMethod** | **Modifies how response is sent to your application** (the Triauth Authenticator refuses a request with any other value): |
1727
+ | `{callbackMethod:'GET'}` | - through a HTTP GET request to the `callbackUrl`, passed inside the `response` URL parameter. Default. Useful for server-side applications that keep the challenge in a session cookie. The encrypted response appears in the URL and in access logs. |
1728
+ | `{callbackMethod:'POST'}` | - through a HTTP POST request to the `callbackUrl`, passed inside the request body as a `response` form parameter. Session cookies marked `SameSite=Lax` or `Strict` do not arrive with it. |
1729
+ | `{callbackMethod:'HASH'}` | - through a HTTP GET request to the `callbackUrl`, passed inside the `response` parameter of the URL fragment (location.hash), which browsers do not send to servers. Useful for client-side applications, and for a receiver page that posts it to your server. |
1730
+ | **permissions** | **Allows requests that run without asking the user again.** `Triauth.sign` and `Triauth.attest` need no permission, because the user approves each of their requests. |
1731
+ | `{permissions:{ping:true}}` | Asks the user to allow `Triauth.ping` in the background. The authentication result shows the decision in `ext[:permissions][:ping]` (`true` or `false`). |
1732
+ | `{permissions:{stamp:true}}` | Asks the user to allow `Triauth.stamp`. The authentication result shows the decision in `ext[:permissions][:stamp]` (`true` or `false`). |
1733
+ | **manifest** | **Information about your website that may be used by Triauth Authenticator, for example to add a shortcut to your website on its home screen** |
1734
+ | `{name:string}` | Name of your website as shown to users (at most 255 bytes), e.g., `{manifest: {name: 'Fabulous Example'}}` |
1735
+ | `{startUrl:string}` | The URL that is opened when a user clicks on a shortcut to your website. It must be on the same origin as the `callbackUrl`, otherwise it is ignored and the shortcut opens the base URL of the `callbackUrl`, e.g., `{manifest: {startUrl: 'https://example.com/app/'}}` |
1736
+ | `{iconUrl:string}` | URL to a PNG or JPEG image file with the icon (up to 128 KB). Must be served with CORS headers (`Access-Control-Allow-Origin`), e.g., `{manifest: {iconUrl: 'https://example.com/icon.png'}}` |
1737
+
1738
+ [Back to TOC](#table-of-contents)
1739
+
1740
+ ## Configuration
1741
+
1742
+ * [Default configuration](#default-configuration)
1743
+ * [Logging](#logging)
1744
+ * [DNS Resolver](#dns-resolver)
1745
+ * [Caching DNS responses](#caching-dns-responses)
1746
+ * [Randomness](#randomness)
1747
+
1748
+ The default, global triauth configuration is stored as a plain Hash in the `Triauth.config` attribute.
1749
+
1750
+ > [!NOTE]
1751
+ > Each public API method also accepts an optional final argument - a Hash containing overrides for the global configuration, applied only to that call.
1752
+ > This argument was omitted from the API reference section above for clarity.
1753
+ > You can modify the global configuration, or provide overrides as the final argument to API method calls like below:
1754
+ > ```ruby
1755
+ > Triauth.whois({ identifier: identifier }, { logger: nil })
1756
+ > ```
1757
+
1758
+ > [!IMPORTANT]
1759
+ > **Avoid mutating `Triauth.config` while API calls are in flight.**
1760
+ > If you change `Triauth.config` (e.g., swap the resolver or logger) between the moment an API call starts and the moment it resolves, different internal objects within the same request may observe different effective configurations.
1761
+ > Prefer per-call overrides for any setting that needs to differ from the default; mutate `Triauth.config` only when no calls are running.
1762
+
1763
+ ### Default configuration
1764
+
1765
+ The default configuration is shown below:
1766
+
1767
+ ```ruby
1768
+ Triauth.config # the global Hash, with these defaults:
1769
+ {
1770
+ # When true, reject any challenge-response result (authenticate, ping, attest, sign, stamp), Triauth.check, and Triauth.verify
1771
+ # that is not DNSSEC-validated (`secure != true`), returning the error `dnssec_required` instead of a success object.
1772
+ # Leave false (default) to receive results regardless of `secure` and decide what to do with it yourself.
1773
+ requireSecure: false,
1774
+
1775
+ # DNS resolver used to query the DNS database
1776
+ # The default MultiResolver uses multiple perspectives to limit the potential for BGP and DNS poisoning attacks.
1777
+ resolver: Triauth::Resolvers::MultiResolver.new([
1778
+ Triauth::Resolvers::Cloudflare.new({retries: 1, timeout: 4000}),
1779
+ Triauth::Resolvers::Google.new({retries: 1, timeout: 4000}),
1780
+ Triauth::Resolvers::SystemDns.new({timeout: 4000})
1781
+ ],
1782
+ {maxFailures: 1, timeout: 8500}
1783
+ ),
1784
+
1785
+ # Console logger. Only `error` severity is reported by default.
1786
+ logger: Triauth::Logger.new(Triauth::Logger::CONSOLE, {logLevel: 'error', prefix: '[triauth]'}),
1787
+
1788
+ # Source of cryptographic randomness for challenge nonces. Defaults to nil, which falls back to `SecureRandom.random_bytes`.
1789
+ # Override with a higher-entropy or hardware-backed RNG, or a deterministic byte source for tests.
1790
+ randomSource: nil,
1791
+
1792
+ # The maximal allowed clock drift between the current clock (e.g., server time), and the end-user's clock (as seen by the web browser and Triauth Authenticator app),
1793
+ # including network round-trip time and any request pre-processing delays.
1794
+ # Applied when checking timestamps generated by the user's device (e.g., the `ts` embedded in signatures).
1795
+ maximalAllowedClientClockDrift: 30 * 1000,
1796
+
1797
+ # The maximal allowed clock drift between your own servers - e.g., when, due to load balancing, a challenge
1798
+ # is generated (stage 1) and verified (stage 3) by two different hosts with imperfectly synchronized clocks.
1799
+ # Applied when checking timestamps generated by your own servers (the challenge `iat` freshness window).
1800
+ # Keep it small: well-managed (NTP-synchronized) fleets stay within milliseconds of each other.
1801
+ maximalAllowedServerClockDrift: 5 * 1000,
1802
+
1803
+ # Timeout for the user to approve the request before it is considered expired (based on the challenge's `issued at` time).
1804
+ authTimeout: 3 * 60_000,
1805
+ pingTimeout: 15_000,
1806
+ attestTimeout: 15 * 60_000,
1807
+ signTimeout: 30 * 60_000,
1808
+ stampTimeout: 15_000,
1809
+ }
1810
+ ```
1811
+
1812
+ ### Logging
1813
+
1814
+ triauth-ruby uses a logger specified in the `config[:logger]` attribute, which defaults to a `Triauth::Logger` writing to the console (`Triauth::Logger::CONSOLE`) at the `error` level.
1815
+
1816
+ Setting `config[:logger] = nil` disables logging.
1817
+
1818
+ You can also provide your own logger, that implements any or all of the `error`, `warn`, `info`, `debug` methods.
1819
+ Your logger may also implement a `spawn` method that is invoked internally at the beginning of each API method call, and that should return a child logger instance which will be used during that call.
1820
+
1821
+ > [!TIP]
1822
+ > A logger whose `error`, `warn`, `info` and `debug` methods take a message and an optional data Hash is compatible out-of-the-box (e.g., `Triauth::Logger::CONSOLE`); others, such as the standard library's `Logger`, need a simple adapter to work.
1823
+ > For example, if you are using the standard library's `Logger` to manage your logs, you can use the following adapter:
1824
+ > ```ruby
1825
+ > require 'logger'
1826
+ >
1827
+ > def create_logger_adapter(logger, cid = nil)
1828
+ > adapter = Object.new
1829
+ > %w[error warn info debug].each do |level|
1830
+ > adapter.define_singleton_method(level) do |msg, obj = {}|
1831
+ > logger.public_send(level, cid) { obj.empty? ? msg.to_s : "#{msg} #{obj.inspect}" }
1832
+ > end
1833
+ > end
1834
+ >
1835
+ > adapter.define_singleton_method(:spawn) do |child_cid|
1836
+ > create_logger_adapter(logger, child_cid || rand(36**8).to_s(36).rjust(8, '0'))
1837
+ > end
1838
+ >
1839
+ > adapter
1840
+ > end
1841
+ >
1842
+ > Triauth.config[:logger] = create_logger_adapter(Logger.new($stdout))
1843
+ > ```
1844
+
1845
+ For the default `Triauth::Logger` logger, you can change the log level by setting the `Triauth.config[:logger].log_level` attribute to one of `['debug', 'info', 'warn', 'error']`, or silence the default console log output with `Triauth.config[:logger].output = nil`.
1846
+ To help with debugging, `Triauth::Logger` output contains a tag with randomly assigned correlation identifier, e.g., `[cid:p9p65365]`, that allows you to trace related log entries.
1847
+ You can specify a custom correlation identifier for a particular API call by passing it in the config override Hash, e.g., `Triauth.whois({identifier: 'john@triauthdemo.org'}, {cid: '...'})`. Pass `cid: false` to suppress the `[cid:...]` tag for a call, or leave it at the default (`nil`) to auto-generate one.
1848
+
1849
+ > [!WARNING]
1850
+ > The `debug` log level emits sensitive data - challenges, signatures, payloads to be signed, key values, and other protocol artifacts. Avoid enabling it in production, and forwarding debug-level output to third-parties.
1851
+ > The `info` level logs identifiers, callback URLs, `ext` options and device tags, which can contain a lookup code - review that output before you forward it to a third-party service.
1852
+
1853
+ ### DNS Resolver
1854
+
1855
+ To resolve identity records and address of authentication endpoint for the given user identifier, the triauth-ruby library needs to query the Domain Name System (DNS).
1856
+ It does so by using the DNS resolver specified in the `config[:resolver]` attribute.
1857
+
1858
+ To increase security, triauth-ruby uses a multi-resolver (an instance of `Triauth::Resolvers::MultiResolver`) by default, allowing it to evaluate the DNS state from multiple perspectives.
1859
+ This multi-resolver is configured to use the following providers:
1860
+
1861
+ - `Triauth::Resolvers::Cloudflare` - [Cloudflare 1.1.1.1](https://developers.cloudflare.com/1.1.1.1/) over HTTPS
1862
+ - `Triauth::Resolvers::Google` - [Google Public DNS](https://developers.google.com/speed/public-dns/docs/doh) over HTTPS
1863
+ - `Triauth::Resolvers::SystemDns` - system resolver via [`Resolv::DNS`](https://docs.ruby-lang.org/en/3.2/Resolv/DNS.html) (Ruby standard library)
1864
+
1865
+ The multi-resolver queries all of the above providers to return an intersection of obtained results, and
1866
+ allows for a maximum of one failing provider to increase availability. For details of the default configuration please see the [lib/triauth/config.rb](https://github.com/triauth/triauth-ruby/blob/main/lib/triauth/config.rb) file.
1867
+
1868
+ With the default configuration the global DNS resolution timeout is controlled by the `config[:resolver].options[:timeout]` setting, which defaults to `8500` (8.5 seconds).
1869
+
1870
+ The DNS-over-HTTPS resolvers send their requests through `Net::HTTP`, which honors the `http_proxy`, `https_proxy` and `no_proxy` environment variables.
1871
+
1872
+ For a list of available resolvers, please see the [lib/triauth/resolvers](lib/triauth/resolvers) directory.
1873
+
1874
+ ### Caching DNS responses
1875
+
1876
+ By default, every API call queries the DNS afresh. If your application performs many triauth operations for users of the same domains (e.g., periodic `Triauth.check` calls on a busy server), you can opt in to application-level caching of DNS answers by wrapping your resolver in a `Triauth::Resolvers::CachingResolver`:
1877
+
1878
+ ```ruby
1879
+ Triauth.config[:resolver] = Triauth::Resolvers::CachingResolver.new(Triauth.config[:resolver])
1880
+ ```
1881
+
1882
+ The caching respects TTL and is semantically equivalent to the caching that every recursive DNS resolver already performs.
1883
+ Failed resolutions are not cached, and concurrent queries for the same records are coalesced into a single upstream query.
1884
+
1885
+ The constructor accepts an options Hash:
1886
+
1887
+ ```ruby
1888
+ Triauth::Resolvers::CachingResolver.new(resolver, {
1889
+ # The cache store: anything with `get(key)` and `set(key, value)` methods (synchronous); stored values are
1890
+ # JSON-serializable (a store that serializes them must restore Symbol keys with `JSON.parse(..., symbolize_names: true)`).
1891
+ # Defaults to an in-process Triauth::Helpers.create_lru_cache(1000).
1892
+ store: Triauth::Helpers.create_lru_cache(1000),
1893
+
1894
+ # The maximal time (seconds) an answer may be served from the cache, regardless of its records' TTLs.
1895
+ # This bounds the worst-case key-revocation latency added on top of regular DNS caching. Defaults to 300.
1896
+ maxTtl: 300,
1897
+
1898
+ # Cache lifetime (seconds) for answers whose records carry no TTL (every built-in resolver reports one,
1899
+ # SystemDns included; a custom resolver may not). Defaults to 0 (such answers are not cached).
1900
+ defaultTtl: 0,
1901
+
1902
+ # Cache lifetime (seconds) for empty answers (e.g., for non-existing identifiers).
1903
+ # Defaults to 0 (empty answers are not cached). Enable with care: a cached empty answer
1904
+ # can mask a transient resolution hiccup for its whole lifetime.
1905
+ negativeTtl: 0
1906
+ })
1907
+ ```
1908
+
1909
+ A per-call `{cache: false}` option fully bypasses the cache (if CachingResolver is used) for that call.
1910
+
1911
+ > [!WARNING]
1912
+ > **The cache store, if used, becomes part of your trusted computing base.**
1913
+ > Anyone able to write to the store can inject DNS records and forge authentications.
1914
+
1915
+ ### Randomness
1916
+
1917
+ The triauth-ruby library generates cryptographic nonces for every challenge it builds.
1918
+ By default, randomness is obtained from `SecureRandom.random_bytes` of the Ruby standard library, which is suitable for production use.
1919
+
1920
+ If you wish to supply your own source - for example a hardware-backed RNG, a system entropy pool wrapper, or a deterministic byte stream for tests - set `Triauth.config[:randomSource]` to a callable that receives a byte count and returns a binary String of exactly that many bytes.
1921
+
1922
+ ```ruby
1923
+ Triauth.config[:randomSource] = lambda do |n|
1924
+ # return `n` bytes (a binary String) from your source
1925
+ my_rng.read(n)
1926
+ end
1927
+ ```
1928
+
1929
+ Setting `Triauth.config[:randomSource] = nil` (the default) restores `SecureRandom.random_bytes`.
1930
+
1931
+ > [!WARNING]
1932
+ > A predictable `randomSource` undermines the unforgeability of challenges. Override the default only with a source you trust to provide cryptographic-quality entropy. The deterministic-bytes use case is intended for tests, not production.
1933
+
1934
+ ## Privacy Considerations
1935
+
1936
+ ### Requests to the authenticator
1937
+
1938
+ A sign-in request has no session key yet, so its challenge, with the identifier, is readable in the redirect URL.
1939
+ Some frameworks may log redirect URLs, so filter the redirect to the authenticator from your logs if you need to.
1940
+
1941
+ ### Identifier enumeration and private mode
1942
+
1943
+ Identity records, which contain public keys for a user's devices and the user's public profile, are publicly readable from DNS.
1944
+ Public readability does not necessarily make these records easy to discover: DNS lookups generally require a known or guessed name, although some DNS configurations allow zone enumeration.
1945
+ The domain's `mode` controls how easily these records can be linked to an identifier:
1946
+
1947
+ * `mode=public` - The usernames appear in plain text in the DNS labels (e.g., `john._at.triauthdemo.org`), so anyone can link an identifier to its identity domain and records, and vice versa.
1948
+ * `mode=private` (default) - Finding records for an identifier also requires its lookup code. Sharing this code allows others to look up the identity's records, but does not allow them to authenticate as that identity.
1949
+ Third parties (e.g., DNS resolvers) that see the DNS label (e.g., `_AXKAJNEUEX._at.triauthdemo.org`) cannot easily link it to any identifier without the lookup code.
1950
+
1951
+ For more details on how `mode=public` and `mode=private` work, see [triauth.org](https://www.triauth.org/).
1952
+
1953
+ ### DNS resolvers
1954
+
1955
+ With the default configuration, apart from your local DNS resolver, triauth-ruby uses the Google Public DNS and Cloudflare 1.1.1.1 to resolve
1956
+ DNS queries for identity records and authentication endpoints.
1957
+
1958
+ **Cloudflare 1.1.1.1**
1959
+ - Documentation - https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/
1960
+ - Privacy - https://developers.cloudflare.com/1.1.1.1/privacy/public-dns-resolver/
1961
+ - Terms - https://developers.cloudflare.com/1.1.1.1/terms-of-use/
1962
+
1963
+ **Google Public DNS**
1964
+ - Documentation - https://developers.google.com/speed/public-dns/docs/doh/json
1965
+ - Privacy - https://developers.google.com/speed/public-dns/privacy
1966
+ - Terms - https://developers.google.com/speed/public-dns/terms
1967
+
1968
+ [Back to TOC](#table-of-contents)
1969
+
1970
+ ## Best Practices and Security Considerations
1971
+
1972
+ * In step 3 of the challenge-response flow, **always pass the `challenge` from your own session store** - never from the HTTP request body or any other user-controlled input.
1973
+ The challenge is not server-signed, so trusting it from user input would allow an attacker to substitute a crafted challenge and authenticate as a different identity.
1974
+ The stored challenge also contains the key that decrypts the response. Keep it out of URLs and logs, because anyone with both can read the response.
1975
+
1976
+ * After step 3 of the challenge-response flow, regardless of its status, forget or invalidate the stored `challenge` to prevent replay attacks.
1977
+ You may also store each user's last seen `verifiedAt` and reject any results whose `issuedAt` is ≤ that value to block re-use of an old `(challenge, response)` pairs.
1978
+
1979
+ * Prefer a fixed, application-controlled `callbackUrl` over one derived from request data or other user input.
1980
+ The Triauth Authenticator identifies and remembers each site by the **base URL** of its `callbackUrl` (its origin plus the path up to and including the last `/`),
1981
+ and binds the session key of every sign-in, with its permissions, to that base URL and to the device that signed in.
1982
+
1983
+ * **Start stage 1 only from your own pages.**
1984
+ Protect the stage-1 endpoint as you would any form that changes state.
1985
+ Require a CSRF token, or reject the request when its `Sec-Fetch-Site` header is present and is not `same-origin`, as in the Sinatra example above. Never start stage 1 from a GET request.
1986
+
1987
+ * When you send the user to `redirectUrl`, **don't strip the `Referer`** - the referring origin is a security signal, and the Triauth Authenticator may reject a request that arrives without it, so it must see at least your origin (a full path isn't required).
1988
+ The browser default is fine; just avoid `rel="noreferrer"` and a `no-referrer`/`same-origin` `Referrer-Policy` (such as [Helmet](https://helmetjs.github.io/)'s default). See [Preserve the referrer when redirecting](#user-content-preserve-referrer).
1989
+ Make the redirect a top-level navigation, so that the user sees the authenticator's origin in the address bar.
1990
+
1991
+ * Many domains do not support or have [DNSSEC](https://en.wikipedia.org/wiki/Domain_Name_System_Security_Extensions) enabled, and authenticating identities from such domains will result in `{authenticated:true, secure:false, …}` status.
1992
+ It is usually OK to proceed in such a case, as by default triauth-ruby uses results from multiple DNS resolvers to gain different perspectives into the DNS landscape, limiting the potential for DNS poisoning and BGP based attacks.
1993
+ However, use caution when the `secure` attribute changes from `true` to `false` for the same user.
1994
+
1995
+ * If you require **strict cryptographic integrity** at the cost of losing compatibility with non-DNSSEC domains, set `config[:requireSecure] = true` (globally or as a per-call override) to have triauth-ruby enforce DNSSEC for you: any challenge-response result (and `Triauth.check`/`Triauth.verify`) that is not DNSSEC-secure is then rejected with [the error `dnssec_required`](#error-codes) instead of returning success.
1996
+
1997
+ * **Stamp and sign responses are transferable proofs and are not protected against replay.**
1998
+ A captured `Triauth.stamp` or `Triauth.sign` response can be replayed against any challenge that reuses the same `message` (and `attachments`) while its timestamp stays inside the freshness window: `config[:stampTimeout]` / `config[:signTimeout]`, widened by `config[:maximalAllowedClientClockDrift]` on both sides.
1999
+ If you need replay protection, you must embed a fresh, single-use nonce inside the `message` itself and verify its presence.
2000
+ A response also names the application it was made for in `via`. A server that accepts a response as proof of a session pins `via` to its own base URL in `Triauth.verify`, or a signature requested by another site verifies just as well.
2001
+
2002
+ * **Rate-limit the endpoints** (stage 1 and 3 of `authenticate`/`ping`/`sign`/`stamp`/`attest`), as you would with any endpoint that performs outbound network I/O.
2003
+ Stage 1 and stage 3 resolve the caller-supplied identifier through your configured DNS resolver, so an unthrottled endpoint lets an attacker drive sustained DNS lookups and server CPU for arbitrary identifiers.
2004
+
2005
+ * **Treat `deviceTag`, `lookupCode`, and `actorLookupCode` like the identifier.**
2006
+ Under `mode=private` the `deviceTag` carries a lookup code, which lets anyone who also knows the identifier read the identity's records, but not authenticate as it. For delegated authentication it also carries the actor's identifier.
2007
+ Do not display it, except to the user it belongs to.
2008
+
2009
+ * Count a ping or a stamp that does not return within your time limit, for example 30 seconds, as a failure.
2010
+ The authenticator does not answer a request that it cannot open, for example after the user made it forget your website.
2011
+ Only a success result is authenticated. The authenticator answers a denial with `false`. Anyone who knows your `callbackUrl` can send the same `false`, or any other string, to it from the user's browser.
2012
+
2013
+ * It is good practice to store the `deviceTag` and the `sessionKey` with the server-side, or encrypted cookie based user session, and to periodically call the `Triauth.check` or `Triauth.ping` to re-authenticate the user.
2014
+
2015
+ * Context-escape strings before rendering.
2016
+ Returned values (e.g., `ext[:publicProfile][:name]`, `groups` entries, device key's `options`, etc.) are returned verbatim, so an unescaped value rendered into HTML (or interpolated into a shell command, SQL query, etc.) is an injection vector. Apply the appropriate output encoding at the point of use.
2017
+
2018
+ * **Refresh session groups on every `Triauth.check` and `Triauth.ping`.**
2019
+ Both return the identity's current `groups` - replace any session-cached lists with it. Also, groups are read at verification time, so re-verifying a stored `sign`/`stamp` envelope yields the signer's *current* groups.
2020
+
2021
+ * **Triauth is not a complete replacement for a full, risk-adjusted, multi-step authentication system.**
2022
+ You should consider your website risk profile, and when needed, implement additional security measures such as two-step verification, and/or out-of-band multifactor authentication.
2023
+ In particular, when a triauth user signs in from a new device (`deviceTag`), or DNSSEC could not be used (`{secure:false}`).
2024
+
2025
+ * Under the hood, the SystemDns resolver relies on `Resolv::DNS` of the Ruby standard library for DNS resolution, which does not expose the DNSSEC status of an answer.
2026
+ As a result, the DNSSEC status of responses from the SystemDns resolver cannot be reliably determined.
2027
+ To address this, triauth-ruby uses the responses and DNSSEC status reported by other resolvers, such as Cloudflare and Google, to cross-validate the resolved records and set the value of the `secure` attribute in authentication response.
2028
+ Although `Resolv::DNS` does not directly support DNSSEC validation or status reporting, it is a good security practice to ensure that the recursive resolvers used by your system and `Resolv::DNS` (e.g., those provided by your hosting provider and configured in /etc/resolv.conf) support DNSSEC validation.
2029
+ A quick way to check your resolver for DNSSEC support is through the use of `host dnssec-failed.org` command. If this command succeeds and returns an IP address, it means that your system resolver does not support DNSSEC validation.
2030
+ In such a case, you can enhance security by configuring your system to use a different resolver, or by installing and using a local recursive DNS resolver such as [Unbound](https://www.nlnetlabs.nl/projects/unbound/about/) on your host.
2031
+
2032
+ * **Make sure the system resolver delivers large answers in full.**
2033
+ Identity records are usually small, but a user with many devices or other records published alongside them, can outgrow a single UDP datagram, and the answer must then arrive complete over TCP.
2034
+ Some DNS relays (home and office routers, container and VM network proxies, some ISP resolvers) cut such an answer down to their buffer without signalling truncation, and the affected users see failed sign-ins or `Triauth.check` reporting their devices as revoked.
2035
+ A quick way to check the resolver path your Ruby process uses is to ask it for the 40-record probe published in the triauth test zone: `ruby -rresolv -e 'n = Resolv::DNS.new.getresources("big.dns-test.triauthdemo.org", Resolv::DNS::Resource::IN::TXT).length; puts "#{n} of 40"'` (or `dig +short big.dns-test.triauthdemo.org TXT | wc -l`).
2036
+ Anything other than 40 means a relay on the way truncates, or blocks TCP on port 53.
2037
+ The user-mode networking proxy of QEMU (10.0.2.3), for example, delivers 20 of the 40 records, and the Azure-provided resolver (168.63.129.16) behind hosted Windows runners delivers 8, now and then without the truncation flag that makes a client retry over TCP.
2038
+ In such a case, point your system at a full recursive resolver - a public one such as 1.1.1.1 or 9.9.9.9, or a local [Unbound](https://www.nlnetlabs.nl/projects/unbound/about/), or configure a multi-resolver without SystemDns, since the DNS-over-HTTPS providers are not affected.
2039
+
2040
+ [Back to TOC](#table-of-contents)